Skip to content

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:

public interface IRepository<T> { Task<T?> GetAsync(int id); Task SaveAsync(T entity); }

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

  1. Testable domain — no infrastructure to mock.
  2. Swappable infrastructure — switch SQL → Cosmos by writing a new adapter.
  3. Clear dependencies — junior dev can find where a feature lives.
  4. 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 infrastructureEF 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.

Further reading