Type to search pages and rules.

Browse pages

.NET Persistence

A .NET backend persists aggregates through Marten, the baseline document store: command handlers stage writes through Domain repositories, queries project through the read session, and one post-handler commits each command pipeline. This page states those .NET bindings with the Infrastructure folder layout and session lifetime they assume, so every command keeps one transaction. The shared persistence rules live on Persistence; the efcore extension names the aggregates that use Entity Framework Core for storage instead of Marten.

Markdown10 provisions

Stack: dotnet

Layer: infrastructure

Concepts#

Sessions, Repositories, and the One Commit#

One scoped session serves a whole command: the Domain repositories stage aggregate writes through it, and exactly one command post-handler commits it. Query handlers read through the read session instead, projecting shaped records without loading aggregates. Only that post-handler commits, so a failed command leaves nothing stored. The declared consistency mode decides how work splits across commands; each command still commits once.

The Baseline Provider and Its Replacement#

Marten is the baseline provider, supplying the session, the read session, and document storage. The efcore extension replaces no baseline provision; every provision on this page binds those paths unchanged.

Standards#

Stage Aggregate Writes Through Repositories#

rulerulecheckable
litespec/backend/dotnet/persistence#stage-aggregate-writes-through-repositories
Statement

A command handler MUST reach persistence only through a Domain repository, never through IDocumentSession.

Applies toartifact: command-handler

Why

Infrastructure implements each Domain repository with the scoped session, so the handler stages work without naming a provider type. A repository that exposed the session, a generic query, or a save operation would hand the handler the provider surface the layer exists to hide. This provision binds litespec/backend/persistence#stage-aggregate-writes-through-domain-repositories for .NET.

Checkchecked by a tool

Checked bydotnet build reports LS2004 for a command handler writing through a document session or database context.

Marked checkable: the tool named here checks it.

Query Through IQuerySession#

ruleruleguidance
litespec/backend/dotnet/persistence#query-through-iquerysession
Statement

A query handler MUST project through IQuerySession, applying filter, scope, ordering, and limit before materialization.

Applies toartifact: query-handler

Why

The read session already filters, orders, pages, and projects; a read-store interface per aggregate, a generic read repository, or a callback wrapper adds a layer that repeats it. Ordering and paging follow litespec/backend/api#bound-collection-queries. This provision binds litespec/backend/persistence#query-through-the-read-session for .NET.

ProviderQuery surface
Marten baselineIQuerySession
EF Core extensionThe context type the extension declares

The table names the binding per provider. The obligation to project with filter, scope, ordering, and limit holds under each.

A table the project keeps beside its documents, such as a counter or an append-only ledger written through plain SQL, is read through the same session. Under Marten, AdvancedSql.QueryAsync<TRow> returns any JSON-serializable type, so the SQL selects one JSON object per row, keyed in the casing the store's serializer uses, with the filter, ordering and limit in its WHERE, ORDER BY and LIMIT. The handler keeps no repository for such a table, and the project adds no read interface for it.

Example

In the Claimwick product, each spending owner's running totals live in a spending_totals table that a command updates in place. The read-spending-totals query reads it through the session:

C#
internal sealed record SpendingTotalRow(Guid OwnerId, string Currency, decimal Submitted, decimal Approved);

var rows = await session.AdvancedSql.QueryAsync<SpendingTotalRow>(
    """
    select jsonb_build_object(
        'ownerId', owner_id, 'currency', currency, 'submitted', submitted, 'approved', approved)
    from spending_totals
    where owner_id = ?
    order by currency
    limit 100
    """,
    cancellationToken,
    query.OwnerId.Value);
Checka reviewer answers

Reviewed forWhich query handlers project outside IQuerySession, or apply filter, scope, ordering, or limit after materialization? Look at each query handler file.

Marked guidance: a reviewer answers this question.

Commit Once in the Command Pipeline#

ruleruleguidance
litespec/backend/dotnet/persistence#commit-once-in-the-command-pipeline
Statement

Exactly one global command post-handler MUST call SaveChangesAsync for a command pipeline.

Applies toartifact: any

Why

Exactly one post-handler commits, so a failed command leaves the scoped session uncommitted and no partial work persists. A second committer, in a handler, repository, validator, reaction, job, or endpoint, would store work the pipeline later refuses. This provision binds litespec/backend/persistence#commit-each-command-pipeline-once for .NET.

The obligation is one commit for one pipeline, not one named method. Under the baseline provider that commit is IDocumentSession.SaveChangesAsync. For aggregates the efcore extension names it is DbContext.SaveChangesAsync. The pipeline wraps the call in an explicit transaction when it writes through more than one context. Under consistency: atomic that commit covers everything the handler staged. Other modes split work across commands: each command still commits once, and the job resumes from its cursor or runs the declared compensation.

Checka reviewer answers

Reviewed forWhich pipelines commit outside one post-handler file calling SaveChangesAsync? Look at the SaveChangesAsync calls in each pipeline.

Marked guidance: a reviewer answers this question.

Enforce Uniqueness with Marten Unique Indexes#

ruleruleguidance
litespec/backend/dotnet/persistence#enforce-uniqueness-with-marten-unique-indexes
Statement

Each Marten aggregate document MUST enforce its uniqueness rules through a unique index over the stored value or an identity derived from its owner.

Applies toartifact: aggregate

Why

A unique index makes the store arbitrate the race at commit, where every concurrent write is visible. The index is declared with options.Schema.For<T>().UniqueIndex, or as a computed index carrying IsUnique, and the index name follows litespec/backend/persistence#use-database-naming-conventions. A record that exists at most once per owner takes a document id equal to the owner id instead, so a second record for the same owner collides on identity. This provision binds litespec/backend/persistence#enforce-uniqueness-in-the-store for .NET.

Example

The payout number is unique per spending owner:

C#
options.Schema.For<PayoutDocument>().UniqueIndex(
    UniqueIndexType.Computed,
    "uq_payout_owner_number",
    x => x.PayoutNumber);

A payout exists at most once per expense, so its document id is the expense id:

C#
// The payout document id equals the expense id: one payout per expense.
session.Store(new PayoutDocument(expense.Id, payoutNumber));
Checka reviewer answers

Reviewed forWhich Marten aggregate documents with a uniqueness rule lack a unique index or an owner-derived identity? Look at the store configuration.

Marked guidance: a reviewer answers this question.

Claim Cross-Record Keys with a Claims Document#

ruleruleguidance
litespec/backend/dotnet/persistence#claim-cross-record-keys-with-a-claims-document
Statement

Each cross-record uniqueness key MUST be claimed through a claims document keyed by the key text and written with session.Insert, never with Store.

Applies toartifact: repository

Why

Store upserts, so it overwrites a competing claim instead of colliding with it; Insert fails when the key document already exists, which is the arbitration the rule needs. The repository compares the aggregate keys at load with the keys at commit, inserts the added key documents and deletes the removed ones in the same transaction. Marten cannot index elements of a list inside a document, cannot enforce uniqueness across document types, and computed indexes do not work on DateTime or DateTimeOffset members; those cases use the claims document. This provision binds litespec/backend/persistence#claim-a-cross-record-key-at-commit for .NET.

Example

In the library product, each copy barcode belongs to one book, while a book holds its barcodes in a list:

C#
public sealed record BarcodeClaim(string Barcode);
C#
// The claim document id is the barcode text; Insert fails on a duplicate.
session.Insert(new BarcodeClaim(barcode));
Checka reviewer answers

Reviewed forWhich cross-record keys bypass the claims document with session.Insert, or use Store on the claims type? Look at each key claim.

Marked guidance: a reviewer answers this question.

Map Constraint Violations with the PostgreSQL Refusal Classifier#

ruleruleguidance
litespec/backend/dotnet/persistence#map-constraint-violations-with-the-postgresql-refusal-classifier
Statement

Each Marten unique index MUST map by name to its conflict refusal code in PostgreSqlConstraintRefusalClassifier.

Applies toartifact: any

Why

The LiteBus.Storage.PostgreSql.PostgreSqlConstraintRefusalClassifier carries the map from index name to refusal code and is registered through UseFaultRefusalClassifierInstance, so the pipeline fault classification turns a unique violation into the use case named conflict refusal under litespec/backend/application#refuse-a-lost-race-as-a-conflict. This provision binds litespec/backend/persistence#map-a-constraint-violation-to-its-refusal for .NET.

Example

The barcode and payout number indexes map to their refusals:

C#
services.UseFaultRefusalClassifierInstance(
    new PostgreSqlConstraintRefusalClassifier(new Dictionary<string, string>
    {
        ["uq_book_barcode"] = "book-barcode-taken",
        ["uq_payout_owner_number"] = "spending.payout-number-taken",
    }));
Checka reviewer answers

Reviewed forWhich unique index names lack a conflict refusal code in the PostgreSQL refusal classifier? Look at the service registration.

Marked guidance: a reviewer answers this question.

Apply Optimistic Concurrency to Every Aggregate Document#

ruleruleguidance
litespec/backend/dotnet/persistence#apply-optimistic-concurrency-to-every-aggregate-document
Statement

Each Marten aggregate document type MUST enable optimistic concurrency.

Applies toartifact: aggregate

Why

The store compares the version each command loaded with the version it commits against, so a stale write fails the commit instead of silently overwriting newer work. Each aggregate document type enables it with UseOptimisticConcurrency(true), or one Policies.ForAllDocuments policy covers every aggregate document type at once; no aggregate document type is left without it. This provision binds litespec/backend/persistence#version-every-aggregate for .NET.

Example

The expense document enables the check on its own type:

C#
options.Schema.For<ExpenseDocument>().UseOptimisticConcurrency(true);
Checka reviewer answers

Reviewed forWhich Marten aggregate document types skip optimistic concurrency? Look at each document type and the document policy.

Marked guidance: a reviewer answers this question.

Map Marten Concurrency Faults to Conflict Refusals#

ruleruleguidance
litespec/backend/dotnet/persistence#map-marten-concurrency-faults-to-conflict-refusals
Statement

A classifier MUST translate Marten's ConcurrencyException into the use case conflict refusal.

Applies toartifact: any

Why

A ConcurrencyException names a provider type that Application does not expose. The registered classifier translates it into the use case named conflict refusal, following litespec/backend/application#refuse-a-lost-race-as-a-conflict, so a version race and a uniqueness race reach the caller through the same refusal path. This provision binds litespec/backend/application#refuse-a-lost-race-as-a-conflict for .NET.

Example

Two approvers race on one submitted expense; the loser throws ConcurrencyException at commit, and the classifier returns the approve conflict refusal so the caller re-reads the expense before deciding again.

Checka reviewer answers

Reviewed forWhich ConcurrencyException paths at commit escape the use case conflict refusal? Look at the registered classifier.

Marked guidance: a reviewer answers this question.

Conventions#

Use the Persistence Layer Folder Layout#

conventiondefaultguidance
litespec/backend/dotnet/persistence#use-the-persistence-layer-folder-layout
Default

Place Marten configuration, repositories, commit behavior, and serialization in the shared kernel and module Infrastructure folders this provision names.

ReplacementA project names the folders holding its persistence types in its project documentation.

Applies toartifact: any

Why

One store stays the default, configured in the shared kernel Infrastructure folder, while each module mappings and repositories live in its own Infrastructure folders, so every persistence type has one discoverable home. Scattered configuration hides the provider setup across the product. This provision binds litespec/backend/persistence#use-one-persistence-folder for .NET.

Example

Text
{Product}.SharedKernel/
  Infrastructure/
    Marten/
      MartenServiceRegistration.cs
      MartenStoreConfiguration.cs
      Commands/
        SaveChangesCommandPostHandler.cs
      Events/
        DomainEventBuffer.cs
{Product}.Modules/
  Posts/                            single aggregate whose name matches the module: files directly under the module
    Infrastructure/
      PostRepository.cs
      PostMartenConfiguration.cs
  Customers/                        two aggregates: one folder per aggregate
    CustomerAccounts/
      Infrastructure/
        CustomerAccountRepository.cs
        CustomerAccountMartenConfiguration.cs
    Consents/
      Infrastructure/
        ConsentRepository.cs
        ConsentMartenConfiguration.cs

The module folders follow litespec/backend/architecture#organize-code-by-module-aggregate-and-use-case. A single-aggregate module stays flat when its aggregate root plural name equals the module name. For example, Post stays flat under Posts. Otherwise, each aggregate takes its own folder.

Aggregate-specific configuration stays beside its aggregate, while session and commit plumbing stay in the shared kernel Infrastructure folder.

Checka reviewer answers

Reviewed forWhich Marten configuration, repository, commit, or serialization files sit outside their shared kernel or module Infrastructure folder? Look at the persistence files.

Marked guidance: a reviewer answers this question.

Register One Scoped Session#

conventiondefaultguidance
litespec/backend/dotnet/persistence#register-one-scoped-session
Default

Register IDocumentSession with the WebApi request or Worker operation scope.

ReplacementA project names the session scope each request or operation uses in its project documentation.

Applies toartifact: any

Why

One session per WebApi request or Worker operation means the repositories and the commit handler in one command resolve the same instance. Two sessions would stage and commit separately, splitting one command across two units of work. This provision binds litespec/backend/persistence#use-one-session-per-command for .NET.

Checka reviewer answers

Reviewed forDoes the service registration wire IDocumentSession once per WebApi request or Worker operation scope? Look at the service registration.

Marked guidance: a reviewer answers this question.

Reference Example#

This informative example demonstrates litespec/backend/dotnet/persistence#stage-aggregate-writes-through-repositories.

C#
internal sealed class PostRepository(
    IDocumentSession session,
    DomainEventBuffer eventBuffer) : IPostRepository
{
    public async Task<Post> GetByIdAsync(
        PostId id,
        CancellationToken cancellationToken)
    {
        var post = await session.LoadAsync<Post>(id, cancellationToken);
        return post ?? throw new PostNotFoundException();
    }

    public void Store(Post post)
    {
        session.Store(post);
        eventBuffer.Track(post);
    }
}