Lessons in this course (8)

3. Onion, Hexagonal and Clean Architecture

Intermediate★ Must knowLesson 3 of 8

All three share one idea: the domain and use cases are the centre; frameworks, databases and UIs are details at the edge, connected through interfaces (Dependency Inversion).

text
            ┌────────────────────────────────────────┐
            │  Presentation (Api, Web, Blazor)        │
            │   ┌──────────────────────────────────┐  │
            │   │ Infrastructure (EF Core, email,  │  │
            │   │ files, external APIs)            │  │
            │   │   ┌──────────────────────────┐   │  │
            │   │   │ Application (use cases,  │   │  │
            │   │   │ interfaces, DTOs)        │   │  │
            │   │   │   ┌──────────────────┐   │   │  │
            │   │   │   │ Domain (entities,│   │   │  │
            │   │   │   │ value objects,   │   │   │  │
            │   │   │   │ domain rules)    │   │   │  │
            │   │   │   └──────────────────┘   │   │  │
            │   │   └──────────────────────────┘   │  │
            │   └──────────────────────────────────┘  │
            └────────────────────────────────────────┘
                  dependencies point INWARD only
  • Hexagonal (ports & adapters): the application exposes ports (interfaces like IOrderRepository, IPaymentGateway); adapters implement them (SQL adapter, Stripe adapter) or drive the app (REST controller, message consumer).
  • Onion: concentric rings — Domain Model → Domain Services → Application Services → outer ring (UI, infrastructure, tests).
  • Clean Architecture: Entities → Use Cases → Interface Adapters → Frameworks & Drivers; same dependency rule.

Clean Architecture folder structure (ASP.NET Core):

text
src/
├── Shop.Domain/                         no project references, no NuGet except tiny helpers
│   ├── Common/          BaseEntity.cs, ValueObject.cs, IDomainEvent.cs
│   ├── Entities/        Order.cs, OrderLine.cs, Customer.cs, Product.cs
│   ├── ValueObjects/    Money.cs, Address.cs, Email.cs
│   ├── Enums/           OrderStatus.cs
│   ├── Events/          OrderPlacedEvent.cs
│   └── Exceptions/      DomainException.cs
├── Shop.Application/                    references Domain only
│   ├── Common/
│   │   ├── Interfaces/  IApplicationDbContext.cs, IEmailSender.cs, ICurrentUser.cs, IDateTime.cs
│   │   ├── Behaviours/  ValidationBehaviour.cs, LoggingBehaviour.cs (pipeline behaviours)
│   │   ├── Exceptions/  NotFoundException.cs, ValidationException.cs
│   │   └── Models/      PagedList.cs, Result.cs
│   ├── Orders/
│   │   ├── Commands/PlaceOrder/   PlaceOrderCommand.cs, PlaceOrderHandler.cs, PlaceOrderValidator.cs
│   │   ├── Queries/GetOrders/     GetOrdersQuery.cs, GetOrdersHandler.cs, OrderDto.cs
│   │   └── EventHandlers/         OrderPlacedEmailHandler.cs
│   └── DependencyInjection.cs     AddApplication() extension
├── Shop.Infrastructure/                 references Application (and Domain)
│   ├── Persistence/     ShopDbContext.cs, Configurations/OrderConfiguration.cs, Migrations/, Interceptors/
│   ├── Repositories/    (if used) OrderRepository.cs
│   ├── Services/        SmtpEmailSender.cs, DateTimeService.cs, BlobFileStorage.cs
│   ├── Identity/        identity/auth integration
│   └── DependencyInjection.cs     AddInfrastructure(configuration)
└── Shop.Api/                            references Application + Infrastructure (composition root)
    ├── Controllers/ or Endpoints/   OrdersController.cs / OrderEndpoints.cs
    ├── Middleware/      ExceptionHandler.cs
    ├── Filters/
    ├── Program.cs       builder.Services.AddApplication().AddInfrastructure(cfg); app pipeline
    └── appsettings.json
tests/
├── Shop.Domain.UnitTests/
├── Shop.Application.UnitTests/
├── Shop.Infrastructure.IntegrationTests/     (Testcontainers SQL Server)
└── Shop.Api.FunctionalTests/                  (WebApplicationFactory)
csharp
// Application defines what it needs...
public interface IEmailSender { Task SendAsync(string to, string subject, string body, CancellationToken ct); }
// ...Infrastructure provides it...
public sealed class SmtpEmailSender(IOptions<SmtpOptions> o) : IEmailSender { /* SmtpClient / MailKit */ }
// ...Api wires it (composition root)
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();

Benefits: domain logic testable without a database, swap infrastructure (SQL → Cosmos, SMTP → SendGrid) without touching use cases, framework upgrades isolated. Costs: more projects and mapping code — overkill for small CRUD apps.