14 questions on domain-driven design: bounded contexts, aggregates, entities and value objects, domain events, repositories, and the anti-corruption layer, with the boundary traps.
01DDDFoundation
What DDD is for
Answer
Domain-Driven Design (Evans) centers the model on business rules and language, not on tables or screens.
Use DDD when the domain is complex and shared language with domain experts matters for correct software.
Skip heavy DDD ceremony for simple CRUD apps where the cost of aggregates and contexts exceeds the risk.
Strategic design (bounded contexts, context maps) and tactical design (entities, aggregates, domain events) solve different problems.
Example: a billing product with many pricing rules benefits from DDD; a contact form store may not.
Microsoft DDD guidance for .NET stresses clear layers, ubiquitous language, and aggregate boundaries over dogma.
Pitfall: adopting every tactical pattern as a checklist without strategic boundaries still yields a messy model.
Start with language and contexts, then apply aggregates where consistency rules demand them.
Flow diagram: Domain at the center
C#
// Domain expresses a business action, not a table updatepublicsealedclassOrder
{
publicvoidCancel(string reason)
{
if (Status == "Shipped")
thrownewInvalidOperationException("Cannot cancel a shipped order.");
Status = "Cancelled";
Reason = reason;
}
publicstringStatus { get; privateset; } = "Placed";
publicstring? Reason { get; privateset; }
}
How layering is done
Common layout: Presentation (API/UI), Application (use cases), Domain (model), and Infrastructure (EF, message bus, email).
Domain sits in the center and does not reference Infrastructure or ASP.NET.
Application orchestrates: load an aggregate, call domain behavior, save, publish.
Infrastructure implements repository and gateway interfaces defined toward the domain or application.
Keep controllers thin; put business rules in entities, value objects, domain services, and aggregates.
02DDDFoundation
Ubiquitous language
Answer
Ubiquitous language is one shared vocabulary between code and domain experts inside a bounded context.
Name classes, methods, and events the way the business speaks, not the way the database tables are named.
If the UI says cancel enrolment and the code says DeleteRow, the model is drifting from the domain.
Refactor names when the language changes. Do not invent parallel jargon only developers understand.
Example: PlaceOrder and OrderPlaced match how sales talks. InsertOrderHeader does not.
Pitfall: one enterprise dictionary forced across contexts. The same word can differ by context; that is expected in DDD.
Document the language in conversation with experts, then encode it in types. Code becomes the living glossary.
Evans treats language as a design tool: if you cannot name it cleanly, the model is incomplete.
Flow diagram: Same words in talk and code
C#
// Prefer business words over CRUD namespublicsealedclassEnrolment
{
publicvoidCancel(DateTimeOffset at) { /* ... */ }
}
// Avoid: DeleteEnrolmentRow / UpdateFlag
03DDDFoundation
Entity vs value object
Answer
An entity has identity that stays stable as its attributes change. Order Id 42 is the same order after status updates.
A value object is defined by its attributes. Replace it instead of mutating identity. Money, Email, and DateRange are common cases.
Value objects should be immutable in practice. Change means creating a new instance with new values.
Equality for value objects compares values. Equality for entities compares identity.
Example: two Money(10, "USD") instances are equal. Two Customer entities with the same name but different ids are not.
Pitfall: modeling Email as an entity with its own table and id when equality by address is enough.
Evans and Microsoft DDD samples use value objects to push validation and behavior next to the data.
Put invariant checks in value object factories or constructors so invalid values never exist.
An Aggregate is a cluster of domain entities and value objects treated as a single consistency boundary for state modifications. All business invariants inside the aggregate must remain valid at the conclusion of every transaction.
The Aggregate Root is the primary entity that serves as the exclusive gateway for external modifications. External code is forbidden from mutating internal entities directly; all changes must route through methods on the root.
Boundary Sizing: Keep aggregates as small as possible while preserving transactional invariants. Large aggregates cause database concurrency collisions, slow query loads, and lock contention.
Referencing Other Aggregates: Aggregates must reference other aggregates strictly by their unique identity (for example, CustomerId or ProductId), never by holding direct object references or navigation properties in memory. This enforces clear module decoupling and allows independent scaling.
Flow diagram: Aggregate consistency boundary and root encapsulation
C# (Encapsulated Aggregate Root)
publicclassOrder
{
privatereadonlyList<OrderLine> _lines = new();
publicGuidId { get; privateset; }
publicGuidCustomerId { get; privateset; } // Reference by ID onlypublicIReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
publicOrderStatusStatus { get; privateset; }
public byte[] RowVersion { get; privateset; } = Array.Empty<byte>();
privateOrder() { } // Required for EF Core hydrationpublicOrder(Guid customerId)
{
Id = Guid.NewGuid();
CustomerId = customerId;
Status = OrderStatus.Draft;
}
// Invariant: Cannot add lines to a submitted or canceled orderpublicvoidAddLine(Guid productId, int quantity, decimal unitPrice)
{
if (Status != OrderStatus.Draft)
thrownewInvalidOperationException("Cannot modify order after submission.");
if (quantity <= 0)
thrownewArgumentException("Quantity must be greater than zero.", nameof(quantity));
_lines.Add(newOrderLine(productId, quantity, unitPrice));
}
}
Enforcing Invariants and Concurrency Tokens
Invariants are business rules that must always hold true (such as: an order cannot have items if total price is negative; a shipment cannot be marked delivered before dispatched).
Optimistic Concurrency: Because transactions should not span multiple aggregates, each aggregate root carries a Version or RowVersion concurrency token to detect conflicting updates during database commits.
05DDDFoundation
Value object vs aggregate
Answer
A value object is defined by its attributes (Money, Email). No tracking identity; replace it instead of mutating identity.
An aggregate is a cluster of entities and value objects treated as one consistency boundary for writes.
The aggregate root is the entry point. Outside code does not reach inside past the root.
Value objects live inside aggregates (and sometimes as standalone types). Aggregates are not value objects.
Handwritten notes sometimes say Aggregator; in DDD the term is aggregate.
Example: Address as a value object on Customer aggregate. Customer has identity; Address equality is by street fields.
Pitfall: calling every entity group an aggregate without a clear invariant to protect.
Ask: does this cluster need one transaction and one root API? If no, it may be separate aggregates or a value object.
Flow diagram: Value object inside an aggregate boundary
A Bounded Context defines the boundary within which a particular domain model and Ubiquitous Language applies without ambiguity. The same real-world entity carries different attributes, behaviors, and definitions in different contexts.
Example: To the Sales Context, a Product is an item with pricing, discounts, and customer-facing descriptions. To the Inventory Context, a Product is a SKU with physical dimensions, bin locations, and weight. Forcing a single unified Product class across the entire enterprise creates an unwieldy model full of nullable columns and conflicting logic.
Context Mapping: Describes the relationship between bounded contexts: Shared Kernel, Customer/Supplier, Conformist, Open Host Service, and Anti-Corruption Layer (ACL).
Flow diagram: Distinct models across Bounded Context boundaries
C# (Anti-Corruption Layer Translator)
publicclassLegacyInventoryAdapter : IInventoryStockService
{
privatereadonlyLegacySapSoapClient _soapClient;
publicLegacyInventoryAdapter(LegacySapSoapClient soapClient) => _soapClient = soapClient;
publicasyncTask<StockAvailability> CheckStockAsync(Sku sku, CancellationToken ct)
{
// Translate clean domain value object into legacy SAP request formatvar legacyResponse = await _soapClient.GetItemStockAsync(sku.Value, ct);
// Translate raw SAP response into native domain modelreturnnewStockAvailability(sku, legacyResponse.QTY_AVAIL > 0, legacyResponse.QTY_AVAIL);
}
}
Anti-Corruption Layer (ACL)
An ACL is a translation adapter between two bounded contexts. It prevents an external legacy system or third-party schema from polluting the clean domain model of a downstream context.
Components: An ACL typically consists of facades, service adapters, and data translators that convert external DTOs into native domain entities and value objects.
13DDDAdvanced
Advanced: aggregate boundary traps
Answer
An aggregate is a consistency boundary. Change it in one transaction through the root, not by editing child entities from outside.
Oversized aggregates load slowly and contend under concurrent writes. Undersized ones force multi-aggregate updates for one business rule.
Reference other aggregates by id. Do not pull large foreign graphs into memory as if they were one unit.
If two roots must stay strongly consistent together, redraw the boundary or accept an eventual flow with events and compensation.
Example: Order and Inventory as separate aggregates. Reserve stock via a command or event flow, not by nesting Inventory inside Order.
Pitfall: foreign keys everywhere treated as one aggregate. Relational joins are not aggregate design.
Tune boundaries around invariants that must hold now, not around every "nice to join" relationship.
Interview tip: explain the trade between strong consistency inside the root and eventual consistency across roots.
Flow diagram: Root guards the boundary
C#
publicsealedclassOrder
{
privatereadonlyList<OrderLine> _lines = new();
publicIReadOnlyList<OrderLine> Lines => _lines;
publicvoidAddLine(Guid productId, int qty, decimal price)
{
if (qty <= 0) thrownewInvalidOperationException("Qty must be positive.");
_lines.Add(newOrderLine(productId, qty, price));
}
}
// Other aggregates keep OrderId, not OrderLine collections.
14DDDAdvanced
Advanced: domain event ordering
Answer
Raise domain events from the aggregate. Persist the aggregate and outbox (or dispatch) in one commit when delivery must match the write.
In-process handlers before SaveChanges see uncommitted state. Handlers after a separate publish can run when the row never landed (dual write).
Handler order is undefined unless you set it. Keep handlers independent or sequence them on purpose.
Make handlers idempotent when the same event can arrive more than once.
Example: write Order and OutboxMessage in one transaction; a background publisher sends OrderPlaced after commit.
Pitfall: fire-and-forget publish before SaveChanges, then crash. Downstream systems saw an event for data that never existed.
Microsoft and community DDD guidance favor transactional outbox for reliable integration events.
Document whether handlers are sync in-process or async after commit so teams do not assume ordering they do not have.