Tactical Patterns

5 min read
Senior7 min read
Rapid overview

Tactical Patterns

TL;DR

  • Value Object: defined by values, immutable, value-equality. Money, EmailAddress, DateRange.
  • Entity: defined by identity, mutable over time. Customer, Order.
  • Aggregate Root: the one entity outside code is allowed to touch. Owns invariants.
  • Repository: collection-like abstraction for loading/saving aggregate roots.
  • Domain Service: business logic spanning aggregates.
  • Domain Event: a past-tense record of something that happened.

Why it matters

  • You can defend "entity vs value object" with a worked example.
  • You can explain why aggregates reference each other by id, not direct reference.
  • You can spot an anaemic domain model in a code review.

How it works

Value Object

  • Defined by its values, not an identity.
  • Immutable — operations return new instances.
  • Equality = all fields equal.
  • Use for Money, EmailAddress, DateRange, Coordinates.
public sealed record Money(decimal Amount, string Currency)
{
    public Money
    {
        if (Amount < 0) throw new ArgumentException("Money cannot be negative");
        if (string.IsNullOrWhiteSpace(Currency)) throw new ArgumentException("Currency required");
    }

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException($"Currency mismatch: {Currency} vs {other.Currency}");
        return this with { Amount = Amount + other.Amount };
    }
}

C# record gives you value-equality for free. Two new Money(10, "EUR") instances are interchangeable.

Entity

  • Defined by identity (an Id), not values.
  • Mutable over time — a Customer with a new email is still the same customer.
  • Equality = same id, regardless of other fields.
public class Customer
{
    public CustomerId Id { get; }
    public EmailAddress Email { get; private set; }

    public Customer(CustomerId id, EmailAddress email)
    {
        Id = id;
        Email = email;
    }

    public void ChangeEmail(EmailAddress next)
    {
        Email = next;
    }

    public override bool Equals(object? obj) =>
        obj is Customer other && Id == other.Id;

    public override int GetHashCode() => Id.GetHashCode();
}

Aggregate & Aggregate Root

An aggregate is a cluster of entities and value objects treated as one unit for consistency. The aggregate root is the single entry point — outside code can only touch the root, never inner pieces directly.

Rules of thumb:

  • One transaction = one aggregate.
  • Reference other aggregates by id, never by direct object reference.
  • Keep aggregates small.
public class Order  // ← aggregate root
{
    private readonly List<OrderLine> _lines = new();
    private bool _submitted;

    public OrderId Id { get; }
    public CustomerId CustomerId { get; }  // ← reference by id, not Customer object

    public Order(OrderId id, CustomerId customerId)
    {
        Id = id;
        CustomerId = customerId;
    }

    public void AddLine(ProductId productId, int quantity, Money unitPrice)
    {
        if (_submitted) throw new InvalidOperationException("Cannot modify a submitted order");
        if (quantity <= 0) throw new ArgumentException("Quantity must be positive");
        _lines.Add(new OrderLine(productId, quantity, unitPrice));
    }

    public Money Total() =>
        _lines.Aggregate(new Money(0, "EUR"), (sum, line) => sum.Add(line.Subtotal()));

    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
}

public record OrderLine(ProductId ProductId, int Quantity, Money UnitPrice)
{
    public Money Subtotal() => new(Quantity * UnitPrice.Amount, UnitPrice.Currency);
}

OrderLine is not reachable from outside as mutable — invariants live on the root.

Repository

A collection-like abstraction for loading/saving aggregate roots. One repository per aggregate type.

public interface IOrderRepository
{
    Task<Order?> FindByIdAsync(OrderId id, CancellationToken ct);
    Task SaveAsync(Order order, CancellationToken ct);
}

The interface lives in the domain layer. The implementation (EF Core, Dapper, REST) lives in infrastructure.

Domain Service

For business logic that doesn't naturally belong to a single entity or VO — typically operations spanning aggregates.

public class FundsTransfer
{
    public void Execute(Account from, Account to, Money amount)
    {
        from.Withdraw(amount);  // throws if insufficient funds
        to.Deposit(amount);
    }
}

Different from an application service — application services orchestrate (load, call, save, publish); domain services hold business rules.

Domain Event

A record that something happened in the domain — past tense (OrderPlaced, EmailVerified).

public record OrderSubmitted(
    OrderId OrderId,
    CustomerId CustomerId,
    Money Total,
    DateTime OccurredAt);

public class Order
{
    private readonly List<object> _events = new();

    public void Submit()
    {
        if (_submitted) return;
        _submitted = true;
        _events.Add(new OrderSubmitted(Id, CustomerId, Total(), DateTime.UtcNow));
    }

    public IReadOnlyList<object> PullEvents()
    {
        var drained = _events.ToList();
        _events.Clear();
        return drained;
    }
}

Pairs naturally with MediatR for in-process dispatch or RabbitMQ for cross-service integration events.

Factory

For creating valid aggregates when construction is non-trivial (computed fields, generated ids).

public static class OrderFactory
{
    public static Order Draft(CustomerId customerId) =>
        new(new OrderId(Guid.NewGuid()), customerId);
}

Use only when new Order(...) directly would let you build something invalid.


Detailed Explanation

Quick decision table

QuestionAnswer
Same identity over time?Entity
Replaceable, defined by values?Value Object
Cluster with invariants across pieces?Aggregate (pick a root)
Persisting a root?Repository
Logic spanning aggregates?Domain Service
Something happened?Domain Event

Why reference aggregates by id

  1. Transaction boundary: one aggregate per transaction. Direct references conflate units.
  2. Independent lifecycle: customer details change without touching every order.
  3. Loading cost: don't drag the whole customer graph in when loading an order.
  4. Concurrency: parallel operations on two aggregates don't lock each other.

Spotting an anaemic domain model

// Anaemic — public setters everywhere, all logic in services
public class Order
{
    public Guid Id { get; set; }
    public string Status { get; set; }
    public List<OrderLine> Lines { get; set; }
    public decimal Total { get; set; }
}

public class OrderService
{
    public void Submit(Order order)
    {
        if (order.Lines.Count == 0) throw new();
        if (order.Total > 10000) throw new();
        order.Status = "submitted";
    }
}

Fix: move behaviour onto the entity. Invariants live with the data they protect.


Common pitfalls

  • Anaemic models (public setters, services-do-everything).
  • Aggregates that span the world — re-split into smaller roots.
  • Holding references to other aggregates instead of ids.
  • Persisting via EF directly from entities without a repository — couples domain to ORM.
  • Domain events with present-tense names (PlaceOrder instead of OrderPlaced).

Interview prompts

  • "Entity vs value object — give two examples from a banking app."
  • "What's an aggregate root and why does it own its children?"
  • "How do you keep aggregates small?"
  • "Domain event vs integration event?"
  • "What's an anaemic domain model and why is it an anti-pattern?"

See also