Clean / Onion / Hexagonal
Key Points
- All three architectures share one principle: the dependency flow points inward, away from infrastructure. The domain doesn't know about EF Core, HTTP, or message brokers.
- Hexagonal (Ports & Adapters) — Cockburn (2005). Ports = abstract interfaces; adapters = concrete implementations.
- Onion — Palermo (2008). Concentric layers; outer depends on inner; domain at the center.
- Clean — Uncle Bob (2012). Same idea; explicit layers (Entities, Use Cases, Interface Adapters, Frameworks).
- All converge on dependency inversion to keep the domain testable and infrastructure-swappable.
- Don't over-apply — small services and CRUD apps often don't need this ceremony. Reach for it when complexity warrants.
Concepts (deep dive)
The shared idea — dependency points inward
┌────────────────────────────────────┐
│ Frameworks / Drivers │
│ (ASP.NET Core, EF Core, RabbitMQ) │
│ │
│ ┌──────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ (Controllers, Repositories) │ │
│ │ │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ Application / Use Cases│ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────────┐ │ │ │
│ │ │ │ Domain / Entities │ │ │ │ ← center
│ │ │ └──────────────────┘ │ │ │
│ │ └────────────────────────┘ │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────┘
Dependencies → point inward (outer references inner; not vice versa)
The center (domain) has no dependencies on outer layers. Outer layers depend on inner via interfaces (ports).
Hexagonal — Ports & Adapters
// Domain (center)
public interface IOrderRepository // PORT (defined by domain)
{
Task<Order?> GetAsync(OrderId id);
Task SaveAsync(Order order);
}
public class Order
{
public OrderId Id { get; private set; }
public OrderStatus Status { get; private set; }
public void Confirm() => Status = OrderStatus.Confirmed;
}
// Application (use case)
public class ConfirmOrderHandler(IOrderRepository repo)
{
public async Task HandleAsync(OrderId id)
{
var order = await repo.GetAsync(id) ?? throw new();
order.Confirm();
await repo.SaveAsync(order);
}
}
// Infrastructure (adapter)
public class EfOrderRepository(AppDb db) : IOrderRepository // ADAPTER
{
public Task<Order?> GetAsync(OrderId id) => db.Orders.FindAsync(id.Value).AsTask();
public Task SaveAsync(Order order) { db.Update(order); return db.SaveChangesAsync(); }
}
The interface lives with the domain; the implementation lives in infrastructure. Domain doesn't know about EF Core.
Project structure
src/
├── MyApp.Domain/ ← Order, IOrderRepository, value objects
├── MyApp.Application/ ← ConfirmOrderHandler, use cases
├── MyApp.Infrastructure/ ← EfOrderRepository, MessageBusAdapter
└── MyApp.Web/ ← Controllers, DI wiring, host
Project references go inward: Web → Application → Domain. Infrastructure → Application → Domain. The domain is leaf.
What goes in each layer
| Layer | Contains |
|---|---|
| Domain | Entities, value objects, aggregate roots, domain services, repository interfaces, domain events |
| Application | Use cases (command/query handlers), application services, DTOs, port interfaces beyond repositories (email, payment gateway) |
| Infrastructure | EF Core implementations, third-party clients, repository implementations, file/email/HTTP adapters |
| Presentation | Controllers, Minimal API endpoints, gRPC services, view models |
Ports for non-repository concerns
// Port (Application layer)
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(PaymentRequest req);
}
// Adapter (Infrastructure layer)
public class StripePaymentGateway(IHttpClientFactory http) : IPaymentGateway
{
public Task<PaymentResult> ChargeAsync(PaymentRequest req) => /* ... */;
}
Any external dependency gets a port. Tests substitute fakes. Production swaps adapters (Stripe → PayPal).
Onion's contribution — explicit layering
Onion architecture diagrams show the layers as concentric rings. The rule: outer references inner; never reverse. Same code as hexagonal; different visual metaphor.
Clean Architecture's specifics
Uncle Bob's variant:
| Ring | Name |
|---|---|
| 1 (innermost) | Entities |
| 2 | Use Cases (application services) |
| 3 | Interface Adapters (controllers, presenters, gateways) |
| 4 (outermost) | Frameworks & Drivers |
Same dependency rule. Adds explicit naming for "presenter" pattern (use case outputs a DTO; presenter formats it for the UI).
Common pitfalls
Anemic domain model:
// ❌ Just data, no behavior
public class Order
{
public Guid Id { get; set; }
public OrderStatus Status { get; set; }
public decimal Total { get; set; }
}
The "Order" is anemic — every operation lives in a service that mutates it. Defeats the purpose of domain encapsulation.
// ✅ Behavior on the entity
public class Order
{
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
public decimal Total { get; private set; }
public void Confirm() => Status = OrderStatus.Confirmed;
public void Ship(string trackingNumber) { ... }
}
Generic repository abuse:
Generic repositories often turn into a thin layer over EF Core's DbSet, adding nothing. Prefer specific repositories that expose only domain-meaningful operations.
Over-engineering small services:
For a CRUD service with 5 endpoints, full clean architecture is heavy. Three classes per use case (handler, validator, mapper) ÷ 5 use cases = 15 classes for what could be a few endpoints. Start simple; refactor when complexity warrants.
What this gets you
- Testable domain — no infrastructure to mock.
- Swappable infrastructure — switch SQL → Cosmos by writing a new adapter.
- Clear dependencies — junior dev can find where a feature lives.
- Long-term maintainability — changes don't ripple from outer to inner.
When to reach for it
- Large or growing app with complex business rules.
- Multiple delivery mechanisms (web, gRPC, message handler) over the same domain.
- Long-lived service where infrastructure may change (DB swap, broker swap).
- Team needs structure beyond ad-hoc.
When to skip it
- Small CRUD service — 4 entities, 10 endpoints; vertical slices serve better.
- Prototype — premature structure slows you down.
- Library that's mostly utility — no domain to protect.
Code: correct vs wrong
❌ Wrong: domain depending on EF Core
// Domain.Order references Microsoft.EntityFrameworkCore
[Table("Orders")]
public class Order { /* ... */ }
✅ Correct: pure domain; mapping in infrastructure
// Domain.Order — no EF references
public class Order { /* pure */ }
// Infrastructure.OrderConfiguration : IEntityTypeConfiguration<Order>
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> b) { b.ToTable("Orders"); /* ... */ }
}
❌ Wrong: anemic domain + service-heavy
public class OrderService(AppDb db)
{
public async Task ConfirmAsync(Guid id)
{
var o = await db.Orders.FindAsync(id);
o.Status = OrderStatus.Confirmed; // mutates from outside
o.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
✅ Correct: behavior on the entity
public class Order
{
public void Confirm() { Status = OrderStatus.Confirmed; UpdatedAt = DateTime.UtcNow; }
}
public class ConfirmOrderHandler(IOrderRepository repo)
{
public async Task HandleAsync(OrderId id)
{
var order = await repo.GetAsync(id) ?? throw new();
order.Confirm();
await repo.SaveAsync(order);
}
}
Design patterns for this topic
Pattern 1 — "Project per layer; references inward"
- Intent: structural enforcement of dependency rule.
Pattern 2 — "Ports defined by application; adapters in infrastructure"
- Intent: application owns the contract.
Pattern 3 — "Domain entity carries behavior"
- Intent: rich domain; cohesion.
Pattern 4 — "EF mapping via IEntityTypeConfiguration<T>"
- Intent: keep domain entities free of EF attributes.
Pattern 5 — "Specific repositories, not generic"
- Intent: intent-revealing methods; lean interface.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Strict layering | Testable; clear | Verbose for small apps |
| Ports & adapters | Swappable infra | Many interfaces |
| Anti-anemic | Domain encapsulates rules | Requires DDD mindset |
| Clean / Onion | Same idea | Two more terms in the literature |
When to use / when to avoid
- Use for medium-large apps with complex business logic.
- Use when multiple delivery mechanisms reuse the domain.
- Avoid for simple CRUD — vertical slices win.
- Avoid generic repositories — they hide opportunities for intent-revealing names.
Interview Q&A
Q1. Difference between Clean, Onion, Hexagonal? Same fundamental idea (dependency points inward; domain at center) with different vocabularies. Clean explicitly names rings; Onion uses concentric metaphor; Hexagonal uses ports & adapters.
Q2. Why is the dependency rule "inward"? So the domain doesn't depend on infrastructure (DB, HTTP, framework). The domain stays testable and stable while infrastructure changes.
Q3. What's a "port"? An interface defined by the application/domain layer. The infrastructure provides the "adapter" implementation.
Q4. What's an "anemic domain model"? Entities are pure data containers; behavior lives in services. Defeats encapsulation; everyone can modify entity state directly.
Q5. When NOT to use Clean Architecture? Small CRUD services; prototypes; libraries without domain logic.
Q6. Where does EF Core mapping go in Clean Architecture? Infrastructure project, via IEntityTypeConfiguration<T>. Domain entities stay pure.
Q7. What's the "presenter" pattern? Use case returns DTO; presenter formats for the UI. Decouples use case output from view representation.
Q8. Generic repository — pro or anti-pattern? Generally anti-pattern. Add specific repositories with intent-revealing methods (IOrderRepository.FindActiveByCustomer).
Q9. How do you start migrating an existing app to Clean Architecture? Identify the domain core; extract entity behavior from services. Pull repository implementations behind interfaces. Move EF mappings out of entities.
Q10. Does this require multiple projects? Conceptually no; structurally yes (one project per layer). Single-project apps often violate the dependency rule via accident; multi-project enforces it.
Gotchas / common mistakes
- ⚠️ Domain referencing infrastructure — EF attributes on entity classes.
- ⚠️ Anemic domain — services do all the work.
- ⚠️ Generic repository that just wraps DbSet.
- ⚠️ Over-engineering small services — heavy ceremony.
- ⚠️ One-to-one DTO ↔ entity mapping without thought — "DDD repository" returns DTOs.