# .NET Persistence

**Stack:** dotnet

**Layer:** infrastructure

## Intent

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](https://litespec.dev/4.0.0/backend/persistence.md); the `efcore` extension names the aggregates that use Entity Framework Core for storage instead of Marten.

## 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

**ID:** `litespec/backend/dotnet/persistence#stage-aggregate-writes-through-repositories`

**Strength:** rule

**Applies to:** artifact: command-handler

**Statement:** A command handler MUST reach persistence only through a Domain repository, never through `IDocumentSession`.

**Rationale:** 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.

**Checked by:** `dotnet build` reports `LS2004` for a command handler writing through a document session or database context.

### Query Through IQuerySession

**ID:** `litespec/backend/dotnet/persistence#query-through-iquerysession`

**Strength:** rule

**Applies to:** artifact: query-handler

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

**Rationale:** 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.

| Provider | Query surface |
|:---|:---|
| Marten baseline | `IQuerySession` |
| EF Core extension | The 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:

```csharp
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);
```

**Reviewed for:** Which query handlers project outside `IQuerySession`, or apply filter, scope, ordering, or limit after materialization? Look at each query handler file.

### Commit Once in the Command Pipeline

**ID:** `litespec/backend/dotnet/persistence#commit-once-in-the-command-pipeline`

**Strength:** rule

**Applies to:** artifact: any

**Statement:** Exactly one global command post-handler MUST call `SaveChangesAsync` for a command pipeline.

**Rationale:** 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.

**Reviewed for:** Which pipelines commit outside one post-handler file calling `SaveChangesAsync`? Look at the `SaveChangesAsync` calls in each pipeline.

### Enforce Uniqueness with Marten Unique Indexes

**ID:** `litespec/backend/dotnet/persistence#enforce-uniqueness-with-marten-unique-indexes`

**Strength:** rule

**Applies to:** artifact: aggregate

**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.

**Rationale:** 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:

```csharp
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:

```csharp
// The payout document id equals the expense id: one payout per expense.
session.Store(new PayoutDocument(expense.Id, payoutNumber));
```

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

### Claim Cross-Record Keys with a Claims Document

**ID:** `litespec/backend/dotnet/persistence#claim-cross-record-keys-with-a-claims-document`

**Strength:** rule

**Applies to:** artifact: repository

**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`.

**Rationale:** `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:

```csharp
public sealed record BarcodeClaim(string Barcode);
```

```csharp
// The claim document id is the barcode text; Insert fails on a duplicate.
session.Insert(new BarcodeClaim(barcode));
```

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

### Map Constraint Violations with the PostgreSQL Refusal Classifier

**ID:** `litespec/backend/dotnet/persistence#map-constraint-violations-with-the-postgresql-refusal-classifier`

**Strength:** rule

**Applies to:** artifact: any

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

**Rationale:** 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:

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

**Reviewed for:** Which unique index names lack a conflict refusal code in the PostgreSQL refusal classifier? Look at the service registration.

### Apply Optimistic Concurrency to Every Aggregate Document

**ID:** `litespec/backend/dotnet/persistence#apply-optimistic-concurrency-to-every-aggregate-document`

**Strength:** rule

**Applies to:** artifact: aggregate

**Statement:** Each Marten aggregate document type MUST enable optimistic concurrency.

**Rationale:** 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:

```csharp
options.Schema.For<ExpenseDocument>().UseOptimisticConcurrency(true);
```

**Reviewed for:** Which Marten aggregate document types skip optimistic concurrency? Look at each document type and the document policy.

### Map Marten Concurrency Faults to Conflict Refusals

**ID:** `litespec/backend/dotnet/persistence#map-marten-concurrency-faults-to-conflict-refusals`

**Strength:** rule

**Applies to:** artifact: any

**Statement:** A classifier MUST translate Marten's `ConcurrencyException` into the use case conflict refusal.

**Rationale:** 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.

**Reviewed for:** Which `ConcurrencyException` paths at commit escape the use case conflict refusal? Look at the registered classifier.

## Conventions

### Use the Persistence Layer Folder Layout

**ID:** `litespec/backend/dotnet/persistence#use-the-persistence-layer-folder-layout`

**Strength:** default

**Applies to:** artifact: any

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

**Replacement:** A project names the folders holding its persistence types in its project documentation.

**Rationale:** 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.

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

### Register One Scoped Session

**ID:** `litespec/backend/dotnet/persistence#register-one-scoped-session`

**Strength:** default

**Applies to:** artifact: any

**Default:** Register `IDocumentSession` with the WebApi request or Worker operation scope.

**Replacement:** A project names the session scope each request or operation uses in its project documentation.

**Rationale:** 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.

**Reviewed for:** Does the service registration wire `IDocumentSession` once per WebApi request or Worker operation scope? Look at the service registration.

## Reference Example

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

```csharp
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);
    }
}
```
