No README available for this skill.
clean-architecture-dotnet
Description
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).
apm install SebastienDegodez/copilot-instructions/plugins/csharp-clean-architecture-development/skills/clean-architecture-dotnet
View on GitHub
Clean Architecture in .NET
Overview
Clean Architecture organizes code into independent layers (Domain β Application β Infrastructure β API).
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 viaICommandBus/IQueryBusor an injected use case interface.new AppDbContext()inside a Controller β bypasses both Infrastructure and Application layers.HttpClientinjected 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
IQueryBusrequired - 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 plainsealed record, not anAggregateRoot. 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 bus
public sealed class ContactUseCase
{
private readonly IContactRepository _repository; // interface defined in Application (see Interface Placement)
public ContactUseCase(IContactRepository repository)
{
_repository = repository;
}
// Command β void (CQS)
public async 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)
public async Task<ContactViewModel?> GetByIdAsync(Guid id, CancellationToken ct)
{
var contact = await _repository.FindAsync(new ContactId(id), ct);
return contact is null ? 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).
// Application/Contacts/IContactReadService.cs β interface in Application, no domain object involved
public interface IContactReadService
{
Task<ContactViewModel?> GetByIdAsync(ContactId id, CancellationToken ct = default);
}
// Infrastructure/Contacts/ContactReadService.cs β implementation maps directly from DB row to ViewModel
public sealed class ContactReadService : IContactReadService
{
private readonly AppDbContext _db;
public ContactReadService(AppDbContext db)
{
_db = db;
}
public async Task<ContactViewModel?> GetByIdAsync(ContactId id, CancellationToken ct)
{
return await _db.Contacts
.Where(c => c.Id == id.Value)
.Select(c => new ContactViewModel(c.Id, c.Name))
.FirstOrDefaultAsync(ct);
}
}
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/IQueryBusonly 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 command
public sealed record PlaceOrderCommand(OrderId OrderId, string CustomerName);
// Application/Features/Orders/PlaceOrderCommandHandler.cs
public sealed class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand>
{
private readonly IOrderRepository _repository; // IOrderRepository defined in Domain (write path) β see Interface Placement
public async 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.
// SharedKernel/Abstractions/ICommandHandler.cs β commands are void (CQS)
public interface ICommandHandler<in TCommand>
{
Task HandleAsync(TCommand command, CancellationToken ct = default);
}
// SharedKernel/Abstractions/IQueryHandler.cs β queries return a result
public interface IQueryHandler<in TQuery, TResult>
{
Task<TResult> HandleAsync(TQuery query, CancellationToken ct = default);
}
// Each context's Application layer implements it:
// Orders.Application/Features/PlaceOrder/PlaceOrderCommandHandler.cs
using SharedKernel.Abstractions;
public sealed class PlaceOrderCommandHandler
: ICommandHandler<PlaceOrderCommand> { }
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 Domain
public sealed class OrderPlacedEvent : DomainEvent // DomainEvent base from SharedKernel (or Domain if single-context)
{
public OrderId OrderId { get; }
public OrderPlacedEvent(OrderId orderId)
{
OrderId = orderId;
}
}
// Domain/Orders/Order.cs β aggregate raises the event
public sealed class Order : AggregateRoot
{
public OrderId Id { get; }
public CustomerId CustomerId { get; }
// Private constructor β only the factory method can create an instance
private Order(OrderId id, CustomerId customerId)
{
Id = id;
CustomerId = customerId;
}
public static Order Create(OrderId id, CustomerId customerId)
{
var order = new Order(id, customerId);
order.AddDomainEvent(new OrderPlacedEvent(id)); // β raised in Domain
return order;
}
}
// Application/Features/PlaceOrder/PlaceOrderCommandHandler.cs β handler dispatches
public sealed class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand>
{
private readonly IOrderRepository _repository;
private readonly IDomainEventDispatcher _dispatcher; // interface in Application/Domain
public async Task HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.OrderId, cmd.CustomerId); // β ID comes from the command
await _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
See Interface Placement for full decision tables and examples.
| Contract | Define in | Key question |
|---|---|---|
| Write repo for a Domain aggregate | Domain | Does it persist an aggregate? |
| Write repo for CRUD entity / read service | Application | No aggregate β Application owns the contract |
ICurrentUser / IUserContext | Application | Use cases need the callerβs identity |
| Authentication (token/role gate) | Infrastructure | Technical filter, no business logic |
Authorization quick rule: read access gate β Application use case policy. Mutation invariant β Domain aggregate method. Token/role β Infrastructure middleware.
Rationalization Table
| Excuse | Reality |
|---|---|
| β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 filesusing MyApp.Infrastructure;inside Domain layer files- Injecting
ICommandHandler<>orIQueryHandler<,>directly in API endpoints when using a CQRS bus β route throughICommandBus/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/IQueryBusto 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
- Convention-Based DI β Auto-discovery, Bus interfaces, full DI setup
- Layer Responsibilities β Full rules for each layer with DDD context
- Project Structure β File/folder conventions and layer organization guidance
- NetArchTest Rules β Automated boundary enforcement
- DDD Patterns β Aggregates, factory methods, value objects, and domain events (optional)
- Shared Kernel β Shared abstractions for multi-context solutions (optional)
- Interface Placement β Repository, authorization, and ICurrentUser placement with GetOrder (read policy) + CancelOrder (domain invariant) examples
- Init Script β Bootstrap script for new project setup (
./init-project.sh MyApp) - ArchitectureTests Template β Drop-in architecture test template
π Evaluation Benchmark
View all βNo evaluation data yet.
Results appear after the first pipeline run (weekly on Mondays, or on PR).