EngineeringSenior DeveloperTechnical Sessions

Session 12: Clean Architecture & DDD in .NET

Mục tiêu: Thiết kế hệ thống với Clean Architecture và Domain-Driven Design, áp dụng tactical patterns trong .NET.


1. Clean Architecture Layers

┌─────────────────────────────────────────────────────────────┐
│                    Presentation Layer                        │
│              (Controllers, Minimal APIs, gRPC)               │
│          Depends on → Application Layer                      │
├─────────────────────────────────────────────────────────────┤
│                   Infrastructure Layer                       │
│          (EF Core, External APIs, Email, Storage)            │
│          Implements → Application Interfaces                 │
├─────────────────────────────────────────────────────────────┤
│                    Application Layer                         │
│          (Use Cases, Commands, Queries, DTOs)                │
│          Depends on → Domain Layer                           │
├─────────────────────────────────────────────────────────────┤
│                      Domain Layer                            │
│          (Entities, Value Objects, Domain Events)             │
│          KHÔNG depend vào bất kỳ layer nào!                  │
└─────────────────────────────────────────────────────────────┘

Dependency Rule: ngoài → trong (KHÔNG BAO GIỜ ngược lại)

Project Structure

src/
├── MyApp.Domain/                    ← Trung tâm, 0 dependencies
│   ├── Entities/
│   │   ├── Order.cs
│   │   └── Product.cs
│   ├── ValueObjects/
│   │   ├── Money.cs
│   │   └── Address.cs
│   ├── Events/
│   │   └── OrderCreatedEvent.cs
│   ├── Enums/
│   │   └── OrderStatus.cs
│   ├── Exceptions/
│   │   └── DomainException.cs
│   └── Interfaces/
│       └── IOrderRepository.cs      ← Interface ONLY
│
├── MyApp.Application/               ← Use cases, orchestration
│   ├── Orders/
│   │   ├── Commands/
│   │   │   ├── CreateOrder/
│   │   │   │   ├── CreateOrderCommand.cs
│   │   │   │   ├── CreateOrderCommandHandler.cs
│   │   │   │   └── CreateOrderCommandValidator.cs
│   │   │   └── CancelOrder/
│   │   └── Queries/
│   │       └── GetOrderById/
│   ├── Common/
│   │   ├── Interfaces/
│   │   │   ├── IUnitOfWork.cs
│   │   │   └── ICurrentUserService.cs
│   │   └── Behaviors/
│   │       ├── ValidationBehavior.cs
│   │       └── LoggingBehavior.cs
│   └── DependencyInjection.cs
│
├── MyApp.Infrastructure/            ← Implementations
│   ├── Persistence/
│   │   ├── AppDbContext.cs
│   │   ├── Configurations/
│   │   └── Repositories/
│   │       └── OrderRepository.cs
│   ├── Services/
│   │   ├── EmailService.cs
│   │   └── PaymentService.cs
│   └── DependencyInjection.cs
│
└── MyApp.Api/                       ← Presentation
    ├── Controllers/
    ├── Middleware/
    └── Program.cs

2. Domain Layer - Rich Domain Model

Entity Base Class

csharp
public abstract class Entity
{
    public Guid Id { get; protected set; }

    private readonly List<IDomainEvent> _domainEvents = [];
    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    protected void RaiseDomainEvent(IDomainEvent domainEvent)
        => _domainEvents.Add(domainEvent);

    public void ClearDomainEvents() => _domainEvents.Clear();
}

public abstract class AggregateRoot : Entity
{
    // Aggregate Root = entry point cho một nhóm entities
    // Bên ngoài CHỈ truy cập qua Aggregate Root
}

Rich Domain Model - Order Example

csharp
public class Order : AggregateRoot
{
    private readonly List<OrderItem> _items = [];

    public Guid CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public Money Total { get; private set; } = null!;
    public Address ShippingAddress { get; private set; } = null!;
    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
    public DateTime CreatedAt { get; private set; }

    private Order() { } // EF Core

    // Factory method - encapsulate creation logic
    public static Order Create(Guid customerId, Address shippingAddress)
    {
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = customerId,
            ShippingAddress = shippingAddress,
            Status = OrderStatus.Draft,
            Total = Money.Zero("VND"),
            CreatedAt = DateTime.UtcNow
        };

        order.RaiseDomainEvent(new OrderCreatedEvent(order.Id, customerId));
        return order;
    }

    // Business logic TRONG entity, không phải service
    public void AddItem(Product product, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Can only add items to draft orders");

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

        var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
        if (existingItem is not null)
        {
            existingItem.UpdateQuantity(existingItem.Quantity + quantity);
        }
        else
        {
            _items.Add(OrderItem.Create(product.Id, product.Name, product.Price, quantity));
        }

        RecalculateTotal();
    }

    public void Submit()
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Only draft orders can be submitted");

        if (_items.Count == 0)
            throw new DomainException("Order must have at least one item");

        Status = OrderStatus.Submitted;
        RaiseDomainEvent(new OrderSubmittedEvent(Id, Total));
    }

    public void Cancel(string reason)
    {
        if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
            throw new DomainException("Cannot cancel shipped/delivered orders");

        Status = OrderStatus.Cancelled;
        RaiseDomainEvent(new OrderCancelledEvent(Id, reason));
    }

    private void RecalculateTotal()
    {
        Total = _items.Aggregate(
            Money.Zero(Total.Currency),
            (sum, item) => sum + item.LineTotal);
    }
}

Value Objects

csharp
public record Money
{
    public decimal Amount { get; init; }
    public string Currency { get; init; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0) throw new DomainException("Amount cannot be negative");
        if (string.IsNullOrWhiteSpace(currency)) throw new DomainException("Currency required");
        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }

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

    public static Money operator +(Money a, Money b)
    {
        if (a.Currency != b.Currency)
            throw new DomainException($"Cannot add {a.Currency} and {b.Currency}");
        return new Money(a.Amount + b.Amount, a.Currency);
    }

    public static Money operator *(Money money, int multiplier)
        => new(money.Amount * multiplier, money.Currency);
}

Domain Events

csharp
public interface IDomainEvent
{
    DateTime OccurredOn { get; }
}

public record OrderCreatedEvent(Guid OrderId, Guid CustomerId) : IDomainEvent
{
    public DateTime OccurredOn { get; } = DateTime.UtcNow;
}

// Dispatch domain events trong SaveChanges
public class AppDbContext : DbContext
{
    private readonly IMediator _mediator;

    public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        // Collect domain events trước khi save
        var events = ChangeTracker.Entries<Entity>()
            .SelectMany(e => e.Entity.DomainEvents)
            .ToList();

        // Save to database first
        var result = await base.SaveChangesAsync(ct);

        // Dispatch events sau khi save thành công
        foreach (var domainEvent in events)
        {
            await _mediator.Publish(domainEvent, ct);
        }

        // Clear events
        ChangeTracker.Entries<Entity>()
            .ToList()
            .ForEach(e => e.Entity.ClearDomainEvents());

        return result;
    }
}

3. Rich Domain Model vs Anemic Domain Model

csharp
// ❌ Anemic Domain Model - entity chỉ có properties
public class Order
{
    public Guid Id { get; set; }
    public OrderStatus Status { get; set; }  // Public setter = ai cũng sửa được
    public decimal Total { get; set; }
}

// Business logic nằm trong Service (procedural style)
public class OrderService
{
    public void SubmitOrder(Order order)
    {
        if (order.Status != OrderStatus.Draft) throw new Exception("..");
        order.Status = OrderStatus.Submitted;  // Violate encapsulation!
    }
}

// ✅ Rich Domain Model - entity chứa behavior
public class Order
{
    public OrderStatus Status { get; private set; } // Private setter!

    public void Submit()
    {
        // Business rule gắn liền với entity
        if (Status != OrderStatus.Draft)
            throw new DomainException("Only draft orders can be submitted");
        Status = OrderStatus.Submitted;
    }
}

// Service chỉ orchestrate, KHÔNG chứa business logic
public class SubmitOrderHandler
{
    public async Task Handle(SubmitOrderCommand cmd)
    {
        var order = await _repo.GetByIdAsync(cmd.OrderId);
        order.Submit(); // Entity tự validate
        await _repo.SaveAsync();
    }
}

4. Bounded Contexts & Context Mapping

┌─────────────────────────────────────────────────────────────────┐
│                        eCommerce System                          │
│                                                                  │
│  ┌──────────────┐   ┌──────────────┐   ┌──────────────────┐    │
│  │   Ordering    │   │   Catalog    │   │    Shipping      │    │
│  │   Context     │   │   Context    │   │    Context       │    │
│  │              │   │              │   │                  │    │
│  │  Order       │   │  Product     │   │  Shipment       │    │
│  │  OrderItem   │   │  Category    │   │  Route          │    │
│  │  Customer ←──┼───┼→ ProductRef  │   │  Tracking       │    │
│  │  (ID only!)  │   │  (full data) │   │  OrderRef       │    │
│  └──────────────┘   └──────────────┘   └──────────────────┘    │
│         │                    │                    │               │
│         └────── Integration Events ──────────────┘               │
└─────────────────────────────────────────────────────────────────┘

Key: Mỗi context có model RIÊNG cho cùng 1 concept
  - Ordering: Customer = CustomerId + Name (enough to place order)
  - Shipping: Customer = Address + Phone (enough to ship)
  - Catalog: Customer = KHÔNG CÓ (không liên quan)

Tổng Kết Session 12

ConceptKey Takeaway
Clean ArchitectureDependencies hướng vào trong, Domain không depend gì
Rich Domain ModelBusiness logic trong Entity, không trong Service
Value ObjectsImmutable, equality by value (dùng record)
Aggregate RootEntry point, đảm bảo consistency
Domain EventsDecouple side effects, dispatch sau SaveChanges
Bounded ContextMỗi context có model riêng, communicate qua events

Bài tập: Refactor 1 Anemic Domain Model (entity chỉ có properties) thành Rich Domain Model: encapsulate business rules, private setters, factory methods, domain events.