Use when domain logic leaks into API/Infrastructure, project references violate layer boundaries, or you need to decide between CQS (always), CQRS bus (complex domains), and DDD patterns (invariants and events).
Use when domain logic leaks into API/Infrastructure, project references violate layer boundaries, or you need to decide between CQS (always), CQRS bus (complex domains), and DDD patterns (invariants and events).
The Iron Law: Violating layer boundaries is a failure. If API references Application or Domain references Infrastructure, delete the change and start over.
Concrete violations that break the Iron Law:
using MyApp.Infrastructure; in a Domain class — Infrastructure must never flow inward.
using MyApp.Application; in an API endpoint file — API must only interact via ICommandBus / IQueryBus or an injected use case interface.
new AppDbContext() inside a Controller — bypasses both Infrastructure and Application layers.
HttpClient injected directly into a Domain or Application class — HTTP belongs in Infrastructure behind an interface.
Fix: move the dependency to the correct outer layer and expose it through an interface pointing inward.
When to Use
Business logic is leaking into Controllers or Repositories
Circular dependencies occur between projects
Need to isolate core business rules from external frameworks (EF Core, APIs)
Autonomy is required: sealed Domain objects, strongly-typed IDs
When NOT to add CQRS or DDD:
The feature is pure CRUD with no business rules, no invariants, and no domain events → use a CQS Application service (see Pattern A below)
A query only reads data with no domain logic → inject the repository or a read service directly in the Application handler; no required
IQueryBus
You have fewer than 3 use cases with no cross-cutting concerns (logging pipeline, validation pipeline) → a CQRS bus adds ceremony without value
CQS vs CQRS vs DDD — Complexity Threshold
These three patterns are independent tools. Choose based on what the codebase actually needs.
Pattern
Rule
Add it when…
Skip it when…
CQS(always)
Every handler/method is either a command (void) or a query (returns value) — never both
Always — zero exceptions
Never skip
CQRS + Bus(optional)
Separate ICommandBus / IQueryBus routing commands and queries through a pipeline
Cross-cutting concerns (validation, logging pipelines), large teams writing many use cases, read/write model asymmetry
Simple CRUD; < 3 use cases; no cross-cutting pipeline needed
DDD(optional)
Aggregates, factory methods, value objects, domain events
Domain has invariants (rules that must be enforced on every state change), state machines, rich business logic
Entities are pure data (no if/throw to protect state); CRUD-only feature
Quick rule of thumb:
"Does this feature have a business rule that could be violated?"
— No → CQS Application Service (Pattern A).
— Yes → Domain Aggregate + CQS or CQRS depending on scale (Pattern B).
DDD vs POCO — Quick Decision
Use
When
sealed class Foo : AggregateRoot + factory method
The object has domain invariants, raises events, or owns child entities
Plain sealed record Foo(...) (POCO)
Pure data carrier, no invariants, used only inside Application or API (DTOs, ViewModels)
sealed class Bar : ValueObject
Immutable, identity-by-value, represents a domain concept (e.g., PolicyNumber, Horsepower)
Rule of thumb: if you're tempted to add an if or throw to protect the object's state — it's a Domain object (aggregate or value object), not a POCO.
Aggregate vs POCO test: write the business method first. If you never need if (condition) throw new DomainException(...) to protect state, the object has no invariants — make it a plain sealed record, not an AggregateRoot. DDD complexity is the cure for invariants, not for data.
Implementation Flow
digraph clean_arch {
"Domain has invariants?" [shape=diamond];
"Pattern A: CQS App Service" [shape=box];
"Pattern B: DDD + CQRS?" [shape=diamond];
"Define Aggregate in Domain" [shape=box];
"Create Handler in Application" [shape=box];
"Add CQRS Bus? (optional)" [shape=diamond];
"Route via ICommandBus/IQueryBus" [shape=box];
"Inject handler directly" [shape=box];
"Expose in API" [shape=box];
"Domain has invariants?" -> "Pattern A: CQS App Service" [label="No (CRUD)"];
"Domain has invariants?" -> "Pattern B: DDD + CQRS?" [label="Yes"];
"Pattern A: CQS App Service" -> "Expose in API";
"Pattern B: DDD + CQRS?" -> "Define Aggregate in Domain";
"Define Aggregate in Domain" -> "Create Handler in Application";
"Create Handler in Application" -> "Add CQRS Bus? (optional)";
"Add CQRS Bus? (optional)" -> "Route via ICommandBus/IQueryBus" [label="Yes (cross-cutting)"];
"Add CQRS Bus? (optional)" -> "Inject handler directly" [label="No"];
"Route via ICommandBus/IQueryBus" -> "Expose in API";
"Inject handler directly" -> "Expose in API";
}
Core Patterns
Pattern A: CQS Application Use Case (Simple / CRUD)
Use when the feature has no business invariants. Layer boundaries are maintained — no CQRS bus needed.
CQS rule: each method is either a command (void / Task) or a query (returns a value) — never both.
Naming: call it *UseCase, not *Service. "Service" implies stateful or cross-cutting infrastructure; a use case class orchestrates one bounded interaction.
// Application/Contacts/ContactUseCase.cs — plain use case, no buspublicsealedclassContactUseCase
{
privatereadonly IContactRepository _repository; // interface defined in Application (see Interface Placement)publicContactUseCase(IContactRepository repository)
{
_repository = repository;
}
// Command — void (CQS)publicasync Task CreateAsync(Guid id, string name, CancellationToken ct)
{
await _repository.AddAsync(new Contact(new ContactId(id), name), ct);
}
// Query — maps domain object to ViewModel in the use case (Option 1)publicasync Task<ContactViewModel?> GetByIdAsync(Guid id, CancellationToken ct)
{
var contact = await _repository.FindAsync(new ContactId(id), ct);
return contact isnull ? null : new ContactViewModel(contact.Id.Value, contact.Name);
}
}
// API/ContactsEndpoints.cs — inject the use case directly (no bus)
app.MapPost("/contacts", async (CreateContactRequest req, ContactUseCase uc) =>
{
var id = Guid.NewGuid();
await uc.CreateAsync(id, req.Name, default);
return Results.Created($"/contacts/{id}", null);
});
app.MapGet("/contacts/{id:guid}", async (Guid id, ContactUseCase uc) =>
await uc.GetByIdAsync(id, default) is { } vm ? Results.Ok(vm) : Results.NotFound());
Option 2 — read-optimized query: for read-heavy paths, skip the domain object entirely. Define a dedicated read interface in Application; Infrastructure implements it directly (e.g., a raw SQL/EF projection).
Rule of thumb:IContactRepository (write path) returns domain objects. IContactReadService (read path) returns ViewModels. Both interfaces live in Application; implementations live in Infrastructure.
Pattern B: CQRS + DDD (Complex Domains)
Use when the domain has invariants, state transitions, or events. CQRS bus is optional even here — add it when you need a cross-cutting pipeline.
Add ICommandBus / IQueryBus only when ALL of the following are true:
You need a cross-cutting pipeline for ALL commands/queries (e.g., logging every command, input validation via pipeline behaviours, performance metrics).
You have multiple use cases (>3) that all benefit from this pipeline — the overhead is justified by scale.
Your read and write models are structurally different (CQRS read-model separation), or your team is large enough that different people own the command and query paths.
If only one condition is true, use a decorator or middleware instead — don't build a bus for a single use case.
CQS rule for commands: a command must be void — it does not return a value. The caller is responsible for generating the ID and including it in the command.
// Application/Features/Orders/PlaceOrderCommand.cs — ID is part of the commandpublicsealedrecordPlaceOrderCommand(OrderId OrderId, string CustomerName);
// Application/Features/Orders/PlaceOrderCommandHandler.cspublicsealedclassPlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand>
{
privatereadonly IOrderRepository _repository; // IOrderRepository defined in Domain (write path) — see Interface Placementpublicasync Task HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.OrderId, cmd.CustomerName);
await _repository.AddAsync(order, ct);
}
}
// API/OrdersEndpoints.cs — caller generates the ID, sends it via ICommandBus (optional)
app.MapPost("/orders", async (PlaceOrderCommand cmd, ICommandBus bus) =>
{
await bus.PublishAsync(cmd);
return Results.Created($"/orders/{cmd.OrderId}");
});
// Queries follow the same rule — inject IQueryBus if using CQRS bus
app.MapGet("/orders/{id}", async (Guid id, IQueryBus bus)
=> Results.Ok(await bus.SendAsync<GetOrderQuery, OrderViewModel>(new GetOrderQuery(new OrderId(id)))));
// Both buses resolve via Infrastructure DI. API never references Application assembly.
Layer Responsibilities
Layer
Purpose
Allowed Dependencies
SharedKernel(multi-context only)
Generic interfaces + base classes only — zero domain logic
None
Domain
Pure business logic, aggregates
SharedKernel (optional)
Application
Use cases, Handler orchestration
Domain, SharedKernel (optional)
Infrastructure
Database, DI registration, CQRS Bus
Application, Domain
API
Endpoints, JSON mapping
Infrastructure (Transitive: Application, Domain)
Shared Kernel (Multi-Context Solutions)
Use a SharedKernel project when two or more Bounded Contexts need to share handler interfaces or base classes. It must contain zero domain logic.
SharedKernel/
├── Abstractions/
│ ├── ICommandHandler.cs ← generic interface only
│ ├── IQueryHandler.cs
│ ├── ValueObject.cs ← base class, no business logic
│ └── AggregateRoot.cs ← base class, exposes DomainEvents collection
└── Events/
└── DomainEvent.cs ← abstract base for all domain events
Dependency rule for SharedKernel: it depends on nothing. Domain and Application reference it, not the other way around.
What NEVER goes in SharedKernel: concrete value objects like Money, Address (each context defines its own); aggregate logic; context-specific event types.
Domain Events
Rule: Domain events are declared in the Domain layer and dispatched in the Application layer (from the handler, after the aggregate operation succeeds).
// Domain/Orders/Events/OrderPlacedEvent.cs — sealed, in DomainpublicsealedclassOrderPlacedEvent : DomainEvent// DomainEvent base from SharedKernel (or Domain if single-context)
{
public OrderId OrderId { get; }
publicOrderPlacedEvent(OrderId orderId)
{
OrderId = orderId;
}
}
// Domain/Orders/Order.cs — aggregate raises the eventpublicsealedclassOrder : AggregateRoot
{
public OrderId Id { get; }
public CustomerId CustomerId { get; }
// Private constructor — only the factory method can create an instanceprivateOrder(OrderId id, CustomerId customerId)
{
Id = id;
CustomerId = customerId;
}
publicstatic Order Create(OrderId id, CustomerId customerId)
{
var order = new Order(id, customerId);
order.AddDomainEvent(new OrderPlacedEvent(id)); // ← raised in Domainreturn order;
}
}
// Application/Features/PlaceOrder/PlaceOrderCommandHandler.cs — handler dispatchespublicsealedclassPlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand>
{
privatereadonly IOrderRepository _repository;
privatereadonly IDomainEventDispatcher _dispatcher; // interface in Application/Domainpublicasync Task HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.OrderId, cmd.CustomerId); // ← ID comes from the commandawait _repository.AddAsync(order, ct);
await _dispatcher.DispatchAsync(order.DomainEvents, ct); // ← dispatched in Application
}
}
Anti-pattern: Never dispatch events from inside a Domain aggregate method — Domain has no dependency on dispatch infrastructure.
The Dependency Chain (Transitive Access)
The architecture follows a strict outward-in dependency flow:
API → Infrastructure → Application → Domain
API has access to everything below it (Infrastructure, Application, Domain).
Infrastructure has access to Application and Domain.
Application has access to Domain.
Domain remains pure with zero project dependencies.
CRITICAL: Just because a layer can see another via transitive reference doesn't mean it should use its concrete types.
API should only use Interfaces from Application/Domain.
Always follow the Iron Law and Red Flags below.
Interface Placement — Repository, Authorization, and Authentication
"Injecting ICommandHandler<,> directly is simpler"
If you're using a CQRS bus, always route through ICommandBus / IQueryBus — consistent indirection, easier to intercept. But if you're not using a bus at all (Pattern A), injecting the Application service directly is correct.
"It's just one small service"
Small leaks become circular dependency nightmares.
"Referencing Application in API is faster"
It bypasses the Bus/Handler pattern and couples contract to implementation.
"Domain needs this NuGet package"
If it's not a primitive/System lib, it doesn't belong in Domain.
"Every handler needs a CQRS bus"
Only if you need a cross-cutting pipeline or read/write model separation. Simple CRUD with a CQS Application service is valid and cleaner.
"Every entity should be an Aggregate"
Only if the entity has invariants to protect. Plain data without business rules → use records and simple repositories.
Red Flags - STOP and Start Over
using MyApp.Application; inside API layer files
using MyApp.Infrastructure; inside Domain layer files
Injecting ICommandHandler<> or IQueryHandler<,> directly in API endpoints when using a CQRS bus — route through ICommandBus / IQueryBus
Command handler returns a domain ID — commands must be void (Task); the ID must be part of the incoming command
Non-sealed classes in Domain
Handlers performing HTTP calls directly (use an Infrastructure service via interface)
Adding ICommandBus / IQueryBus to a simple CRUD API with no domain logic, invariants, or cross-cutting pipeline — use a CQS Application use case (Pattern A) instead
Application class named *Service (e.g., OrderService) — rename to *UseCase; "Service" implies infrastructure or shared state, not a single bounded interaction
Common Mistakes
Mistake
Fix
Handler not found by DI
Handler must be listed explicitly in AddInfrastructure() via AddHandler<T>()
using MyApp.Application; in API
Remove it — inject ICommandBus / IQueryBus (CQRS) or Application service (CQS) via DI
Command handler returns an ID
Commands are void — the caller generates the ID and passes it in the command
Read result named ProductDto
Name it ProductViewModel to distinguish from transfer objects
typeof(Product).Assembly in tests
Use typeof(IApplicationMarker).Assembly for reliable discovery
Non-sealed Domain classes
All Domain classes must be sealed (enforced by NetArchTest)
SharedKernel references Domain
SharedKernel must depend on nothing — if it references Domain, invert: Domain references SharedKernel
Handler contains if/domain invariant logic
Delegate to Domain aggregate methods — handlers orchestrate only. Exception: Application use cases may contain access policy checks (if (resource.OwnerId != _currentUser.Id) throw) — that is use-case policy, not domain invariant logic
Creating Domain aggregates for CRUD entities with no invariants
Use a plain Application service with direct repository access — no aggregate, no events
Adding CQRS bus for < 3 use cases with no cross-cutting concerns
Use Pattern A (CQS Application service) — less indirection, same layer safety
References
Architecture Layers — Dependency rules, marker interfaces, layer tests, and layer validation with NetArchTest
CQRS Patterns — Handler interfaces, examples, DI discovery, and complete CQRS implementation patterns