Przejdź do treści głównej

Clean Architecture and Domain-Driven Design in Practice 2025

Complete guide to Clean Architecture and Domain-Driven Design: application layers, Bounded Contexts, Aggregates, tactical and strategic patterns, CQRS, Event Sourcing. Practical implementation examples in .NET and TypeScript for enterprise applications.

Author: Michał Wojciechowski··15 min read
Clean Architecture - system diagrams and structures

Every senior developer has encountered this problem: code that started elegant and simple becomes impossible to maintain after years. Business logic scattered across controllers, frameworks penetrating every corner of the application, tests requiring the entire infrastructure to run. Changing the database or framework becomes a quarter-long project.

Clean Architecture and Domain-Driven Design are the answer to these problems. These are not new concepts - Eric Evans published "Domain-Driven Design" in 2003, Robert C. Martin (Uncle Bob) codified Clean Architecture in 2012. But in 2025, these patterns are more relevant than ever.

Why Clean Architecture in 2025?

  • Technologies change - frameworks come and go, business logic remains
  • Microservices require good boundaries - DDD strategic patterns define Bounded Contexts
  • Testing must be fast - domain unit tests without infrastructure in milliseconds
  • Teams grow - clear boundaries enable parallel development
  • Maintenance costs grow exponentially - good foundations are an investment

01Clean Architecture Foundations

Clean Architecture is based on several fundamental principles that Robert Martin identified as key to maintainable software. They all serve one purpose: separating business logic from technical concerns.

Dependency Inversion Principle (most important)

Dependencies in code should point inward - from infrastructure to domain, never the opposite. The Domain layer knows nothing about databases, frameworks, or external APIs.

// ❌ BAD: Domain depends on infrastructure
public class OrderService
{
    private readonly SqlConnection _db;  // Direct DB dependency!

    public void CreateOrder(Order order)
    {
        _db.Execute("INSERT INTO Orders...");  // Domain knows SQL!
    }
}

// ✅ GOOD: Domain defines interface, infrastructure implements
public interface IOrderRepository  // Domain interface
{
    Task SaveAsync(Order order);
}

public class OrderService
{
    private readonly IOrderRepository _repository;  // Abstraction!

    public async Task CreateOrder(Order order)
    {
        await _repository.SaveAsync(order);  // Domain logic only
    }
}

// Infrastructure implements:
public class SqlOrderRepository : IOrderRepository
{
    // SQL details here, domain doesn't see this
}

Separation of Concerns

Each layer has its responsibility and doesn't interfere with others:

  • Domain - business rules
  • Application - use cases
  • Infrastructure - DB, API, files
  • Presentation - UI, controllers

Testability First

Good architecture is one that's easy to test:

  • Domain unit tests without mocks
  • Integration tests with in-memory DB
  • Application logic without UI
  • Fast feedback loops

Uncle Bob on Clean Architecture:

"The outer circles are mechanisms. The inner circles are policies. The overriding rule that makes this architecture work is the Dependency Rule: Source code dependencies must point only inward, toward higher-level policies."

02Clean Architecture Layers

Architecture layers - conceptual visualization

Clean Architecture divides the application into four main layers arranged in concentric circles. The closer to the center, the more abstract and stable the code. Outer layers can change - switching the DB from SQL to MongoDB shouldn't affect business logic.

1

Domain Layer (center)

The heart of the application. Contains business rules that are independent of any technology. Has no dependencies - everything depends on the domain.

Contains:

  • Entities - objects with identity (Order, Customer, Product)
  • Value Objects - immutable concepts (Money, Email, Address)
  • Aggregates - clusters of entities with consistency boundaries
  • Domain Events - something happened in domain (OrderCreated)
  • Domain Services - logic that doesn't fit in entity
  • Specifications - reusable business rules
// C# - Domain Entity with business logic
public class Order : Entity<OrderId>  // Aggregate Root
{
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    private readonly List<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    public Money Total => _items
        .Select(i => i.UnitPrice * i.Quantity)
        .Aggregate(Money.Zero, (a, b) => a + b);

    // Business logic in domain
    public void AddItem(Product product, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Cannot modify confirmed order");

        if (quantity <= 0)
            throw new DomainException("Quantity must be positive");

        var item = _items.FirstOrDefault(i => i.ProductId == product.Id);
        if (item != null)
            item.IncreaseQuantity(quantity);
        else
            _items.Add(new OrderItem(product.Id, quantity, product.Price));

        AddDomainEvent(new OrderItemAdded(Id, product.Id, quantity));
    }

    public void Confirm()
    {
        if (!_items.Any())
            throw new DomainException("Cannot confirm empty order");

        Status = OrderStatus.Confirmed;
        AddDomainEvent(new OrderConfirmed(Id, Total));
    }
}
2

Application Layer

Orchestrates use cases. Fetches data through repository interfaces (defined in domain), executes business logic, saves changes. Contains no business rules - delegates to domain.

Contains:

  • Commands & Queries - CQRS pattern (CreateOrder command)
  • Handlers - orchestration logic for use cases
  • DTOs - data transfer objects for UI communication
  • Validators - input validation (FluentValidation)
  • Mappers - domain ↔ DTO mapping
  • Interfaces - for external services (IEmailSender)
// C# - Application Command Handler (CQRS)
public class CreateOrderCommandHandler
    : ICommandHandler<CreateOrderCommand, OrderDto>
{
    private readonly IOrderRepository _orders;
    private readonly IProductRepository _products;
    private readonly IUnitOfWork _unitOfWork;

    public async Task<OrderDto> Handle(
        CreateOrderCommand command,
        CancellationToken ct)
    {
        // 1. Load aggregate
        var customer = await _customers.GetByIdAsync(
            command.CustomerId, ct);

        // 2. Create domain entity
        var order = Order.Create(customer.Id);

        // 3. Business logic (delegated to domain)
        foreach (var item in command.Items)
        {
            var product = await _products.GetByIdAsync(
                item.ProductId, ct);
            order.AddItem(product, item.Quantity);
        }

        // 4. Persist
        await _orders.AddAsync(order, ct);
        await _unitOfWork.SaveChangesAsync(ct);

        // 5. Return DTO (not domain entity!)
        return OrderDto.FromDomain(order);
    }
}

// TypeScript - similar pattern
export class CreateOrderHandler
  implements ICommandHandler<CreateOrderCommand>
{
  constructor(
    private orders: IOrderRepository,
    private products: IProductRepository,
    private unitOfWork: IUnitOfWork
  ) {}

  async execute(command: CreateOrderCommand): Promise<OrderDto> {
    const order = Order.create(command.customerId);

    for (const item of command.items) {
      const product = await this.products.getById(item.productId);
      order.addItem(product, item.quantity);
    }

    await this.orders.save(order);
    await this.unitOfWork.commit();

    return OrderDto.fromDomain(order);
  }
}
3

Infrastructure Layer

Technical implementations - databases, external APIs, file systems, message queues. Implements interfaces defined in domain/application.

Contains:

  • Repository Implementations - EF Core, Dapper, MongoDB
  • DB Context - Entity Framework DbContext
  • External Services - email, SMS, payment gateways
  • Caching - Redis, in-memory cache
  • Message Brokers - RabbitMQ, Kafka publishers
  • Logging - Serilog, Application Insights
// C# - Infrastructure Repository Implementation
public class EfOrderRepository : IOrderRepository  // implements domain interface
{
    private readonly AppDbContext _context;

    public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
    {
        return await _context.Orders
            .Include(o => o.Items)  // EF navigation
            .FirstOrDefaultAsync(o => o.Id == id, ct);
    }

    public async Task AddAsync(Order order, CancellationToken ct)
    {
        await _context.Orders.AddAsync(order, ct);
    }
}

// EF Core configuration (infrastructure concern)
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders");
        builder.HasKey(o => o.Id);

        builder.Property(o => o.Id)
            .HasConversion(id => id.Value, value => new OrderId(value));

        builder.OwnsMany(o => o.Items, items =>
        {
            items.ToTable("OrderItems");
            items.WithOwner().HasForeignKey("OrderId");
            items.Property(i => i.UnitPrice)
                .HasConversion(
                    money => money.Amount,
                    amount => new Money(amount));
        });
    }
}
4

Presentation Layer

User interface - API controllers, Razor Pages, React components, GraphQL resolvers. Passes input to application layer, formats output for users.

// ASP.NET Core - API Controller (thin!)
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;  // MediatR for CQRS

    [HttpPost]
    public async Task<ActionResult<OrderDto>> CreateOrder(
        [FromBody] CreateOrderRequest request,
        CancellationToken ct)
    {
        // Validation via FluentValidation (middleware)
        var command = new CreateOrderCommand(
            request.CustomerId,
            request.Items);

        // Delegate to application layer
        var result = await _mediator.Send(command, ct);

        return CreatedAtAction(
            nameof(GetOrder),
            new { id = result.Id },
            result);
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<OrderDto>> GetOrder(
        Guid id,
        CancellationToken ct)
    {
        var query = new GetOrderQuery(id);
        var result = await _mediator.Send(query, ct);

        return result is null ? NotFound() : Ok(result);
    }
}

Dependency Flow:

Presentation → Application → Domain ← Infrastructure

Infrastructure is NOT at the bottom - it's on the side. It implements interfaces defined by domain. This is the essence of Dependency Inversion.

03DDD Strategic Patterns

Strategic planning - team defining context boundaries

Strategic DDD consists of system-level patterns. Before you start coding Aggregates and Value Objects, you must define the high-level structure. Eric Evans calls this the "problem space" - understanding the business domain and its division.

Bounded Context - most important pattern

Bounded Context is a boundary within which a specific domain model is valid. The same word ("Customer", "Order") can mean different things in different contexts.

Sales Context

Customer: has shopping history, preferences, wishlist. Order: aggregates items, calculates total, applies discounts.

Fulfillment Context

Customer: just a shipping address. Order: list of items to pack, shipping method, tracking number.

Accounting Context

Customer: billing info, credit limit. Order: invoice, payment status, accounting entries, tax calculation.

In monolith - separate modules. In microservices - separate services. Bounded Context = deployment unit boundary.

Ubiquitous Language

Common language between developers and domain experts. Code uses exactly the same terms as business. You don't translate - you use their vocabulary.

❌ Without Ubiquitous Language:

class CustomerData {
  string Name;
  List<PurchaseRecord> Buys;
  bool IsVip;
}

Developer language, business doesn't understand

✅ With Ubiquitous Language:

class Customer {
  CustomerName Name;
  OrderHistory Orders;
  LoyaltyTier Tier;
}

Exact words from business glossary

Context Mapping

How do different Bounded Contexts communicate? Eric Evans identified several relationship patterns:

Partnership

Two contexts are strongly related, teams coordinate changes. Use when: tight business coupling is unavoidable. Example: Orders ↔ Payments.

Customer-Supplier

Upstream team (supplier) provides API, downstream team (customer) uses it. Supplier takes feedback from customer. Example: Inventory (supplier) → Sales (customer).

Conformist

Downstream team has no influence on upstream API - must adapt. Example: integration with external payment gateway. Your code conforms to their model.

Anti-Corruption Layer (ACL)

Translation layer protecting your domain model from external system. Example: legacy system has poor API - ACL translates it to clean domain objects. Don't let legacy infect new code!

Shared Kernel

Small subset of domain shared between contexts. Requires strict coordination - change affects both teams. Use sparingly! Example: common Value Objects (Money, Address).

Practical application:

In modular monolith - Bounded Contexts are modules. In microservices - separate services. Strategic DDD first, deployment strategy after.

Context Mapping shows where you need integration events, where shared libraries, where API calls. It's a blueprint for system architecture.

04DDD Tactical Patterns - Building Blocks

Tactical patterns are concrete building blocks for domain model implementation. These are code-level patterns showing how to structure domain logic. Most confusion around DDD concerns tactical patterns specifically.

Entity vs Value Object

Entity - has identity

Object with unique identity that persists over time. Two Customers with same name/email are different Customers (different IDs).

public abstract class Entity<TId>
{
    public TId Id { get; protected set; }

    public override bool Equals(object obj)
    {
        if (obj is not Entity<TId> other)
            return false;

        return Id.Equals(other.Id);  // identity!
    }
}

public class Customer : Entity<CustomerId>
{
    public string Name { get; private set; }
    public Email Email { get; private set; }
    // Can change name/email, still same Customer
}

Value Object - no identity

Immutable, equality by value. Money(10, "USD") == Money(10, "USD"). No ID, we don't track changes - replace entirely.

public record Money  // C# record = immutable VO
{
    public decimal Amount { get; init; }
    public string Currency { get; init; }

    public static Money operator +(Money a, Money b)
    {
        if (a.Currency != b.Currency)
            throw new InvalidOperationException();
        return new Money
        {
            Amount = a.Amount + b.Amount,
            Currency = a.Currency
        };
    }
}

// TypeScript
export class Money {
  constructor(
    readonly amount: number,
    readonly currency: string
  ) {}

  add(other: Money): Money {
    if (this.currency !== other.currency)
      throw new Error("Currency mismatch");
    return new Money(
      this.amount + other.amount,
      this.currency
    );
  }
}

When Entity, when Value Object?

Question: "Does changing attributes create a new concept?". Address changes - it's a Value Object (new address = new object). Customer changes address - it's an Entity (same Customer, different address).

Aggregates - Consistency Boundaries

Aggregate is a cluster of entities/VOs treated as a unit. Aggregate Root is the single entity serving as entry point. External objects can only reference the root, not internal entities.

Example: Order Aggregate

public class Order : Entity<OrderId>, IAggregateRoot  // Root!
{
    private readonly List<OrderItem> _items = new();  // internal entities
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    // All modifications through Aggregate Root
    public void AddItem(ProductId productId, int quantity, Money price)
    {
        // Aggregate enforces invariants
        if (Status != OrderStatus.Draft)
            throw new DomainException("Cannot modify confirmed order");

        var existingItem = _items.Find(i => i.ProductId == productId);
        if (existingItem != null)
            existingItem.IncreaseQuantity(quantity);  // internal call OK
        else
            _items.Add(new OrderItem(productId, quantity, price));
    }

    public void RemoveItem(OrderItemId itemId)
    {
        var item = _items.Find(i => i.Id == itemId);
        if (item == null)
            throw new DomainException("Item not found");

        _items.Remove(item);
    }
}

public class OrderItem : Entity<OrderItemId>  // Internal entity
{
    // No public constructor! Only through Order.AddItem()
    internal OrderItem(ProductId productId, int quantity, Money price)
    {
        ProductId = productId;
        Quantity = quantity;
        UnitPrice = price;
    }

    internal void IncreaseQuantity(int amount)  // internal!
    {
        Quantity += amount;
    }
}

// Repository operates on Aggregate Root
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id);  // loads whole aggregate
    Task AddAsync(Order order);  // saves whole aggregate
}

Aggregate Design Rules:

  • Small aggregates - only entities that must be consistent together
  • Reference by ID - don't hold whole aggregate objects, only IDs
  • One transaction = one aggregate - don't modify multiple aggregates in one transaction
  • Eventual consistency between aggregates - use Domain Events

Domain Events - Reactive Domain Logic

Domain Event is "something happened" in the domain. Enables loose coupling - one part of domain reacts to changes in another without direct dependency.

// Domain Event - immutable record
public record OrderConfirmedEvent(
    OrderId OrderId,
    CustomerId CustomerId,
    Money TotalAmount,
    DateTime ConfirmedAt
) : IDomainEvent;

// Aggregate raises event
public class Order : Entity<OrderId>
{
    public void Confirm()
    {
        if (!_items.Any())
            throw new DomainException("Empty order");

        Status = OrderStatus.Confirmed;
        ConfirmedAt = DateTime.UtcNow;

        // Add event to aggregate
        AddDomainEvent(new OrderConfirmedEvent(
            Id, CustomerId, Total, ConfirmedAt));
    }
}

// Event handler in application layer
public class OrderConfirmedEventHandler
    : IDomainEventHandler<OrderConfirmedEvent>
{
    private readonly IEmailService _emailService;
    private readonly IInventoryService _inventoryService;

    public async Task Handle(OrderConfirmedEvent @event)
    {
        // Send confirmation email
        await _emailService.SendOrderConfirmation(
            @event.CustomerId, @event.OrderId);

        // Reserve inventory
        await _inventoryService.ReserveItems(@event.OrderId);
    }
}

Benefits of Domain Events:

  • Decoupling - Order doesn't need to know about email/inventory
  • Auditability - event log shows what happened when
  • Integration - other Bounded Contexts can subscribe to events
  • Eventual consistency - handle side effects asynchronously

Repository Pattern

Repository abstracts data access. Domain defines interface, infrastructure implements it. Repository works with Aggregate Roots only, never with internal entities.

// Domain layer - interface only
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct);
    Task<List<Order>> GetByCustomerAsync(CustomerId customerId, CancellationToken ct);
    Task AddAsync(Order order, CancellationToken ct);
    Task UpdateAsync(Order order, CancellationToken ct);
    Task DeleteAsync(OrderId id, CancellationToken ct);
}

// Infrastructure layer - implementation
public class EfOrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
    {
        return await _context.Orders
            .Include(o => o.Items)  // Load aggregate children
            .ThenInclude(i => i.Product)
            .FirstOrDefaultAsync(o => o.Id == id, ct);
    }

    public async Task UpdateAsync(Order order, CancellationToken ct)
    {
        _context.Orders.Update(order);  // EF tracks changes
        // Actual save happens in UnitOfWork
    }
}

Domain Services

When business logic doesn't naturally fit in any Entity or Value Object, use Domain Service. It's stateless and operates on domain objects.

// Domain Service - pricing calculation involving multiple entities
public class OrderPricingService
{
    public Money CalculateTotal(
        Order order,
        Customer customer,
        List<Promotion> activePromotions)
    {
        var subtotal = order.Items
            .Select(i => i.UnitPrice * i.Quantity)
            .Aggregate(Money.Zero, (a, b) => a + b);

        // Apply customer tier discount
        var discount = customer.LoyaltyTier switch
        {
            LoyaltyTier.Gold => subtotal * 0.1m,
            LoyaltyTier.Platinum => subtotal * 0.15m,
            _ => Money.Zero
        };

        // Apply promotions
        foreach (var promo in activePromotions)
        {
            if (promo.AppliesTo(order))
                discount += promo.CalculateDiscount(subtotal);
        }

        return subtotal - discount;
    }
}

Key Takeaways - Tactical Patterns:

  • • Entity when identity matters, Value Object when value matters
  • • Keep Aggregates small - only entities that must be consistent
  • • Use Domain Events for cross-aggregate communication
  • • Repository per Aggregate Root, not per Entity
  • • Domain Services for logic spanning multiple Aggregates

05CQRS and Event Sourcing

CQRS - separation of read and write models

CQRS (Command Query Responsibility Segregation) and Event Sourcing are often mentioned alongside Clean Architecture and DDD. They're optional patterns - not every system needs them, but when used appropriately, they provide powerful capabilities.

CQRS - Command Query Responsibility Segregation

Separate read models from write models. Commands change state (CreateOrder, UpdateCustomer), Queries return data (GetOrders, GetCustomerById). Different models, different optimization strategies.

Write Model (Commands)

  • • Normalized database structure
  • • Domain model with business rules
  • • Aggregates enforce invariants
  • • Optimized for data integrity
  • • Transactional consistency

Read Model (Queries)

  • • Denormalized views
  • • DTOs optimized for UI
  • • No business logic
  • • Optimized for read performance
  • • Eventual consistency OK
// C# - CQRS with MediatR
// Command - changes state
public record CreateOrderCommand(
    Guid CustomerId,
    List<OrderItemDto> Items
) : ICommand<OrderDto>;

public class CreateOrderHandler : ICommandHandler<CreateOrderCommand, OrderDto>
{
    private readonly IOrderRepository _orders;
    private readonly IUnitOfWork _unitOfWork;

    public async Task<OrderDto> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        var order = Order.Create(new CustomerId(cmd.CustomerId));

        foreach (var item in cmd.Items)
            order.AddItem(item.ProductId, item.Quantity, item.Price);

        await _orders.AddAsync(order, ct);
        await _unitOfWork.SaveChangesAsync(ct);

        return OrderDto.FromDomain(order);
    }
}

// Query - returns data
public record GetOrdersByCustomerQuery(Guid CustomerId) : IQuery<List<OrderSummaryDto>>;

public class GetOrdersByCustomerHandler
    : IQueryHandler<GetOrdersByCustomerQuery, List<OrderSummaryDto>>
{
    private readonly IReadDbContext _readDb;  // Separate read database!

    public async Task<List<OrderSummaryDto>> Handle(
        GetOrdersByCustomerQuery query,
        CancellationToken ct)
    {
        // Direct SQL query to denormalized view
        return await _readDb.OrderSummaries
            .Where(o => o.CustomerId == query.CustomerId)
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync(ct);
    }
}

Event Sourcing

Instead of storing current state, store all events that led to that state. Aggregate is rebuilt by replaying events. Complete audit log and time-travel capabilities.

// Event-sourced aggregate
public class Order : EventSourcedAggregate
{
    private OrderId _id;
    private OrderStatus _status;
    private List<OrderItem> _items = new();

    // Apply event to rebuild state
    public void Apply(OrderCreatedEvent @event)
    {
        _id = @event.OrderId;
        _status = OrderStatus.Draft;
    }

    public void Apply(OrderItemAddedEvent @event)
    {
        _items.Add(new OrderItem(
            @event.ProductId,
            @event.Quantity,
            @event.UnitPrice));
    }

    public void Apply(OrderConfirmedEvent @event)
    {
        _status = OrderStatus.Confirmed;
    }

    // Command - creates new events
    public void AddItem(ProductId productId, int quantity, Money price)
    {
        if (_status != OrderStatus.Draft)
            throw new DomainException("Cannot modify confirmed order");

        // Add event to uncommitted events
        RaiseEvent(new OrderItemAddedEvent(
            _id, productId, quantity, price));
    }
}

// Event store saves events, not state
public class EventStoreRepository : IOrderRepository
{
    private readonly IEventStore _eventStore;

    public async Task<Order> GetByIdAsync(OrderId id)
    {
        // Load all events for this aggregate
        var events = await _eventStore.GetEventsAsync(id);

        // Replay events to rebuild state
        var order = new Order();
        foreach (var @event in events)
            order.Apply((dynamic)@event);

        return order;
    }

    public async Task SaveAsync(Order order)
    {
        // Save uncommitted events
        var events = order.GetUncommittedEvents();
        await _eventStore.AppendEventsAsync(order.Id, events);
    }
}

Event Sourcing Benefits:

  • Complete audit trail - every change is recorded
  • Time travel - rebuild state at any point in time
  • Event replay - rebuild read models from events
  • Business insights - analyze how system evolved

When to use CQRS and Event Sourcing?

✅ Good candidates:

  • • High read/write ratio requiring different optimization
  • • Complex reporting needs with denormalized views
  • • Audit requirements - need full history
  • • Event-driven architecture - events drive other systems
  • • Eventually consistent is acceptable

❌ Poor candidates:

  • • Simple CRUD applications
  • • Strong immediate consistency requirements
  • • Team unfamiliar with event-driven patterns
  • • Small applications - overhead not justified
  • • No complex read model requirements

Remember:

CQRS and Event Sourcing are independent patterns - you can use CQRS without Event Sourcing, and Event Sourcing without CQRS. But they complement each other beautifully: events from Event Sourcing can build CQRS read models.

06Vertical Slice vs Clean Architecture

Vertical Slice Architecture has gained popularity as an alternative to traditional layered architecture. Understanding the differences helps you choose the right approach for your project.

Clean Architecture

Horizontal layers separated by technical concerns. Domain, Application, Infrastructure, Presentation.

Pros:

  • • Clear separation of concerns
  • • Reusable business logic
  • • Testable domain layer
  • • Framework independence
  • • Good for complex domains

Cons:

  • • More boilerplate code
  • • Feature changes touch multiple layers
  • • Can be overkill for CRUD
  • • Steeper learning curve

Vertical Slice

Features as self-contained slices. Everything needed for a feature in one place.

Pros:

  • • Fast development
  • • Feature isolation
  • • Easy to locate code
  • • Minimal coupling between features
  • • Good for CRUD-heavy apps

Cons:

  • • Potential code duplication
  • • Harder to enforce domain rules
  • • Less structure for complex domains
  • • Shared concerns can be messy

Hybrid Approach

You can combine both! Use Clean Architecture layers with Vertical Slices for features. Best of both worlds.

// Hybrid structure
src/
├── Domain/               // Shared domain layer
│   ├── Entities/
│   ├── ValueObjects/
│   └── Interfaces/
├── Features/             // Vertical slices
│   ├── Orders/
│   │   ├── CreateOrder/
│   │   │   ├── CreateOrderCommand.cs
│   │   │   ├── CreateOrderHandler.cs
│   │   │   ├── CreateOrderValidator.cs
│   │   │   └── CreateOrderEndpoint.cs
│   │   ├── GetOrders/
│   │   │   ├── GetOrdersQuery.cs
│   │   │   ├── GetOrdersHandler.cs
│   │   │   └── GetOrdersEndpoint.cs
│   │   └── OrderDto.cs
│   └── Customers/
│       └── ...
└── Infrastructure/       // Shared infrastructure
    ├── Persistence/
    └── External/

Decision Matrix:

  • Complex domain logic → Clean Architecture
  • CRUD-heavy application → Vertical Slice
  • Large team, need clear boundaries → Clean Architecture
  • Small team, fast delivery → Vertical Slice
  • Mix of both → Hybrid approach

07Testing Strategy

One of the main benefits of Clean Architecture is testability. The architecture enables a comprehensive testing strategy across all layers.

Test Pyramid for Clean Architecture

Unit Tests - Domain Layer

Test business logic in isolation. No infrastructure, no databases, fast execution.

// C# - xUnit Domain Tests
public class OrderTests
{
    [Fact]
    public void AddItem_WithValidProduct_AddsToOrder()
    {
        // Arrange
        var order = Order.Create(new CustomerId(Guid.NewGuid()));
        var product = new Product("Laptop", new Money(1000, "USD"));

        // Act
        order.AddItem(product, 2);

        // Assert
        Assert.Single(order.Items);
        Assert.Equal(2, order.Items.First().Quantity);
        Assert.Equal(new Money(2000, "USD"), order.Total);
    }

    [Fact]
    public void Confirm_EmptyOrder_ThrowsException()
    {
        // Arrange
        var order = Order.Create(new CustomerId(Guid.NewGuid()));

        // Act & Assert
        Assert.Throws<DomainException>(() => order.Confirm());
    }
}

Integration Tests - Application Layer

Test use cases with real infrastructure (in-memory DB, test containers).

// C# - Integration test with WebApplicationFactory
public class CreateOrderTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    [Fact]
    public async Task CreateOrder_ValidRequest_ReturnsCreatedOrder()
    {
        // Arrange
        var request = new CreateOrderRequest
        {
            CustomerId = Guid.NewGuid(),
            Items = new[]
            {
                new OrderItemRequest { ProductId = Guid.NewGuid(), Quantity = 2 }
            }
        };

        // Act
        var response = await _client.PostAsJsonAsync("/api/orders", request);

        // Assert
        response.EnsureSuccessStatusCode();
        var order = await response.Content.ReadFromJsonAsync<OrderDto>();
        Assert.NotNull(order);
        Assert.Single(order.Items);
    }
}

E2E Tests - Full System

Test critical user journeys through UI. Fewer tests, but high confidence.

// Playwright - E2E test
import { test, expect } from '@playwright/test';

test('user can create and view order', async ({ page }) => {
  // Login
  await page.goto('/login');
  await page.fill('[name="email"]', 'user@example.com');
  await page.fill('[name="password"]', 'password');
  await page.click('button[type="submit"]');

  // Create order
  await page.goto('/products');
  await page.click('[data-testid="product-1"]');
  await page.click('[data-testid="add-to-cart"]');
  await page.click('[data-testid="checkout"]');

  // Verify order created
  await expect(page.locator('[data-testid="order-success"]')).toBeVisible();

  // View orders
  await page.goto('/orders');
  await expect(page.locator('[data-testid="order-list"]')).toContainText('Order #');
});

Testing Best Practices:

  • 70% unit tests - fast, isolated, test business logic
  • 20% integration tests - test component interactions
  • 10% E2E tests - test critical user paths
  • Test behavior, not implementation - refactor-friendly tests
  • Use test containers for infrastructure - realistic integration tests

08Real Implementation Examples

Software development - real implementation

Let's look at complete examples in .NET and TypeScript showing how all the patterns fit together.

.NET Implementation

Project Structure:

Solution.sln
├── Domain/                           # Domain layer
│   ├── Common/
│   │   ├── Entity.cs
│   │   ├── ValueObject.cs
│   │   └── IDomainEvent.cs
│   ├── Orders/
│   │   ├── Order.cs                 # Aggregate Root
│   │   ├── OrderItem.cs             # Entity
│   │   ├── OrderStatus.cs           # Enum
│   │   └── IOrderRepository.cs      # Interface
│   └── ValueObjects/
│       ├── Money.cs
│       └── Email.cs
├── Application/                      # Application layer
│   ├── Common/
│   │   ├── ICommand.cs
│   │   ├── IQuery.cs
│   │   └── IUnitOfWork.cs
│   ├── Orders/
│   │   ├── Commands/
│   │   │   ├── CreateOrderCommand.cs
│   │   │   └── CreateOrderHandler.cs
│   │   └── Queries/
│   │       ├── GetOrdersQuery.cs
│   │       └── GetOrdersHandler.cs
│   └── DTOs/
│       └── OrderDto.cs
├── Infrastructure/                   # Infrastructure layer
│   ├── Persistence/
│   │   ├── AppDbContext.cs
│   │   ├── OrderRepository.cs
│   │   └── Configurations/
│   │       └── OrderConfiguration.cs
│   └── External/
│       └── EmailService.cs
└── API/                             # Presentation layer
    ├── Controllers/
    │   └── OrdersController.cs
    └── Program.cs

Dependency Injection Setup:

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Domain - no dependencies to register

// Application
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(CreateOrderHandler).Assembly));
builder.Services.AddValidatorsFromAssembly(typeof(CreateOrderHandler).Assembly);

// Infrastructure
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<IEmailService, EmailService>();

// Presentation
builder.Services.AddControllers();

var app = builder.Build();
app.MapControllers();
app.Run();

TypeScript/Node.js Implementation

Project Structure:

src/
├── domain/                          # Domain layer
│   ├── common/
│   │   ├── Entity.ts
│   │   └── DomainEvent.ts
│   ├── orders/
│   │   ├── Order.ts                # Aggregate Root
│   │   ├── OrderItem.ts            # Entity
│   │   └── IOrderRepository.ts     # Interface
│   └── value-objects/
│       └── Money.ts
├── application/                     # Application layer
│   ├── common/
│   │   ├── ICommand.ts
│   │   └── IQuery.ts
│   ├── orders/
│   │   ├── commands/
│   │   │   ├── CreateOrderCommand.ts
│   │   │   └── CreateOrderHandler.ts
│   │   └── queries/
│   │       ├── GetOrdersQuery.ts
│   │       └── GetOrdersHandler.ts
│   └── dtos/
│       └── OrderDto.ts
├── infrastructure/                  # Infrastructure layer
│   ├── persistence/
│   │   ├── PrismaOrderRepository.ts
│   │   └── schema.prisma
│   └── external/
│       └── EmailService.ts
└── presentation/                    # Presentation layer
    ├── api/
    │   ├── routes/
    │   │   └── orders.ts
    │   └── server.ts
    └── middleware/

Dependency Injection with tsyringe:

// container.ts
import { container } from 'tsyringe';
import { IOrderRepository } from './domain/orders/IOrderRepository';
import { PrismaOrderRepository } from './infrastructure/persistence/PrismaOrderRepository';
import { IEmailService } from './application/common/IEmailService';
import { EmailService } from './infrastructure/external/EmailService';

// Register repositories
container.register<IOrderRepository>(
  'IOrderRepository',
  { useClass: PrismaOrderRepository }
);

// Register services
container.register<IEmailService>(
  'IEmailService',
  { useClass: EmailService }
);

// Express route
// orders.ts
import { container } from 'tsyringe';
import { CreateOrderHandler } from '@/application/orders/commands/CreateOrderHandler';

router.post('/orders', async (req, res) => {
  const handler = container.resolve(CreateOrderHandler);
  const result = await handler.execute(req.body);
  res.status(201).json(result);
});

Frequently Asked Questions

What is Clean Architecture and why should you use it?

Clean Architecture is an architectural pattern proposed by Robert C. Martin (Uncle Bob) that separates business logic from technical details. The biggest benefit? Your application isn't hostage to a framework or database. Switching from PostgreSQL to MongoDB, from REST API to GraphQL, from Angular to React - all possible without rewriting business logic. It especially pays off in long-term projects where requirements change and teams need to quickly introduce new features without the risk of "breaking" the entire system.

What is the difference between Clean Architecture and Hexagonal Architecture?

Truth is, the differences are minimal - they're two approaches to the same problem. Hexagonal Architecture (also known as Ports & Adapters) came first and uses the metaphor of ports (input/output interfaces) and adapters (their implementations). Clean Architecture came later and named the layers explicitly: Domain, Application, Infrastructure, Presentation. In practice? You're implementing the same thing - isolating business logic from external dependencies. Choose the name that sounds better to your team.

How does tactical DDD differ from strategic DDD?

It's like the difference between a city plan and a building design. Strategic DDD is looking at the system from a bird's eye view - you define Bounded Contexts (boundaries between modules), Context Mapping (how these modules communicate), Ubiquitous Language (shared language with business). Tactical DDD is diving into code level - Aggregates, Entities, Value Objects, Domain Events. The most common mistake? People start with tactical patterns and create beautiful Aggregates... that make no business sense because nobody defined context boundaries. Always start with strategic - it's the foundation on which you build tactical patterns.

When to use CQRS with Clean Architecture?

CQRS separates write operations from read operations - a separate model for creating orders, a separate one for displaying them. It's worth it when you have asymmetry: lots of reads, few writes (or vice versa), complex reports requiring denormalization, or different scaling requirements for read/write. Example? E-commerce: you save an order to a normalized database, but the dashboard requires aggregation from 10 tables - CQRS lets you have a separate, denormalized read model just for reports. DON'T use it for simple CRUD - that's overengineering. If your edit form looks the same as the details view, CQRS is too much.

Clean Architecture vs Vertical Slice - which to choose?

It depends on your application's character. Clean Architecture works well when you have shared business logic used in multiple places - e.g., a financial system where validation or calculation rules are reused. Vertical Slice Architecture is better for applications where each feature is practically independent - admin panels, backoffice systems where each section is its own world. My recommendation? Start with Clean Architecture for your core domain (where the real logic is), and Vertical Slices for supporting/generic features. And remember - you can combine them: Vertical Slices at the Application Layer level, but with a shared Domain Layer.

Key Takeaways

  • Clean Architecture isn't over-engineering - it's an investment in maintainability for long-term projects
  • Dependency Inversion is crucial - Domain defines interfaces, Infrastructure implements
  • Strategic DDD first (Bounded Contexts), tactical patterns later (Aggregates, VOs)
  • CQRS and Event Sourcing are optional - add when you need them, not by default
  • Testability first - if it's hard to test, the architecture is wrong
  • You can combine Vertical Slices with Clean Architecture - they're not exclusive

Is your system getting out of control?

Legacy code refactoring, Clean Architecture design from scratch, code review of existing systems. I work with teams on implementing DDD and Clean Architecture in .NET and TypeScript. Let's build architecture that lasts years, not months.

Related Articles

Sources

  1. [1] Robert C. Martin - Clean Architecture: A Craftsman's Guide to Software Structure -Amazon
  2. [2] Eric Evans - Domain-Driven Design: Tackling Complexity in the Heart of Software -Amazon
  3. [3] Vaughn Vernon - Implementing Domain-Driven Design -Amazon
  4. [4] Microsoft - .NET Microservices Architecture Guide -https://learn.microsoft.com/en-us/dotnet/architecture/microservices/
  5. [5] Martin Fowler - Domain-Driven Design -https://martinfowler.com/tags/domain%20driven%20design.html
  6. [6] Jimmy Bogard - Vertical Slice Architecture -https://www.jimmybogard.com/vertical-slice-architecture/
  7. [7] Greg Young - CQRS and Event Sourcing -https://cqrs.wordpress.com/
  8. [8] Alistair Cockburn - Hexagonal Architecture -https://alistair.cockburn.us/hexagonal-architecture/
Clean Architecture and DDD in Practice 2025 - Complete Guide | Wojciechowski.app