Back to Abp

ABP DDD Patterns

.agents/skills/abp-ddd/SKILL.md

10.3.08.0 KB
Original Source

ABP DDD Patterns

Docs: https://abp.io/docs/latest/framework/architecture/domain-driven-design

Anti-Patterns to Avoid

  • Anemic entities: public setters with no behavior — use private setters + methods that enforce invariants
  • Repository for child entities: only aggregate roots get repositories — access child entities through their root
  • Generating GUID in entity constructor: use IGuidGenerator from outside and pass id parameter
  • Navigation properties to other aggregates: reference by Id only, never add full navigation properties across aggregates
  • Domain service depending on current user: accept values from the application layer instead

Rich Domain Model vs Anemic Domain Model

ABP promotes Rich Domain Model pattern where entities contain both data AND behavior:

Anemic (Anti-pattern)Rich (Recommended)
Entity = data onlyEntity = data + behavior
Logic in servicesLogic in entity methods
Public settersPrivate setters with methods
No validation in entityEntity enforces invariants

Encapsulation is key: Protect entity state by using private setters and exposing behavior through methods.

Entities

Entity Example (Rich Model)

csharp
public class OrderLine : Entity<Guid>
{
    public Guid ProductId { get; private set; }
    public int Count { get; private set; }
    public decimal Price { get; private set; }

    protected OrderLine() { } // For ORM

    internal OrderLine(Guid id, Guid productId, int count, decimal price) : base(id)
    {
        ProductId = productId;
        SetCount(count); // Validates through method
        Price = price;
    }

    public void SetCount(int count)
    {
        if (count <= 0)
            throw new BusinessException("Orders:InvalidCount");
        Count = count;
    }
}

Aggregate Roots

Aggregate roots are consistency boundaries that:

  • Own their child entities
  • Enforce business rules
  • Publish domain events
csharp
public class Order : AggregateRoot<Guid>
{
    public string OrderNumber { get; private set; }
    public Guid CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public ICollection<OrderLine> Lines { get; private set; }

    protected Order() { } // For ORM

    public Order(Guid id, string orderNumber, Guid customerId) : base(id)
    {
        OrderNumber = Check.NotNullOrWhiteSpace(orderNumber, nameof(orderNumber));
        CustomerId = customerId;
        Status = OrderStatus.Created;
        Lines = new List<OrderLine>();
    }

    public void AddLine(Guid lineId, Guid productId, int count, decimal price)
    {
        // Business rule: Can only add lines to created orders
        if (Status != OrderStatus.Created)
            throw new BusinessException("Orders:CannotModifyOrder");

        Lines.Add(new OrderLine(lineId, productId, count, price));
    }

    public void Complete()
    {
        if (Status != OrderStatus.Created)
            throw new BusinessException("Orders:CannotCompleteOrder");

        Status = OrderStatus.Completed;

        // Publish events for side effects
        AddLocalEvent(new OrderCompletedEvent(Id));           // Same transaction
        AddDistributedEvent(new OrderCompletedEto { OrderId = Id }); // Cross-service
    }
}

Domain Events

  • AddLocalEvent() - Handled within same transaction, can access full entity
  • AddDistributedEvent() - Handled asynchronously, use ETOs (Event Transfer Objects)

Entity Best Practices

  • Encapsulation: Private setters, public methods that enforce rules
  • Primary constructor: Enforce invariants, accept id parameter
  • Protected parameterless constructor: Required for ORM
  • Initialize collections: In primary constructor
  • Virtual members: For ORM proxy compatibility
  • Reference by Id: Don't add navigation properties to other aggregates
  • Don't generate GUID in constructor: Use IGuidGenerator externally

Repository Pattern

When to Use Custom Repository

  • Generic repository (IRepository<T, TKey>): Sufficient for simple CRUD operations
  • Custom repository: Only when you need custom query methods

Interface (Domain Layer)

csharp
// Define custom interface only when custom queries are needed
public interface IOrderRepository : IRepository<Order, Guid>
{
    Task<Order> FindByOrderNumberAsync(string orderNumber, bool includeDetails = false);
    Task<List<Order>> GetListByCustomerAsync(Guid customerId, bool includeDetails = false);
}

Repository Best Practices

  • One repository per aggregate root only - Never create repositories for child entities
  • Child entities must be accessed/modified only through their aggregate root
  • Creating repositories for child entities breaks data consistency (bypasses aggregate root's business rules)
  • In ABP, use AddDefaultRepositories() without includeAllEntities: true to enforce this
  • Define custom repository only when custom queries are needed
  • ABP handles CancellationToken automatically; add parameter only for explicit cancellation control
  • Single entity methods: includeDetails = true by default
  • List methods: includeDetails = false by default
  • Don't return projection classes
  • Interface in Domain, implementation in data layer
csharp
// ✅ Correct: Repository for aggregate root (Order)
public interface IOrderRepository : IRepository<Order, Guid> { }

// ❌ Wrong: Repository for child entity (OrderLine)
// OrderLine should only be accessed through Order aggregate
public interface IOrderLineRepository : IRepository<OrderLine, Guid> { } // Don't do this!

Domain Services

Use domain services for business logic that:

  • Spans multiple aggregates
  • Requires repository queries to enforce rules
csharp
public class OrderManager : DomainService
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;

    public OrderManager(
        IOrderRepository orderRepository,
        IProductRepository productRepository)
    {
        _orderRepository = orderRepository;
        _productRepository = productRepository;
    }

    public async Task<Order> CreateAsync(string orderNumber, Guid customerId)
    {
        // Business rule: Order number must be unique
        var existing = await _orderRepository.FindByOrderNumberAsync(orderNumber);
        if (existing != null)
        {
            throw new BusinessException("Orders:OrderNumberAlreadyExists")
                .WithData("OrderNumber", orderNumber);
        }

        return new Order(GuidGenerator.Create(), orderNumber, customerId);
    }

    public async Task AddProductAsync(Order order, Guid productId, int count)
    {
        var product = await _productRepository.GetAsync(productId);
        order.AddLine(productId, count, product.Price);
    }
}

Domain Service Best Practices

  • Use *Manager suffix naming
  • No interface by default (create only if needed)
  • Accept/return domain objects, not DTOs
  • Don't depend on authenticated user - pass values from application layer
  • Use base class properties (GuidGenerator, Clock) instead of injecting these services

Domain Events

Local Events

csharp
// In aggregate
AddLocalEvent(new OrderCompletedEvent(Id));

// Handler
public class OrderCompletedEventHandler : ILocalEventHandler<OrderCompletedEvent>, ITransientDependency
{
    public async Task HandleEventAsync(OrderCompletedEvent eventData)
    {
        // Handle within same transaction
    }
}

Distributed Events (ETO)

For inter-module/microservice communication:

csharp
// In Domain.Shared
[EventName("Orders.OrderCompleted")]
public class OrderCompletedEto
{
    public Guid OrderId { get; set; }
    public string OrderNumber { get; set; }
}

Specifications

Reusable query conditions:

csharp
public class CompletedOrdersSpec : Specification<Order>
{
    public override Expression<Func<Order, bool>> ToExpression()
    {
        return o => o.Status == OrderStatus.Completed;
    }
}

// Usage
var orders = await _orderRepository.GetListAsync(new CompletedOrdersSpec());