원클릭으로
clean-architecture-dotnet
// 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).
Use when implementing any feature or fix using TDD, before writing any implementation code
Use when configuring Git hooks in .NET projects before team commits occur, to enforce commit message standards and code formatting automatically
Use before writing any test or implementation task, when observable behavior needs to be captured in business language scenarios and approved by the user before code begins
Use when running mutation testing, killing mutants, verifying test quality, checking mutation score, or analyzing survivors after the test baseline is green
Use when writing tests from the outside-in, defining behavior before code, or any feature where tests should start from observable business behavior and let internal design emerge
Use when understanding project composition by language, measuring code change impact, or generating code statistics for CI/CD metrics
| name | 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). |
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 NOT to add CQRS or DDD:
IQueryBus requiredThese 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).
| 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.
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";
}
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.
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 | 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) |
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.
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 architecture follows a strict outward-in dependency flow: API → Infrastructure → Application → Domain
CRITICAL: Just because a layer can see another via transitive reference doesn't mean it should use its concrete types.
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.
| 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. |
using MyApp.Application; inside API layer filesusing MyApp.Infrastructure; inside Domain layer filesICommandHandler<> or IQueryHandler<,> directly in API endpoints when using a CQRS bus — route through ICommandBus / IQueryBusTask); the ID must be part of the incoming commandICommandBus / IQueryBus to a simple CRUD API with no domain logic, invariants, or cross-cutting pipeline — use a CQS Application use case (Pattern A) instead*Service (e.g., OrderService) — rename to *UseCase; "Service" implies infrastructure or shared state, not a single bounded interaction| 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 |
./init-project.sh MyApp)