Przejdź do treści
Backend

Clean Architecture and DDD in Practice - A Complete Guide

Published on:
·7 min read·Author: MDS Software Solutions Group

Clean Architecture DDD

backend

Clean Architecture and DDD in Practice

Building scalable, testable, and maintainable applications is one of the greatest challenges in modern software engineering. Two approaches that have set the gold standard in system design for years are Clean Architecture by Robert C. Martin and Domain-Driven Design (DDD) by Eric Evans. In this article, we will show how these concepts work together, how to implement them in practice using .NET and TypeScript, and how they compare with hexagonal architecture.

What is Clean Architecture?#

Clean Architecture is an architectural pattern proposed by Robert C. Martin (Uncle Bob) in 2012. Its main goal is to create a system where business logic is completely independent of frameworks, databases, user interfaces, and any external dependencies.

The key idea is dividing the system into concentric layers, where dependencies always point inward - from implementation details to business rules.

Clean Architecture Layers#

1. Entities (Domain Entities)

Entities are the core of the system. They contain the most important business rules that would be true regardless of whether the application exists. Entities know nothing about databases, frameworks, or user interfaces.

// .NET - Domain Entity
public class Order
{
    public Guid Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public Money TotalAmount { get; private set; }
    public OrderStatus Status { get; private set; }
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    public Order(CustomerId customerId)
    {
        Id = Guid.NewGuid();
        CustomerId = customerId;
        Status = OrderStatus.Draft;
        TotalAmount = Money.Zero("PLN");
    }

    public void AddLine(Product product, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Nie można modyfikować zatwierdzonego zamówienia.");

        if (quantity <= 0)
            throw new DomainException("Ilość musi być większa od zera.");

        var line = new OrderLine(product.Id, product.Price, quantity);
        _lines.Add(line);
        RecalculateTotal();
    }

    public void Confirm()
    {
        if (!_lines.Any())
            throw new DomainException("Nie można zatwierdzić pustego zamówienia.");

        Status = OrderStatus.Confirmed;
        _domainEvents.Add(new OrderConfirmedEvent(Id, CustomerId, TotalAmount));
    }

    private void RecalculateTotal()
    {
        TotalAmount = _lines.Aggregate(
            Money.Zero("PLN"),
            (sum, line) => sum.Add(line.SubTotal));
    }
}

2. Use Cases

The Use Cases layer contains application-specific logic. It orchestrates data flow between entities and directs them to the appropriate output ports. This layer does not know where data comes from or where it goes.

// .NET - Use Case (Application Service)
public class ConfirmOrderUseCase : IRequestHandler<ConfirmOrderCommand, OrderDto>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IPaymentGateway _paymentGateway;
    private readonly IEventPublisher _eventPublisher;
    private readonly IUnitOfWork _unitOfWork;

    public ConfirmOrderUseCase(
        IOrderRepository orderRepository,
        IPaymentGateway paymentGateway,
        IEventPublisher eventPublisher,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _paymentGateway = paymentGateway;
        _eventPublisher = eventPublisher;
        _unitOfWork = unitOfWork;
    }

    public async Task<OrderDto> Handle(
        ConfirmOrderCommand command, CancellationToken ct)
    {
        var order = await _orderRepository.GetByIdAsync(command.OrderId, ct)
            ?? throw new NotFoundException($"Zamówienie {command.OrderId} nie istnieje.");

        order.Confirm();

        await _paymentGateway.InitiatePaymentAsync(
            order.Id, order.TotalAmount, ct);

        await _unitOfWork.SaveChangesAsync(ct);

        foreach (var domainEvent in order.DomainEvents)
            await _eventPublisher.PublishAsync(domainEvent, ct);

        return OrderDto.FromDomain(order);
    }
}

3. Interface Adapters

Adapters convert data from the format convenient for Use Cases to the format required by external systems (databases, APIs, UI) and vice versa. Controllers, presenters, repositories, and mappers are found here.

// TypeScript - Adapter (Controller)
@Controller('/api/orders')
export class OrderController {
  constructor(
    private readonly confirmOrder: ConfirmOrderUseCase,
    private readonly getOrderDetails: GetOrderDetailsQuery,
  ) {}

  @Post(':id/confirm')
  @HttpCode(200)
  async confirm(@Param('id') id: string): Promise<OrderResponse> {
    const command = new ConfirmOrderCommand(id);
    const result = await this.confirmOrder.execute(command);

    return OrderResponse.fromDto(result);
  }

  @Get(':id')
  async getDetails(@Param('id') id: string): Promise<OrderResponse> {
    const query = new GetOrderDetailsQuery(id);
    const result = await this.getOrderDetails.execute(query);

    if (!result) {
      throw new NotFoundException(`Order ${id} not found`);
    }

    return OrderResponse.fromDto(result);
  }
}

4. Frameworks & Drivers

The outermost layer contains implementation details: web frameworks, databases, queuing systems, external APIs. This layer is most susceptible to changes and should therefore be as thin as possible.

// .NET - Repository Implementation (Infrastructure)
public class EfOrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public EfOrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct)
    {
        return await _context.Orders
            .Include(o => o.Lines)
            .FirstOrDefaultAsync(o => o.Id == id, ct);
    }

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

    public void Update(Order order)
    {
        _context.Orders.Update(order);
    }
}

Dependency Rule#

Dependency Rule is the most important principle of Clean Architecture. It states that dependencies in source code can only point inward, toward higher-level layers. The inner layer must know absolutely nothing about the outer layers.

In practice, this means:

  • Entities depend on nothing
  • Use Cases depend only on Entities and define port interfaces
  • Interface Adapters depend on Use Cases and implement ports
  • Frameworks depend on Interface Adapters

Dependency Inversion Principle is the key mechanism enabling adherence to this rule. The inner layer defines the interface (port), and the outer layer provides the implementation (adapter).

// Port (defined in the Use Cases layer)
export interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

// Adapter (implemented in the Infrastructure layer)
export class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async findById(id: OrderId): Promise<Order | null> {
    const data = await this.prisma.order.findUnique({
      where: { id: id.value },
      include: { lines: true },
    });
    return data ? OrderMapper.toDomain(data) : null;
  }

  async save(order: Order): Promise<void> {
    const data = OrderMapper.toPersistence(order);
    await this.prisma.order.upsert({
      where: { id: data.id },
      create: data,
      update: data,
    });
  }
}

Domain-Driven Design - Domain Building Blocks#

Domain-Driven Design is an approach to software design that puts the business domain at the center of the system creation process. DDD introduces a set of tactical patterns (building blocks) that help model business complexity.

Value Objects#

Value Objects are objects defined by their attributes, not identity. Two Value Objects with the same attributes are equal. They are immutable and encapsulate validation rules.

// .NET - Value Object
public record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0)
            throw new DomainException("Kwota nie może być ujemna.");
        if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
            throw new DomainException("Nieprawidłowy kod waluty.");

        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }

    public static Money Zero(string currency) => new(0, currency);

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new DomainException(
                $"Nie można dodać {Currency} i {other.Currency}.");
        return new Money(Amount + other.Amount, Currency);
    }

    public Money Multiply(int factor) => new(Amount * factor, Currency);
}

public record Address(
    string Street,
    string City,
    string PostalCode,
    string Country)
{
    public string FullAddress => $"{Street}, {PostalCode} {City}, {Country}";
}
// TypeScript - Value Object
export class Money {
  private constructor(
    public readonly amount: number,
    public readonly currency: string,
  ) {}

  static create(amount: number, currency: string): Money {
    if (amount < 0) throw new DomainError('Amount cannot be negative');
    if (!/^[A-Z]{3}$/.test(currency))
      throw new DomainError('Invalid currency code');
    return new Money(amount, currency);
  }

  static zero(currency: string): Money {
    return new Money(0, currency);
  }

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

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

Aggregates#

An Aggregate is a cluster of related domain objects treated as a single unit of consistency. Each aggregate has a root (Aggregate Root), which is the only access point for modifying the aggregate state. Aggregates define transaction boundaries.

// .NET - Aggregate Root
public class ShoppingCart : AggregateRoot
{
    public CustomerId CustomerId { get; private set; }
    private readonly List<CartItem> _items = new();
    public IReadOnlyCollection<CartItem> Items => _items.AsReadOnly();
    public Money Total => CalculateTotal();

    public ShoppingCart(CustomerId customerId)
    {
        Id = Guid.NewGuid();
        CustomerId = customerId;
    }

    public void AddItem(ProductId productId, Money price, int quantity)
    {
        var existingItem = _items.FirstOrDefault(
            i => i.ProductId == productId);

        if (existingItem != null)
        {
            existingItem.IncreaseQuantity(quantity);
        }
        else
        {
            _items.Add(new CartItem(productId, price, quantity));
        }

        AddDomainEvent(new ItemAddedToCartEvent(
            Id, productId, quantity));
    }

    public void RemoveItem(ProductId productId)
    {
        var item = _items.FirstOrDefault(
            i => i.ProductId == productId)
            ?? throw new DomainException("Produkt nie jest w koszyku.");

        _items.Remove(item);
    }

    public Order Checkout(ShippingAddress address)
    {
        if (!_items.Any())
            throw new DomainException("Koszyk jest pusty.");

        var order = new Order(CustomerId, address);
        foreach (var item in _items)
            order.AddLine(item.ProductId, item.Price, item.Quantity);

        _items.Clear();
        AddDomainEvent(new CartCheckedOutEvent(Id, order.Id));

        return order;
    }

    private Money CalculateTotal()
    {
        return _items.Aggregate(
            Money.Zero("PLN"),
            (sum, item) => sum.Add(item.SubTotal));
    }
}

Repositories#

Repositories provide data access abstraction for aggregates. They define a collection interface so the domain does not need to know how data is stored. One repository corresponds to one aggregate.

// .NET - Repository Interface (in the domain layer)
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<IReadOnlyList<Order>> GetByCustomerIdAsync(
        CustomerId customerId, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    void Update(Order order);
    Task<bool> ExistsAsync(Guid id, CancellationToken ct = default);
}

Domain Events#

Domain events represent significant facts that occurred in the domain. They allow loose coupling between aggregates and enable reactions to state changes without direct dependencies.

// .NET - Domain Event
public record OrderConfirmedEvent(
    Guid OrderId,
    CustomerId CustomerId,
    Money TotalAmount) : IDomainEvent
{
    public DateTime OccurredAt { get; } = DateTime.UtcNow;
}

// Domain Event Handler
public class SendOrderConfirmationEmail
    : INotificationHandler<OrderConfirmedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ICustomerRepository _customerRepository;

    public SendOrderConfirmationEmail(
        IEmailService emailService,
        ICustomerRepository customerRepository)
    {
        _emailService = emailService;
        _customerRepository = customerRepository;
    }

    public async Task Handle(
        OrderConfirmedEvent notification, CancellationToken ct)
    {
        var customer = await _customerRepository
            .GetByIdAsync(notification.CustomerId, ct);

        await _emailService.SendAsync(
            customer!.Email,
            "Potwierdzenie zamówienia",
            $"Zamówienie #{notification.OrderId} na kwotę " +
            $"{notification.TotalAmount.Amount} {notification.TotalAmount.Currency} " +
            "zostało potwierdzone.",
            ct);
    }
}

Domain Services#

Domain Services contain business logic that does not naturally fit into any entity or Value Object. They operate on multiple aggregates or require access to external resources through ports.

// TypeScript - Domain Service
export class PricingService {
  constructor(
    private readonly discountPolicy: DiscountPolicy,
    private readonly taxCalculator: TaxCalculator,
  ) {}

  calculateFinalPrice(
    items: OrderItem[],
    customer: Customer,
  ): PriceBreakdown {
    const subtotal = items.reduce(
      (sum, item) => sum.add(item.price.multiply(item.quantity)),
      Money.zero('PLN'),
    );

    const discount = this.discountPolicy.calculateDiscount(
      subtotal,
      customer.loyaltyTier,
    );

    const afterDiscount = subtotal.subtract(discount);
    const tax = this.taxCalculator.calculate(afterDiscount, 'PL');

    return new PriceBreakdown(subtotal, discount, tax, afterDiscount.add(tax));
  }
}

Bounded Contexts and Ubiquitous Language#

Bounded Contexts#

Bounded Context is a fundamental concept of strategic DDD. It defines boundaries within which a given domain model is consistent and has unambiguous meaning. Different contexts can use the same terms but with different meanings.

For example, in an e-commerce system:

  • Sales Context - Product is an object with price, description, and availability
  • Warehouse Context - Product is an object with warehouse location, weight, and dimensions
  • Shipping Context - Product is a shipment item with delivery address

Each context has its own model, its own repository, and its own business rules. Contexts communicate with each other through integration events, not through direct references.

┌─────────────────────┐     ┌─────────────────────┐
│   Sales Context     │     │  Warehouse Context   │
│                     │     │                      │
│  Order              │ ──► │  StockReservation    │
│  Product            │     │  Product             │
│  Customer           │     │  Location            │
│  Payment            │     │  InventoryItem       │
│                     │     │                      │
│  OrderConfirmedEvent│     │  StockReservedEvent  │
└─────────────────────┘     └──────────────────────┘
         │                            │
         ▼                            ▼
┌──────────────────────────────────────────────────┐
│              Message Broker (RabbitMQ / Kafka)   │
└──────────────────────────────────────────────────┘
         │                            │
         ▼                            ▼
┌─────────────────────┐     ┌─────────────────────┐
│  Shipping Context   │     │  Billing Context     │
│                     │     │                      │
│  Shipment           │     │  Invoice             │
│  DeliveryAddress    │     │  Payment             │
│  Carrier            │     │  TaxCalculation      │
└─────────────────────┘     └─────────────────────┘

Ubiquitous Language#

Ubiquitous Language is a shared language used by the development team and domain experts. The same vocabulary should be present in code, documentation, conversations, and tests. This eliminates misunderstandings and ensures that the code model faithfully reflects business reality.

Examples of applying Ubiquitous Language:

  • Zamiast setStatus("confirmed") piszemy order.Confirm()
  • Zamiast user.type = "premium" piszemy customer.UpgradeToLoyaltyTier(LoyaltyTier.Gold)
  • Zamiast item.count -= 1 piszemy inventory.ReserveStock(productId, quantity)

CQRS - Command Query Responsibility Segregation#

CQRS is a pattern based on separating read operations (Query) from write operations (Command). This allows read and write models to be optimized independently.

// .NET - CQRS with MediatR

// Command - write operation
public record CreateOrderCommand(
    Guid CustomerId,
    List<OrderLineDto> Lines) : IRequest<Guid>;

public class CreateOrderCommandHandler
    : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderCommandHandler(
        IOrderRepository orderRepository,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _unitOfWork = unitOfWork;
    }

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

        foreach (var line in command.Lines)
        {
            order.AddLine(
                new Product(line.ProductId, new Money(line.Price, "PLN")),
                line.Quantity);
        }

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

        return order.Id;
    }
}

// Query - read operation (from a separate model)
public record GetOrderSummaryQuery(Guid OrderId) : IRequest<OrderSummaryDto?>;

public class GetOrderSummaryQueryHandler
    : IRequestHandler<GetOrderSummaryQuery, OrderSummaryDto?>
{
    private readonly IReadDbContext _readDb;

    public GetOrderSummaryQueryHandler(IReadDbContext readDb)
    {
        _readDb = readDb;
    }

    public async Task<OrderSummaryDto?> Handle(
        GetOrderSummaryQuery query, CancellationToken ct)
    {
        return await _readDb.OrderSummaries
            .Where(o => o.Id == query.OrderId)
            .Select(o => new OrderSummaryDto
            {
                Id = o.Id,
                CustomerName = o.CustomerName,
                TotalAmount = o.TotalAmount,
                ItemCount = o.ItemCount,
                Status = o.Status,
                CreatedAt = o.CreatedAt
            })
            .FirstOrDefaultAsync(ct);
    }
}

Event Sourcing#

Event Sourcing is a pattern where the aggregate state is not stored directly but reconstructed from a sequence of events. Each state change is recorded as an immutable fact (event). This provides a complete change history, the ability to reconstruct state at any point in time, and natural integration with CQRS.

// TypeScript - Event Sourcing
export abstract class EventSourcedAggregate {
  private uncommittedEvents: DomainEvent[] = [];
  protected version = 0;

  protected apply(event: DomainEvent): void {
    this.when(event);
    this.version++;
    this.uncommittedEvents.push(event);
  }

  protected abstract when(event: DomainEvent): void;

  loadFromHistory(events: DomainEvent[]): void {
    for (const event of events) {
      this.when(event);
      this.version++;
    }
  }

  getUncommittedEvents(): DomainEvent[] {
    return [...this.uncommittedEvents];
  }

  clearUncommittedEvents(): void {
    this.uncommittedEvents = [];
  }
}

export class BankAccount extends EventSourcedAggregate {
  private _id!: string;
  private _balance!: Money;
  private _status!: AccountStatus;

  static open(id: string, owner: string, initialDeposit: Money): BankAccount {
    const account = new BankAccount();
    account.apply(new AccountOpenedEvent(id, owner, initialDeposit));
    return account;
  }

  deposit(amount: Money): void {
    if (this._status !== AccountStatus.Active)
      throw new DomainError('Account is not active');
    this.apply(new MoneyDepositedEvent(this._id, amount));
  }

  withdraw(amount: Money): void {
    if (this._status !== AccountStatus.Active)
      throw new DomainError('Account is not active');
    if (this._balance.isLessThan(amount))
      throw new DomainError('Insufficient funds');
    this.apply(new MoneyWithdrawnEvent(this._id, amount));
  }

  protected when(event: DomainEvent): void {
    if (event instanceof AccountOpenedEvent) {
      this._id = event.accountId;
      this._balance = event.initialDeposit;
      this._status = AccountStatus.Active;
    } else if (event instanceof MoneyDepositedEvent) {
      this._balance = this._balance.add(event.amount);
    } else if (event instanceof MoneyWithdrawnEvent) {
      this._balance = this._balance.subtract(event.amount);
    }
  }
}

// Event Store Repository
export class EventStoreRepository {
  constructor(private readonly eventStore: EventStoreClient) {}

  async save(aggregate: EventSourcedAggregate, streamId: string): Promise<void> {
    const events = aggregate.getUncommittedEvents();
    const eventData = events.map((e) => ({
      type: e.constructor.name,
      data: JSON.stringify(e),
      metadata: JSON.stringify({ timestamp: new Date().toISOString() }),
    }));

    await this.eventStore.appendToStream(streamId, eventData);
    aggregate.clearUncommittedEvents();
  }

  async load<T extends EventSourcedAggregate>(
    streamId: string,
    factory: () => T,
  ): Promise<T> {
    const events = await this.eventStore.readStream(streamId);
    const aggregate = factory();
    aggregate.loadFromHistory(events);
    return aggregate;
  }
}

Hexagonal Architecture vs Clean Architecture#

Hexagonal Architecture (Ports & Adapters), proposed by Alistair Cockburn, is closely related to Clean Architecture. Both are based on isolating the domain from implementation details but use slightly different terminology.

| Aspect | Clean Architecture | Hexagonal Architecture | |--------|-------------------|--------------------------| | Structure | Concentric layers | Hexagon with ports | | Central point | Entities | Domain Model | | Interfaces | Use Case boundaries | Ports (input/output) | | Implementations | Interface Adapters | Adapters (primary/secondary) | | Dependencies | Dependency Rule (inward) | Dependency Inversion | | Layers | 4 distinct layers | 3 areas (domain, ports, adapters) |

In practice, hexagonal architecture places greater emphasis on input (driving) and output (driven) ports, while Clean Architecture more precisely defines boundaries between layers. Both work excellently with DDD.

Architektura heksagonalna:

        ┌──────────────────────┐
        │   Primary Adapters   │
        │  (Controllers, CLI)  │
        │         │            │
        │    ┌────▼────┐       │
        │    │  Input   │       │
        │    │  Ports   │       │
        │    │    │     │       │
        │    │ ┌──▼───┐ │       │
        │    │ │Domain│ │       │
        │    │ │Model │ │       │
        │    │ └──┬───┘ │       │
        │    │    │     │       │
        │    │ Output  │       │
        │    │  Ports   │       │
        │    └────┬────┘       │
        │         │            │
        │  Secondary Adapters  │
        │  (DB, APIs, Queue)   │
        └──────────────────────┘

Project Structure in Practice#

A well-organized project combining Clean Architecture with DDD should have a clear directory structure reflecting layers and contexts:

src/
├── Domain/                          # Warstwa encji
│   ├── Orders/
│   │   ├── Order.cs                 # Aggregate Root
│   │   ├── OrderLine.cs             # Entity
│   │   ├── OrderStatus.cs           # Enum
│   │   ├── IOrderRepository.cs      # Port repozytorium
│   │   └── Events/
│   │       ├── OrderConfirmedEvent.cs
│   │       └── OrderCancelledEvent.cs
│   ├── Customers/
│   │   ├── Customer.cs
│   │   └── ICustomerRepository.cs
│   └── SharedKernel/
│       ├── Money.cs                 # Value Object
│       ├── AggregateRoot.cs         # Bazowa klasa
│       └── IDomainEvent.cs
├── Application/                     # Warstwa Use Cases
│   ├── Orders/
│   │   ├── Commands/
│   │   │   ├── CreateOrderCommand.cs
│   │   │   └── ConfirmOrderCommand.cs
│   │   ├── Queries/
│   │   │   ├── GetOrderQuery.cs
│   │   │   └── GetOrdersByCustomerQuery.cs
│   │   └── EventHandlers/
│   │       └── OrderConfirmedHandler.cs
│   └── Common/
│       ├── IUnitOfWork.cs
│       └── IEventPublisher.cs
├── Infrastructure/                  # Warstwa Frameworks & Drivers
│   ├── Persistence/
│   │   ├── AppDbContext.cs
│   │   ├── EfOrderRepository.cs
│   │   └── Configurations/
│   │       └── OrderConfiguration.cs
│   ├── Messaging/
│   │   └── RabbitMqEventPublisher.cs
│   └── ExternalServices/
│       └── StripePaymentGateway.cs
└── WebApi/                          # Warstwa Interface Adapters
    ├── Controllers/
    │   └── OrdersController.cs
    ├── Middleware/
    │   └── ExceptionHandlingMiddleware.cs
    └── Program.cs

Common Mistakes and How to Avoid Them#

  1. Anemic domain model - entities containing only getters and setters without business logic. Solution: move logic to entities, hide setters, enforce invariants.

  2. Aggregates too large - aggregates containing too many entities, causing performance and concurrency issues. Solution: design small aggregates, reference between them by identifiers.

  3. Domain leakage - business logic in controllers or infrastructure services. Solution: strictly apply the Dependency Rule, move logic to the domain layer.

  4. Pattern overuse - applying DDD and Clean Architecture to simple CRUD applications. Solution: assess domain complexity before choosing architecture.

  5. Ignoring Bounded Contexts - one large model for the entire system. Solution: identify context boundaries with domain experts, separate models.

Summary#

Clean Architecture and Domain-Driven Design are powerful tools in every software architect's arsenal. Their combination allows creating systems that are:

  • Testable - business logic is isolated from infrastructure
  • Flexible - easy to swap frameworks, databases, and external services
  • Understandable - code reflects business language through Ubiquitous Language
  • Scalable - Bounded Contexts and CQRS enable independent component scaling
  • Auditable - Event Sourcing provides a complete change history

The key to success is a pragmatic approach - apply these patterns where domain complexity justifies it, and do not be afraid of simpler solutions for less complex parts of the system.


Need Help with System Architecture?#

At MDS Software Solutions Group, we design and implement systems based on Clean Architecture and DDD. Our experience includes building scalable enterprise applications in .NET, TypeScript, and Java. Whether you are starting a new project or modernizing an existing system - we will help you choose the right architecture and implement it in practice.

Contact us and let's discuss your project.

Author
MDS Software Solutions Group

Team of programming experts specializing in modern web technologies.

Clean Architecture and DDD in Practice - A Complete Guide | MDS Software Solutions Group | MDS Software Solutions Group