Skip to content
bitzorcas
中EN

Reference

Persistence building block

Unified aggregates, compile-time ORM configuration, Command/ReadModel/Query ports, and production-parallel SqlSugar/EF Core contracts.

Last updated

BitzOrcas persistence does not let business code call three interchangeable ORM APIs. Business code depends on stable ports and provider differences remain inside adapters. SqlSugar and EF Core are production-parallel targets for standard capabilities. Dapper is limited to registered read-only Query Store scenarios.

Critical path diagram

Follow the main path to identify responsibility handoffs, then use the prose to inspect failure branches and evidence.

Use case

Store port

SqlSugar or EF adapter

Database transaction

Post-commit effects

Overall structure

Application use case
│
├─ Command Repository / Store ── save, restore, delete aggregate state
├─ ReadModelStore ────────────── standard detail, list, filter, sort, page
└─ QueryStore ────────────────── join, timeline, group-by, report
│
▼
Provider-neutral contracts
│
┌────────┴────────┐
▼ ▼
SqlSugar Adapter EF Core Adapter
│ │
└────────┬────────┘
▼
SQL Server + CAP Outbox
Dapper Adapter ── explicit read-only complex-query exception; no write UoW

Persistence:Provider selects the runtime provider. That choice must not leak into Contracts, Domain, Application, handlers, query DTOs, or aggregates.

Default write model: the unified aggregate

An ordinary one-to-one, read/write-consistent aggregate is declared once:

// ① Focus on the contract and control flow; keep validation, cancellation, and typed errors explicit.
[BitzTable("SandboxNote", IsTenant = true, IsSoftDelete = true)]
public sealed class Note : TenantAggregateRoot<string>
{
// ② Ignore the value object itself; generated configuration maps through the existing Name column.
[BitzColumn(Ignore = true)]
public NoteTitle Title { get; private set; }
[BitzColumn(ColumnName = "Name", Length = 200, IsRequired = true)]
public string TitleName
{
get => Title.Value;
// ③ Rehydrate the domain representation through the value-object factory on database reads.
private set => Title = NoteTitle.From(value);
}
}

The default completed state has no:

  • NoteEntity.cs mirroring the aggregate one-to-one;
  • two-way mapper that only copies same-name fields;
  • assembly-level Mapping Specs;
  • empty Query Translator created only to satisfy DI.

[BitzTable], [BitzColumn], and [BitzIndex] are provider-neutral metadata. At compile time, the ORM Fluent Configuration Generator reads Roslyn symbols and emits:

  • EF Core model configuration;
  • SqlSugar static mapping and schema metadata;
  • SmartEnum, value-object, JSON, precision, length, and index configuration;
  • key, audit, tenant, soft-delete, and concurrency accessors;
  • diagnostics for unsupported shapes.

Runtime reflection must not inspect these attributes to build mappings.

When a separate persistence model is valid

A genuinely asymmetric storage shape is allowed—for example, an aggregate spanning multiple tables, immutable history rows, or a dedicated reporting model. The exception needs:

  1. evidence explaining why aggregate and storage shapes differ;
  2. module-local mapping and restore logic;
  3. tenant, child synchronization, and deletion behavior tests;
  4. a SqlSugar/EF Core parity status;
  5. a deletion condition or long-term retention reason.

“This is how older modules were written” is not evidence for another *Entity.

Three port families

Command Repository / Store

Use it to save, restore when consistency requires it, and delete aggregate state. The pipeline and IUnitOfWork own transaction handling; handlers do not commit. A unified aggregate repository uses one type directly and needs no dummy mapper.

ReadModelStore

Use it for standard lists and details. The Query Shape Generator compiles field, fixed sort, filter, paging, and projection declarations, and the adapter pushes them into the database:

query input declaration
→ generated QueryDescriptor
→ FilterInput / SortRequest / PageRequest
→ provider executor
→ database projection
→ DTO

Do not page aggregates and map DTOs in memory. Do not accept arbitrary user-provided column names as sort expressions.

QueryStore

Use it for business reads that Query Shape should not carry: timelines, inboxes, histories, joins, exists, group-by, unions, and report sources. The interface describes the business question and does not expose IQueryable, DbContext, ISqlSugarClient, a connection, or an SQL string.

Dapper may implement one of these read-only Infrastructure ports. User input is parameterized or compiled into fixed choices first. Architecture gates prohibit new hand-written T-SQL strings in C# source.

SqlSugar and EF Core completion states

Both providers are production targets for standard business capabilities:

StatusMeaning
Production ParallelBoth implementations exist and pass the same port behavior contract
Parity BlockerOne side or shared behavioral evidence is missing; production completion cannot be claimed
Explicit ExceptionDeliberately outside dual-ORM scope with an ADR/matrix entry and separate test strategy

A switchable claim needs evidence for at least:

  • successful commit, explicit-failure rollback, and exception rollback;
  • tenant isolation, soft delete, audit fields, and optimistic concurrency;
  • port-specific save, find, page, or complex-query semantics;
  • no provider-type leakage into Application or Domain;
  • architecture tests that detect one-sided adapters and one-sided contract tests.

The EF Core project currently sets IsTrimmable=false. This isolates trim behavior inside the adapter; it does not make EF Core a secondary or development-only implementation. SqlSugar keeps the trim-clean path, while both providers share the same business completion standard.

Tenant, soft-delete, audit, and concurrency

Generated metadata and provider adapters cooperate on cross-cutting behavior:

  • ITenantEntity is filtered by current persistence context by default;
  • ISoftDelete hides deleted rows by default;
  • IAuditableEntity is populated from clock and current-user context;
  • IConcurrencyTracked.Version supports optimistic concurrency.

Global filters do not make every operation automatically safe. Primary-key updates, bulk deletes, complex queries, and multi-table child rows still prove tenant and concurrency semantics in their port contracts.

Transactions, domain events, and Outbox

The transaction behavior wraps Command handlers. Aggregate writes and integration messages requiring atomic publication commit with CAP Outbox under adapter contracts.

AggregateRepositoryBase takes an optional IDomainEventCollector. On Save/Delete/SaveRange/DeleteRange, it runs a pre-write guard: if the aggregate is an IDomainEventSource with pending events but the collector is missing (not registered) or does not expose IDomainEventDispatchCapability, it throws and points to AddBitzOrcasDomainEventDispatchRuntime. After a successful write it calls Track(source). AddBitzOrcasSqlSugarWithCap / AddBitzOrcasEfCoreWithCap register the dispatch runtime internally; a Host that wires a repository without CAP must call AddBitzOrcasDomainEventDispatchRuntime explicitly.

A Handler returns a Result:

  • a business failure returns a failed result and the transaction behavior rolls back;
  • an unexpected exception propagates, rolls back, and becomes Problem Details at the Host;
  • the Handler does not call CommitAsync() itself or manually republish an event after saving.

Default port policy and unavailable failure contract

Every persistence port declares a PersistenceDefaultPolicy that governs what the DI source generator emits when no production adapter is registered:

PolicyBehavior
Auto (default)Generator emits a business-closed default implementation, but only for interfaces whose every member returns Result/Result<T>/Task<Result…>/ValueTask<Result…> (BODI028 otherwise).
FailLoudGenerator emits a surrogate that throws / returns faulted async results.
RequiredNo default is registered; the port enters the compile-time Required Manifest and fails Production/Staging startup if unresolved.

Raw-return-value ports (members not returning Result) must explicitly select FailLoud or Required — the generator refuses to guess. The policy is carried on [FailClosedPort], [FailClosedPort<TService>], [RegisterPersistenceAdapter<TService>], and [RegisterOrmAdapter<TService>].

A FailLoud port throws PersistenceUnavailableException carrying the stable error System.PersistenceUnavailable. PersistenceUnavailableExceptionHandler (auto-registered by AddBitzOrcasProblemDetails) maps it to RFC 9457 503 with title “Service Unavailable” and an errorCode extension, never leaking the inner exception, connection string, or stack. PersistenceCapability flags (Database/Cap/Search/FileStorage) declare which infrastructure a port requires, and ProductionAdapterReadinessGuard resolves every Required port at startup and fails closed if any resolves to the unavailable proxy.

Compile-time ORM selection

In addition to the runtime Persistence:Provider setting, a Consumer Host csproj can narrow the transitive dual-ORM closure at compile time with the MSBuild property:

<PropertyGroup>
<BitzOrcasOrmProvider>SqlSugar</BitzOrcasOrmProvider>
<!-- or EfCore -->
</PropertyGroup>

The DI source generator reads build_property.bitzorcasormprovider and drops the unselected provider’s implementations from the closure. Three blocking diagnostics guard it: BODI020 (aggregate repository needs a referenced ORM provider package), BODI021 (BitzOrcasOrmProvider value is not SqlSugar or EfCore), and BODI022 (selected provider’s implementation types are not resolvable — add the matching BitzOrcas.Framework.Infrastructure provider PackageReference). This is a distinct mechanism from runtime Persistence:Provider; set both consistently.

Schema and migrations

Generated schema metadata supports provider configuration and initialization, but production evolution still needs reviewable, idempotent, fail-closed migration evidence. A migration validates old data before changing constraints or backfilling fields. Unsafe input stops the migration instead of being guessed into shape.

First-environment initialization, seed data, version upgrades, and rollback have different responsibilities. A development CodeFirst path creating tables does not prove a production upgrade. See Database migrations and Upgrading.

Configuration example

{
"ConnectionStrings": {
"Default": "Server=localhost;Database=BitzOrcas;..."
},
"Persistence": {
"Provider": "SqlSugar"
},
"RabbitMq": {
"Host": "localhost",
"Port": 5672
}
}

Changing the provider to EfCore changes only the adapter selected by the composition root. If a port remains a Parity Blocker, neither runtime status nor documentation may describe it as fully switchable.

Selection checklist

Changing aggregate state?
→ Command Repository / Store
Standard detail, list, filter, sort, page, and DTO projection?
→ ReadModelStore + generated Query Shape
Join, timeline, group-by, history, or report?
→ Dedicated QueryStore, with dual-adapter or explicit-exception status
About to add Entity + Mapper?
→ Prove asymmetric storage first; otherwise persist the unified aggregate

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%