Tactical Patterns
5 min readTactical 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
Customerwith 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
| Question | Answer |
|---|---|
| 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
- Transaction boundary: one aggregate per transaction. Direct references conflate units.
- Independent lifecycle: customer details change without touching every order.
- Loading cost: don't drag the whole customer graph in when loading an order.
- 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 (
PlaceOrderinstead ofOrderPlaced).
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?"