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.
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 UoWPersistence: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.csmirroring 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:
- evidence explaining why aggregate and storage shapes differ;
- module-local mapping and restore logic;
- tenant, child synchronization, and deletion behavior tests;
- a SqlSugar/EF Core parity status;
- 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 → DTODo 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:
| Status | Meaning |
|---|---|
Production Parallel | Both implementations exist and pass the same port behavior contract |
Parity Blocker | One side or shared behavioral evidence is missing; production completion cannot be claimed |
Explicit Exception | Deliberately 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:
ITenantEntityis filtered by current persistence context by default;ISoftDeletehides deleted rows by default;IAuditableEntityis populated from clock and current-user context;IConcurrencyTracked.Versionsupports 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:
| Policy | Behavior |
|---|---|
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). |
FailLoud | Generator emits a surrogate that throws / returns faulted async results. |
Required | No 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