Type to search pages and rules.

Browse pages

.NET Application Layer

Application code in a .NET backend sends commands and queries through mediators from LiteBus, the pinned messaging library: commands change business state, queries read without changing it, and validators report structural input problems as LiteBus validity values. This page binds that dispatch for .NET: the command-handler shape, the validity shape, the query-handler shape, the entry point for each declared effect, the use case folder layout, the pinned mediator methods, and the pinned pipeline bindings. The shared coordination rules live on Application Layer; this page holds only their .NET bindings.

Markdown9 provisions

Stack: dotnet

Layer: application

Concepts#

Commands, Queries, and Mediators#

A command is a message that changes business state; a query reads a read model without changing state. The boundary sends the message through a LiteBus mediator, and the command pipeline around the handler runs validation, authorization, the handler itself, and then one commit. Commands travel through ICommandMediator and queries through IQueryMediator, so the write path and the read path stay visibly separate at every call site.

A reaction answers a domain event or an integration event in one class carrying one mark naming its reaction record. A scheduled or manual job runs its declared commands once per item in its selection.

Validity Findings#

A validator checks structural input, such as an empty title or a malformed identifier, before Domain runs. Each finding names the input member, a stable reason code, and a caller-safe message. The pipeline runs every validator for the message and merges the findings, so the caller learns about all input problems in one answer instead of fixing them one round trip at a time.

Standards#

Keep Command Handlers to Load, Call and Stage#

ruleruleguidance
litespec/backend/dotnet/application#keep-command-handlers-to-load-call-and-stage
Statement

A command handler MUST load or create one aggregate through its repository, call one domain method on it, stage it, and return the result.

Applies toartifact: command-handler

Why

The handler body has three steps: the load or creation, the one domain call, and the stage. A required load uses the throwing GetByIdAsync and an optional lookup uses the nullable FindBy...Async under litespec/backend/dotnet/domain#keep-repository-interfaces-in-domain. The message carries the pipeline-stamped timestamp as HandledAt under litespec/backend/application#take-one-timestamp-per-message (see litespec/backend/dotnet/application#bind-the-pipeline-to-the-pinned-litebus-apis), so the handler stamps decisions without reading a clock. Validation, authorization, idempotency, audit, holds, and the commit are pipeline stages; messages, provider calls, and writes to other aggregates run in event handlers after the commit under litespec/backend/dotnet/application#dispatch-declared-effects-through-their-entry-points. A use case whose record declares consistency: atomic over several aggregates is the one exception: the handler loads, calls, and stages each aggregate its changes names, one method each. This provision binds litespec/backend/application#keep-command-handlers-to-load-call-and-stage for .NET.

Example

C#
internal sealed class ApproveExpenseCommandHandler(IExpenseRepository expenseRepository)
    : ICommandHandler<ApproveExpenseCommand, ApproveExpenseCommandResult>
{
    public async Task<ApproveExpenseCommandResult> HandleAsync(ApproveExpenseCommand command, CancellationToken cancellationToken)
    {
        var expense = await expenseRepository.GetByIdAsync(command.ExpenseId, cancellationToken);
        expense.Approve(command.ApproverId, command.HandledAt);
        expenseRepository.Store(expense);
        return new ApproveExpenseCommandResult(expense.Id);
    }
}
Checka reviewer answers

Reviewed forWhich command handlers do more than load or create one aggregate, call one domain method, stage it, and return? Look at each command handler body; dotnet build reports find-or-create as LS2014 and looped stores as LS2015.

Marked guidance: a reviewer answers this question.

Report Input Problems as Validity#

ruleruleguidance
litespec/backend/dotnet/application#report-input-problems-as-validity
Statement

A validator MUST report its findings as Validity, with each ValidationFailure carrying a member, a code, and a message, and no HTTP status.

Applies toartifact: validator

Why

Every validator for the message runs, and the pipeline merges the findings before it refuses the request, so the caller fixes the whole form at once. The member tells the caller which input failed, the stable code lets a client branch on the reason, and the message explains the problem in words safe to show. Validity values come from the pinned mediator's abstractions, named in litespec/backend/dotnet/application#use-the-pinned-mediator-entry-points, so every project reports the same shape. This provision binds litespec/backend/application#report-input-problems-as-validity-findings for .NET.

Checka reviewer answers

Reviewed forWhich validators report outside Validity, or skip member, code, or message, or leak HTTP status? Look at each validator.

Marked guidance: a reviewer answers this question.

Keep Query Handlers Free of Writes#

ruleruleguidance
litespec/backend/dotnet/application#keep-query-handlers-free-of-writes
Statement

A query handler MUST take no repository and stage, store, delete or dispatch nothing.

Applies toartifact: query-handler

Why

A read path acts on no invariant, so a repository in a query handler loads an aggregate the query has no write to justify. The handler projects through the read session under litespec/backend/dotnet/persistence#query-through-iquerysession. A read that writes makes retries and caching unsafe; an audited read is the pipeline's work, not the handler's. dotnet build reports a repository in a query handler as LS2011. This provision binds litespec/backend/application#keep-query-handlers-free-of-writes and the repository rule in litespec/backend/application#project-queries-directly for .NET.

Example

The list-expenses query projects its rows through IQuerySession, taking no repository and staging nothing.

Checka reviewer answers

Reviewed forWhich query handlers take a repository, skip IQuerySession, or stage, store, delete, or dispatch? Look at each query handler; dotnet build reports a repository in a query handler as LS2011.

Marked guidance: a reviewer answers this question.

Dispatch Declared Effects Through Their Entry Points#

rulerulecheckable
litespec/backend/dotnet/application#dispatch-declared-effects-through-their-entry-points
Statement

An event handler MUST dispatch each declared sends effect through the messenger port, each declared calls effect through its external-system port, and each declared runs effect through ICommandMediator, after the commit that stored the fact it answers, while the aggregate raises each declared emits effect as a domain event the outbox stages in the same commit.

Applies toartifact: reaction, job

Why

A command handler changes its aggregate and returns; it dispatches nothing. The aggregate raises its facts, and the outbox stages them in the same commit through outbox.StageCollectedEvents() (see litespec/backend/dotnet/application#bind-the-pipeline-to-the-pinned-litebus-apis). After that commit, an event handler answers each fact through its entry point: the messenger port for sends, its external-system port for calls, and ICommandMediator for runs. An inline runs entry states whether it shares the caller's transaction. A scheduled or manual job dispatches through the same entry points. Each handler gets its own delivery under litespec/ext/outbox#deliver-each-event-to-each-handler-separately, so one failing handler retries alone. An undeclared dispatch fails effect-undeclared, whatever its entry point. This provision binds litespec/backend/application#dispatch-only-declared-effect-edges for .NET.

Example

C#
// The commit step stores the changed aggregate and stages the facts it raised in one transaction.
outbox.StageCollectedEvents();
Checkchecked by a tool

Checked bylitespec model check reports effect-undeclared for a dispatch no record declares; each event handler dispatches sends through the messenger port, calls through its external-system port, and runs through ICommandMediator, after the commit.

Marked checkable: the tool named here checks it.

Run Reactions in Dedicated Handlers#

ruleruleguidance
litespec/backend/dotnet/application#run-reactions-in-dedicated-handlers
Statement

A reaction MUST run in one class carrying one mark naming its reaction record.

Applies toartifact: reaction

Why

One class per reaction answers the trigger the record names in triggeredBy and dispatches only through its effects entries. The class carries the mark reaction/<module>.<name> in the declared style, so the stack reader links it without guessing. It lives in the On{Fact} folder inside its module UseCases/ folder under litespec/workspace/naming#group-event-handlers-by-the-fact-they-handle, beside every other handler of the same fact. It never writes an aggregate directly: a reaction that needs a write runs a command. It honors the record's reads, its idempotency, and its onFailure. This provision binds litespec/backend/application#keep-reaction-implementations-explicit for .NET.

Example

C#
// Lending/UseCases/OnLoanBorrowed/SendDueNoticeOnLoanBorrowedHandler.cs
/// <implements>reaction/lending.send-due-notice-on-loan-borrowed</implements>
internal sealed class SendDueNoticeOnLoanBorrowedHandler(...)
{
    // Answers event/loan.loan-borrowed by sending notification/lending.loan-due.
}
Checka reviewer answers

Reviewed forWhich reactions escape one handler class with their record's mark in the module UseCases/On{Fact} folder? Look at each reaction in its module UseCases/ folder.

Marked guidance: a reviewer answers this question.

Run Scheduled Jobs as Hosted Operations#

ruleruleguidance
litespec/backend/dotnet/application#run-scheduled-jobs-as-hosted-operations
Statement

A scheduled or manual job MUST run as one hosted operation carrying one mark naming its job record.

Applies toartifact: job

Why

The hosted operation lives in its module Jobs folder with the scheduling concerns and registers on the declared execution host. It honors its record's selection, batch size, lease, and cursor, runs its commands once per item, and records its evidence where the record states. A manual job states its runsWhere and its guard and touches no aggregate. This provision binds litespec/backend/application#run-each-job-within-its-declared-selection for .NET.

Example

C#
/// <implements>job/spending.erase-expired-personal-data</implements>
internal sealed class EraseExpiredPersonalDataHost(...)
{
    // Runs one erase command per expired expense, resuming from its cursor.
}
Checka reviewer answers

Reviewed forWhich scheduled or manual jobs escape one marked hosted operation honoring selection, batch size, lease, and cursor? Look at each such job.

Marked guidance: a reviewer answers this question.

Bind the Pipeline to the Pinned LiteBus APIs#

ruleruleguidance
litespec/backend/dotnet/application#bind-the-pipeline-to-the-pinned-litebus-apis
Statement

An Application MUST compose its command pipeline through the LiteBus APIs this provision names for commit, authorization, audit, fault classification, delivery, event staging, and the timestamp.

Applies toartifact: any

Why

One pinned binding per position keeps every project's pipeline identical. The commit runs through commands.UseMartenUnitOfWork(), which registers the LiteBus IUnitOfWork at HandlerPriorities.UnitOfWork, so the pipeline commits once under litespec/backend/dotnet/persistence#commit-once-in-the-command-pipeline. Authorization runs through a guard derived from LiteBus.Messaging.Abstractions.DeclarationGuard<TMessage, TValue>, reading the declaration from the message definition under litespec/backend/application#enforce-target-authorization-in-the-pipeline. Audit runs through AuditDeclaration.Audited(...).WithTargetFrom<TCommand>(...).WithReasonFrom<TCommand>(...) in an IAuditDefinition<TCommand>, declaring the target and reason beside the message under litespec/backend/application#declare-audit-values-beside-the-message. Fault classification runs through UseFaultRefusalClassifierInstance(new PostgreSqlConstraintRefusalClassifier(map)), mapping each store fault to its refusal under litespec/backend/persistence#map-a-constraint-violation-to-its-refusal. Delivery runs through outbox.EnablePerHandlerDelivery(), reaching each handler separately under litespec/ext/outbox#deliver-each-event-to-each-handler-separately. Event staging runs through outbox.StageCollectedEvents(), staging the facts IDomainEventCollector collected in the same commit as the aggregate change. The timestamp runs through IExecutionContext.HandledAt, stamping each message once for the handler to read as HandledAt under litespec/backend/application#take-one-timestamp-per-message.

Checka reviewer answers

Reviewed forWhich pipeline positions lack their pinned LiteBus API? Look at the command pipeline composition.

Marked guidance: a reviewer answers this question.

Conventions#

Use the Use Case Folder Layout#

conventiondefaultguidance
litespec/backend/dotnet/application#use-the-use-case-folder-layout
Default

Place each use case flat in a folder named for its use case inside the UseCases/ folder of its module and aggregate, with the files this provision names.

ReplacementA project names its use case layout in its Application documentation.

Applies toartifact: any

Why

The folder path matches the specification path, so a reader locates a use case from its use-case identifier without searching. Type names derive from the use-case identifier with the architectural suffix, so use-case/spending.submit-expense owns SubmitExpenseCommand under litespec/workspace/naming#use-architectural-suffixes. Only a use case or a handled fact gets a folder below its UseCases/ folder: any other type sits flat in the layer folder that owns it under litespec/backend/architecture#place-a-type-at-the-lowest-folder-that-holds-its-consumers, and a handled fact folder takes the On{Fact} name with its marked handler classes under litespec/workspace/naming#group-event-handlers-by-the-fact-they-handle. Each result type carries one case per result, and each refusal carries the refusal code from its result record. A query result type carries one member per declared include group, and each result part is named after its result under litespec/workspace/naming#name-result-parts-after-their-result.

Example

The use case folder layout is:

Text
{Product}.SharedKernel/
  Application/                      product plumbing: pipeline positions, clock, failures, validation
    Pipeline/                       the pipeline own stages, and nothing else
      ApplicationPipeline.cs
      AuthorizationGuard.cs
    Clock/
      IClock.cs
    Failures/
      ResourceNotFoundException.cs
      UseCaseForbiddenException.cs
      UseCaseConflictException.cs
    Validation/
      ValidationErrors.cs
{Product}.Modules/
  Posts/                            single aggregate whose name matches the module: use cases sit in the module UseCases folder
    Application/
      IPostArchive.cs               a port several Posts use cases name
    UseCases/
      CreateDraft/
        CreateDraftCommand.cs
        CreateDraftCommandResult.cs
        CreateDraftCommandValidator.cs
        CreateDraftCommandHandler.cs
        CreateDraftEndpoint.cs
        CreateDraftRequestModel.cs
        CreateDraftResponseModel.cs
      GetPost/
        GetPostQuery.cs
        GetPostQueryResult.cs
        GetPostQueryValidator.cs
        GetPostQueryHandler.cs
      ListPosts/
        ListPostsQuery.cs
        ListPostsQueryResult.cs
        ListPostsQueryResultItem.cs
        ListPostsQueryValidator.cs
        ListPostsQueryHandler.cs
      OnPostPublished/              event handlers of one fact: one marked class per reaction
        NotifySubscribersOnPostPublishedHandler.cs
        IPostPublicationNotifier.cs a port one event handler names
  Customers/                        two aggregates: use cases nest under each aggregate UseCases folder
    CustomerAccounts/
      Application/
        CustomerAccountScopeLookup.cs aggregate-owned, so it sits with the aggregate
      UseCases/
        RestrictAccount/
          RestrictAccountCommand.cs
          RestrictAccountCommandHandler.cs
    Consents/
      UseCases/
        GrantConsent/
          GrantConsentCommand.cs
          GrantConsentCommandHandler.cs

Module and aggregate order, including the single-aggregate flat form, follow litespec/backend/architecture#organize-code-by-module-aggregate-and-use-case. A use case file takes its layer from its role suffix under litespec/backend/dotnet/architecture#keep-closed-folder-sets-at-every-level.

Checka reviewer answers

Reviewed forWhich use case folders miss message, result, validator, or handler files named for the use case, or which event handlers escape their module UseCases/On{Fact} folder? Look at each use case folder.

Marked guidance: a reviewer answers this question.

Use the Pinned Mediator Entry Points#

conventiondefaultguidance
litespec/backend/dotnet/application#use-the-pinned-mediator-entry-points
Default

Dispatch a command through ICommandMediator.SendAsync and a query through IQueryMediator.QueryAsync, from the LiteBus version the manifest pins.

ReplacementA project names its dispatch entry points in its Application documentation.

Applies toartifact: reaction, endpoint, job

Why

One pinned library means every dispatch site in every project reads the same way, and an agent finds all callers of a message with one search. A project that replaces the default still keeps commands and queries on separate entry points, because that separation is required by litespec/backend/application#dispatch-commands-and-queries-through-separate-entry-points and no replacement removes it.

Checka reviewer answers

Reviewed forWhich dispatch sites bypass ICommandMediator.SendAsync or IQueryMediator.QueryAsync, or use an unpinned LiteBus version? Look at each dispatch site.

Marked guidance: a reviewer answers this question.

Reference Example#

This informative example demonstrates litespec/backend/dotnet/architecture#keep-implementation-types-internal, litespec/backend/application#keep-command-handlers-to-load-call-and-stage, litespec/backend/dotnet/application#dispatch-declared-effects-through-their-entry-points, and litespec/workspace/naming#group-event-handlers-by-the-fact-they-handle.

C#
public sealed record CreateDraftCommand(
    PostId PostId,
    AuthorId AuthorId,
    string Title,
    DateTimeOffset HandledAt) : ICommand<CreateDraftCommandResult>;

public sealed record CreateDraftCommandResult(PostId PostId);

internal sealed class CreateDraftCommandHandler(
    IPostRepository postRepository) : ICommandHandler<CreateDraftCommand, CreateDraftCommandResult>
{
    public async Task<CreateDraftCommandResult> HandleAsync(
        CreateDraftCommand command,
        CancellationToken cancellationToken)
    {
        var post = Post.CreateDraft(
            command.PostId,
            command.AuthorId,
            PostTitle.Create(command.Title),
            command.HandledAt);

        postRepository.Store(post);

        return new CreateDraftCommandResult(post.Id);
    }
}

The pinned LiteBus package signatures take precedence over illustrative example signatures.

Change Another Aggregate from an Event Handler#

C#
// Lending/Loans/UseCases/BorrowBook/BorrowBookCommandHandler.cs
internal sealed class BorrowBookCommandHandler(
    ILoanRepository loanRepository)
    : ICommandHandler<BorrowBookCommand, BorrowBookCommandResult>
{
    public async Task<BorrowBookCommandResult> HandleAsync(
        BorrowBookCommand command,
        CancellationToken cancellationToken)
    {
        var loan = Loan.Borrow(
            command.LoanId,
            command.BookId,
            command.ReaderId,
            command.HandledAt);

        loanRepository.Store(loan);

        return new BorrowBookCommandResult(loan.Id);
    }
}
C#
// Lending/UseCases/OnLoanBorrowed/MarkBookLentOnLoanBorrowedHandler.cs
/// <implements>reaction/lending.mark-book-lent-on-loan-borrowed</implements>
internal sealed class MarkBookLentOnLoanBorrowedHandler(
    IBookRepository bookRepository)
    : IEventHandler<LoanBorrowedEvent>
{
    public async Task HandleAsync(
        LoanBorrowedEvent @event,
        CancellationToken cancellationToken)
    {
        var book = await bookRepository.GetByIdAsync(@event.BookId, cancellationToken);

        book.MarkLent(@event.LoanId, @event.BorrowedAt);

        bookRepository.Store(book);
    }
}

The command handler stages only the loan; borrowing raises LoanBorrowedEvent, and the event handler records the lent state on the book after the commit. It commits nothing: the post-handler commits once, and an event handler that needs a write runs a command through ICommandMediator.