Skip to content

Application Layer

This document is the authoritative guide for all design decisions in the five application layer projects. Read it in full before writing or modifying any application code.

This convention implements docs/decisions/cqrs-with-split-application-projects.md (CQRS split), docs/decisions/contracts-projects-for-application-layer.md (Contracts projects), docs/decisions/reactions-project-depends-only-on-abstractions.md (Reactions depends only on abstractions), and docs/decisions/transaction-pipeline-behaviors.md (Transaction pipeline behaviors).

Agent Quick Rules {#agent-quick-rules}

  • Commands and queries MUST live in Contracts; handlers MUST be internal sealed in implementation projects.
  • Query handlers MUST inject IDatabaseContext; MUST NOT inject repositories.
  • Command handlers MUST NOT call SaveChangesAsync.
  • Validators throw CommandValidationException / QueryValidationException; MUST NOT use Guard.Against.
  • Validators MUST NOT make database lookups. Structural validation only.
  • Application.Reactions MUST NOT reference external NuGet packages; define narrow interfaces.
  • Domain folders: {Aggregate}/{UseCase}/ naming.
  • Handlers that need the current time MUST inject IClock and pass clock.UtcNow explicitly to aggregate methods. MUST NOT call DateTime.UtcNow or DateTimeOffset.UtcNow directly in handlers.

Full convention: docs/conventions/backend/application-layer.md When generating new files: Load and copy from docs/blueprints/backend/write-endpoint.md rather than assembling from examples in this file.


1. Guiding Philosophy

The application layer orchestrates use cases. It contains no business rules. It is split into five projects to enforce structural separation between write operations, read operations, and event reactions at the compiler level. If you find yourself writing an if statement that enforces a domain constraint in a handler, that logic belongs in the Domain layer.


2. The Five Projects

ProjectResponsibilityLiteBus Package
Application.Write.ContractsCommands, command results, CommandValidationException, write-side contract interfaces. No handlers.LiteBus.Commands.Abstractions
Application.WriteCommand handler and validator implementations.LiteBus.Commands.Abstractions
Application.Read.ContractsQuery records, query result records, IDatabaseContext interface, PagedResult<T>, PaginationParameters, QueryValidationException. No handlers.LiteBus.Queries.Abstractions
Application.ReadQuery handler and validator implementations. References Application.Read.Contracts, Domain, Microsoft.EntityFrameworkCore.LiteBus.Queries.Abstractions
Application.ReactionsEvent handlers and narrow side-effect interfaces. May dispatch follow-up commands when eventual consistency is acceptable.LiteBus.Events.Abstractions, LiteBus.Commands.Abstractions

IClock and Time in Handlers

Domain aggregates MUST NOT call DateTime.UtcNow directly. Handlers inject IClock and pass clock.UtcNow into aggregate methods.

Application.Write.Contracts/Shared/IClock.cs
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
// Infrastructure/Time/SystemClock.cs
internal sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => TimeProvider.System.GetUtcNow();
}

Register in Infrastructure:

services.AddSingleton<IClock, SystemClock>();
services.AddSingleton(TimeProvider.System);
// GOOD: handler passes clock time to aggregate
internal sealed class PublishPostCommandHandler : ICommandHandler<PublishPostCommand>
{
private readonly IPostRepository _repository;
private readonly IClock _clock;
public PublishPostCommandHandler(IPostRepository repository, IClock clock)
{
_repository = repository;
_clock = clock;
}
public async Task HandleAsync(PublishPostCommand command, CancellationToken cancellationToken)
{
var post = await _repository.GetByIdAsync(command.PostId, cancellationToken);
post.Publish(_clock.UtcNow);
}
}

3. Folder Structure

graph TD
WriteContracts["Application.Write.Contracts/\nShared/\n Exceptions/\n CommandValidationException.cs\nPosts/\n CreatePostCommand.cs\n PublishPostCommand.cs\n CreatePostCommandResult.cs"]
Write["Application.Write/\nPosts/\n Create/\n CreatePostCommandHandler.cs\n CreatePostCommandValidator.cs\n Publish/\n PublishPostCommandHandler.cs\n PublishPostCommandValidator.cs"]
ReadContracts["Application.Read.Contracts/\nShared/\n IDatabaseContext.cs\n PagedResult.cs\n PaginationParameters.cs\n Exceptions/\n QueryValidationException.cs\nPosts/\n GetPostByIdQuery.cs\n GetAllPostsQuery.cs\n PostResult.cs\n PostSummary.cs"]
Read["Application.Read/\nPosts/\n GetById/\n GetPostByIdQueryHandler.cs\n GetPostByIdQueryValidator.cs\n GetAll/\n GetAllPostsQueryHandler.cs\n GetAllPostsQueryValidator.cs"]
Reactions["Application.Reactions/\nPosts/\n OnPostPublished/\n NotifySubscribersOnPostPublishedEventHandler.cs\n IPostPublishedNotifier.cs\n OnPostCreated/\n LogOnPostCreatedEventHandler.cs"]

Domain folders are named after the aggregate. Use case folders inside are named after the operation in imperative form (Create/, Publish/, GetById/). Event handler folders are named On{EventName}/.


4. What Goes in Contracts vs. Implementation

Contracts ProjectImplementation Project
Command record typesCommand handler classes
Command result record typesCommand validator classes
Query record typesQuery handler classes
Query result record typesQuery validator classes
IDatabaseContext (single interface in Shared/)Implementation is AppDbContext in Infrastructure
Write-side contract interfacesNarrow interfaces (in Reactions only)

5. Command Pattern

Command Record (in Application.Write.Contracts)

A command is a record that carries the input for a single operation. All properties use required with init setters.

// GOOD: command record in Application.Write.Contracts/Posts/CreatePostCommand.cs
sealed record CreatePostCommand : ICommand<PostId>
{
public required PostId Id { get; init; }
public required string Title { get; init; }
public required string Content { get; init; }
public required AuthorId AuthorId { get; init; }
}

Command Handler (in Application.Write)

A command handler loads an aggregate and calls the operation. It does not call SaveChangesAsync. The pipeline post-handler handles persistence.

// GOOD: handler in Application.Write/Posts/Create/CreatePostCommandHandler.cs
internal sealed class CreatePostCommandHandler : ICommandHandler<CreatePostCommand, PostId>
{
private readonly IPostRepository _postRepository;
public CreatePostCommandHandler(IPostRepository postRepository)
{
_postRepository = postRepository;
}
public async Task<PostId> HandleAsync(CreatePostCommand command, CancellationToken cancellationToken)
{
var post = Post.Create(
command.Id,
new PostTitle(command.Title),
new PostContent(command.Content),
command.AuthorId);
await _postRepository.AddAsync(post, cancellationToken);
return post.Id;
// SaveChangesAsync is called by SaveChangesCommandPostHandler in the pipeline
}
}
// BAD: handler placed in the Contracts project
// Application.Write.Contracts/Posts/CreatePostCommandHandler.cs
// BAD: handler is public instead of internal sealed
public class CreatePostCommandHandler : ICommandHandler<CreatePostCommand, PostId> { }

Void command handler (no result returned): use ICommandHandler<TCommand> with one type argument.

Application.Write/Posts/Publish/PublishPostCommandHandler.cs
internal sealed class PublishPostCommandHandler : ICommandHandler<PublishPostCommand>
{
private readonly IPostRepository _postRepository;
public PublishPostCommandHandler(IPostRepository postRepository)
{
_postRepository = postRepository;
}
public async Task HandleAsync(
PublishPostCommand command,
CancellationToken cancellationToken)
{
var post = await _postRepository.GetByIdAsync(command.PostId, cancellationToken);
post.Publish();
await _postRepository.UpdateAsync(post, cancellationToken);
// SaveChangesAsync is called by SaveChangesCommandPostHandler in the pipeline
}
}
### Command Validator (in Application.Write)
Validators run before the handler. They check structural validity only (non-null, non-empty, within range). They do NOT check business rules. Validators MUST throw `CommandValidationException` subclasses. Never throw `ArgumentException` or use `Guard.Against` in validators.
```csharp
// GOOD: throw CommandValidationException subclasses directly
internal sealed class CreatePostCommandValidator : ICommandValidator<CreatePostCommand>
{
public Task ValidateAsync(CreatePostCommand command, CancellationToken cancellationToken)
{
if (command.Id == default)
{
throw new PostIdRequiredException();
}
if (string.IsNullOrWhiteSpace(command.Title))
{
throw new PostTitleRequiredException();
}
if (command.Title.Length > 200)
{
throw new PostTitleTooLongException(command.Title.Length);
}
return Task.CompletedTask;
}
}
// BAD: Guard.Against throws ArgumentException, not CommandValidationException
internal sealed class CreatePostCommandValidator : ICommandValidator<CreatePostCommand>
{
public Task ValidateAsync(CreatePostCommand command, CancellationToken cancellationToken)
{
Guard.Against.Default(command.Id, nameof(command.Id));
// BAD: throws ArgumentException -> GlobalExceptionHandler maps to 500, not 400
Guard.Against.NullOrWhiteSpace(command.Title, nameof(command.Title));
return Task.CompletedTask;
}
}

6. Query Pattern

Query Record (in Application.Read.Contracts)

Application.Read.Contracts/Posts/GetPostByIdQuery.cs
public sealed record GetPostByIdQuery : IQuery<PostResult>
{
public required PostId PostId { get; init; }
}

IDatabaseContext Interface (in Application.Read.Contracts/Shared/)

IDatabaseContext is the single read-side database abstraction. It exposes one IQueryable<T> property per aggregate. Add a property here when a new aggregate needs query handlers.

Application.Read.Contracts/Shared/IDatabaseContext.cs
public interface IDatabaseContext
{
IQueryable<Post> Posts { get; }
IQueryable<Author> Authors { get; }
IQueryable<Order> Orders { get; }
}

IDatabaseContext is NOT a repository. It does not load aggregates. It exposes queryable collections for EF Core LINQ projections. Query handlers write Select projections against these collections and never call aggregate methods.

Paginated Query (in Application.Read.Contracts)

All list queries that return potentially large result sets MUST use PagedResult<T> and include PaginationParameters as a required property.

Application.Read.Contracts/Posts/GetAllPostsQuery.cs
public sealed record GetAllPostsQuery : IQuery<PagedResult<PostSummary>>
{
public required PaginationParameters Pagination { get; init; }
}

PaginationParameters and PagedResult<T> are defined in Application.Read.Contracts/Shared/:

Application.Read.Contracts/Shared/PaginationParameters.cs
public sealed record PaginationParameters
{
public int PageNumber { get; init; } = 1;
public int PageSize { get; init; } = 20;
public bool SkipTotalCount { get; init; } = false;
public const int MaxPageSize = 100;
}
// Application.Read.Contracts/Shared/PagedResult.cs
public sealed record PagedResult<T>
{
public required IReadOnlyList<T> Items { get; init; }
public required int TotalCount { get; init; }
public required int PageNumber { get; init; }
public required int PageSize { get; init; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasNextPage => PageNumber < TotalPages;
public bool HasPreviousPage => PageNumber > 1;
}

Query Handler (in Application.Read)

Query handlers inject IDatabaseContext and write LINQ projections directly. They MUST NOT call any aggregate method. They MUST NOT load a full aggregate. Every query handler uses Select to project only the fields needed.

Application.Read/Posts/GetById/GetPostByIdQueryHandler.cs
internal sealed class GetPostByIdQueryHandler
: IQueryHandler<GetPostByIdQuery, PostResult>
{
private readonly IDatabaseContext _db;
public GetPostByIdQueryHandler(IDatabaseContext db)
{
_db = db;
}
public async Task<PostResult> HandleAsync(
GetPostByIdQuery query,
CancellationToken cancellationToken)
{
var result = await _db.Posts
.Where(p => p.Id == query.PostId)
.Select(p => new PostResult
{
Id = p.Id,
Title = p.Title.Value,
Content = p.Content.Value,
AuthorName = p.Author.DisplayName,
PublishedAt = EF.Property<DateTimeOffset?>(p, PostStateColumns.PublishedAt)
})
.FirstOrDefaultAsync(cancellationToken);
if (result is null)
{
throw new PostNotFoundException(query.PostId);
}
return result;
}
}
// BAD: loading a full aggregate in a query handler
internal sealed class GetPostByIdQueryHandler
: IQueryHandler<GetPostByIdQuery, PostResult>
{
private readonly IPostRepository _postRepository; // BAD: repository in query handler
public async Task<PostResult> HandleAsync(
GetPostByIdQuery query,
CancellationToken cancellationToken)
{
var post = await _postRepository.GetByIdAsync(query.PostId, cancellationToken);
// BAD: loading full aggregate, triggering all EF Core navigation loading
return new PostResult { Id = post.Id, Title = post.Title.Value };
}
}

Paginated Query Handler (in Application.Read)

Application.Read/Posts/GetAll/GetAllPostsQueryHandler.cs
internal sealed class GetAllPostsQueryHandler
: IQueryHandler<GetAllPostsQuery, PagedResult<PostSummary>>
{
private readonly IDatabaseContext _db;
public GetAllPostsQueryHandler(IDatabaseContext db)
{
_db = db;
}
public async Task<PagedResult<PostSummary>> HandleAsync(
GetAllPostsQuery query,
CancellationToken cancellationToken)
{
var pageSize = Math.Min(
query.Pagination.PageSize,
PaginationParameters.MaxPageSize);
var baseQuery = _db.Posts
.Where(p => EF.Property<string>(p, PostStateColumns.StateType) == PostStateColumns.Published);
var totalCount = query.Pagination.SkipTotalCount
? 0
: await baseQuery.CountAsync(cancellationToken);
var items = await baseQuery
.OrderByDescending(p => EF.Property<DateTimeOffset>(p, PostStateColumns.PublishedAt))
.Skip((query.Pagination.PageNumber - 1) * pageSize)
.Take(pageSize)
.Select(p => new PostSummary
{
Id = p.Id,
Title = p.Title.Value,
PublishedAt = EF.Property<DateTimeOffset>(p, PostStateColumns.PublishedAt)
})
.ToListAsync(cancellationToken);
return new PagedResult<PostSummary>
{
Items = items,
TotalCount = totalCount,
PageNumber = query.Pagination.PageNumber,
PageSize = pageSize
};
}
}

Multi-Aggregate Projections

IDatabaseContext exposes multiple aggregates. A single LINQ projection can join across them without multiple round trips:

// Joining Post and Author in a single projection
var result = await _db.Posts
.Where(p => p.Id == query.PostId)
.Select(p => new PostResult
{
Id = p.Id,
Title = p.Title.Value,
// EF Core resolves this as a JOIN, no separate Author query
AuthorName = _db.Authors
.Where(a => a.Id == p.AuthorId)
.Select(a => a.DisplayName)
.FirstOrDefault() ?? string.Empty,
PublishedAt = EF.Property<DateTimeOffset?>(p, PostStateColumns.PublishedAt)
})
.FirstOrDefaultAsync(cancellationToken);

Query Validator (in Application.Read)

Query validators throw QueryValidationException subclasses. Never throw ArgumentException or ArgumentNullException.

List query validators MUST enforce pagination bounds before the handler runs.

// GOOD: pagination validated in query validator
internal sealed class GetAllPostsQueryValidator : IQueryValidator<GetAllPostsQuery>
{
public Task ValidateAsync(
GetAllPostsQuery query,
CancellationToken cancellationToken)
{
if (query.Pagination.PageNumber < 1)
{
throw new PageNumberMustBePositiveException();
}
if (query.Pagination.PageSize < 1)
{
throw new PageSizeMustBePositiveException();
}
if (query.Pagination.PageSize > PaginationParameters.MaxPageSize)
{
throw new PageSizeExceedsMaximumException(PaginationParameters.MaxPageSize);
}
return Task.CompletedTask;
}
}
// GOOD:
internal sealed class GetPostByIdQueryValidator
: IQueryValidator<GetPostByIdQuery>
{
public Task ValidateAsync(
GetPostByIdQuery query,
CancellationToken cancellationToken)
{
if (query.PostId == default)
{
throw new PostIdRequiredException();
}
return Task.CompletedTask;
}
}
// BAD:
internal sealed class GetPostByIdQueryValidator
: IQueryValidator<GetPostByIdQuery>
{
public Task ValidateAsync(
GetPostByIdQuery query,
CancellationToken cancellationToken)
{
Guard.Against.Default(query.PostId, nameof(query.PostId));
// BAD: Guard.Against throws ArgumentException, not QueryValidationException
// This maps to HTTP 500, not HTTP 400
return Task.CompletedTask;
}
}

Query Result Types (in Application.Read.Contracts)

Result records are defined in the Contracts project, next to the query that returns them.

Application.Read.Contracts/Posts/PostResult.cs
public sealed record PostResult
{
public required PostId Id { get; init; }
public required string Title { get; init; }
public required string Content { get; init; }
public required string AuthorName { get; init; }
public required DateTime? PublishedAt { get; init; }
}

7. Event Handler Pattern

Event Handler Categories

// Category 1: dispatches a follow-up command
internal sealed class SendConfirmationOnOrderPlacedEventHandler : IEventHandler<OrderPlaced>
{
private readonly ICommandMediator _commandMediator;
public SendConfirmationOnOrderPlacedEventHandler(ICommandMediator commandMediator)
{
_commandMediator = commandMediator;
}
public async Task HandleAsync(OrderPlaced @event, CancellationToken cancellationToken)
{
var command = new SendOrderConfirmationEmailCommand { OrderId = @event.OrderId };
await _commandMediator.SendAsync(command, cancellationToken);
}
}
// Category 2: triggers an external side effect via a narrow interface
internal sealed class NotifySubscribersOnPostPublishedEventHandler : IEventHandler<PostPublished>
{
private readonly IPostPublishedNotifier _notifier;
public NotifySubscribersOnPostPublishedEventHandler(
IPostPublishedNotifier notifier)
{
_notifier = notifier;
}
public async Task HandleAsync(PostPublished @event, CancellationToken cancellationToken)
{
await _notifier.NotifySubscribersAsync(
@event.PostId,
@event.PostTitle,
cancellationToken);
}
}

Read model projection updates are not a default event-handler category. Query handlers use IDatabaseContext projections by default. If a project introduces denormalized read model tables, the project ADR must define whether they are updated inside the command transaction, by an outbox-backed projector, or by a reconciliation job. Do not dispatch ad hoc UpdateReadModelCommand commands from Reactions without that ADR.

Narrow Interface Definition (in Application.Reactions)

Application.Reactions/Posts/OnPostPublished/IPostPublishedNotifier.cs
// GOOD: narrow interface defined in Application.Reactions
internal interface IPostPublishedNotifier
{
Task NotifySubscribersAsync(PostId postId, string postTitle, CancellationToken cancellationToken);
}
// BAD: injecting a broad external service interface directly
internal sealed class NotifySubscribersOnPostPublishedEventHandler : IEventHandler<PostPublished>
{
private readonly IEmailClient _emailClient; // BAD: external library in Application.Reactions
}

8. Validators

Validators MUST:

  • Run before the handler (LiteBus pre-handler pipeline).
  • Check structural validity only: null checks, empty string checks, range checks, format checks.
  • Throw CommandValidationException subclasses (for command validators) or QueryValidationException subclasses (for query validators). Never throw ArgumentException or ArgumentNullException.
  • Be internal sealed.

Validators MUST NOT:

  • Query the database to check business rules (do not check whether a post already exists in a validator).
  • Contain domain logic.
  • Use Guard.Against in validators. See exception-hierarchy.md (canonical GOOD/BAD examples).
// GOOD: throw the correct custom exception directly
internal sealed class CreatePostCommandValidator : ICommandValidator<CreatePostCommand>
{
public Task ValidateAsync(CreatePostCommand command, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(command.Title))
{
throw new PostTitleRequiredException();
}
if (command.AuthorId == default)
{
throw new AuthorIdRequiredException();
}
return Task.CompletedTask;
}
}

9. Application Models and Mapping

The Application layer defines its own input and output types. It does not pass domain types out to callers.

When a command needs to pass data into domain factory methods or domain value objects, the handler constructs those types inline. There is no separate mapper class in the Application layer for domain construction; the handler is the translation site.

If the same mapping appears in multiple handlers, extract it to a feature-level Shared/ extension method, applying the Promotion Rule from docs/conventions/principles.md.


Use case documentation for a project lives at docs/domain/{feature}/{use-case}.md. Copy docs/templates/docs/domain-use-case.md when adding a new use case. See docs/guides/agentic-domain-driven-design.md.