Domain-driven design interview questions

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 update public sealed class Order { public void Cancel(string reason) { if (Status == "Shipped") throw new InvalidOperationException("Cannot cancel a shipped order."); Status = "Cancelled"; Reason = reason; } public string Status { get; private set; } = "Placed"; public string? Reason { get; private set; } }

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 names public sealed class Enrolment { public void Cancel(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.
Flow diagram: Entity vs value object
C#
public sealed class Money : IEquatable<Money> { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount, string currency) { if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount)); Amount = amount; Currency = currency; } public bool Equals(Money? other) => other is not null && Amount == other.Amount && Currency == other.Currency; public override int GetHashCode() => HashCode.Combine(Amount, Currency); }
04DDDFoundation

Aggregates and Aggregate Roots in DDD

Answer

  • 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)
public class Order { private readonly List<OrderLine> _lines = new(); public Guid Id { get; private set; } public Guid CustomerId { get; private set; } // Reference by ID only public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly(); public OrderStatus Status { get; private set; } public byte[] RowVersion { get; private set; } = Array.Empty<byte>(); private Order() { } // Required for EF Core hydration public Order(Guid customerId) { Id = Guid.NewGuid(); CustomerId = customerId; Status = OrderStatus.Draft; } // Invariant: Cannot add lines to a submitted or canceled order public void AddLine(Guid productId, int quantity, decimal unitPrice) { if (Status != OrderStatus.Draft) throw new InvalidOperationException("Cannot modify order after submission."); if (quantity <= 0) throw new ArgumentException("Quantity must be greater than zero.", nameof(quantity)); _lines.Add(new OrderLine(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
C#
public sealed class Money : IEquatable<Money> { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount, string currency) { if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount)); Amount = amount; Currency = currency; } public bool Equals(Money? other) => other is not null && Amount == other.Amount && Currency == other.Currency; public override int GetHashCode() => HashCode.Combine(Amount, Currency); } public sealed class Order // aggregate root { private readonly List<OrderLine> _lines = new(); public Guid Id { get; private set; } = Guid.NewGuid(); public void AddLine(Guid productId, int qty, Money price) { if (qty <= 0) throw new InvalidOperationException("Qty must be positive."); _lines.Add(new OrderLine(productId, qty, price)); } } public sealed class OrderLine { public OrderLine(Guid productId, int qty, Money price) { ProductId = productId; Quantity = qty; Price = price; } public Guid ProductId { get; } public int Quantity { get; } public Money Price { get; } }
06DDDFoundation

Domain events

Answer

  • A domain event records something that happened in the domain, named in past tense (OrderPlaced).
  • Raise events from the aggregate after a successful state change that the model accepts.
  • Handlers in the same process may update other models. Across services, publish via messaging or an outbox.
  • Events are past tense facts, not commands. They describe outcomes, not requests.
  • Example: after Order.Place(), raise OrderPlaced with order id and totals for inventory and email handlers.
  • Pitfall: raising events before invariants pass, then rolling back the write while handlers already ran.
  • Evans and Microsoft samples collect events on the aggregate and dispatch after persistence succeeds.
  • Keep payloads focused. Subscribers should not need the whole aggregate graph.
Sequence diagram: Domain event
C#
public record OrderPlaced(Guid OrderId, DateTimeOffset At); public class Order { private readonly List<object> _events = new(); public IReadOnlyList<object> DomainEvents => _events; public static Order Place(Guid id) { var order = new Order { Id = id }; order._events.Add(new OrderPlaced(id, DateTimeOffset.UtcNow)); return order; } public Guid Id { get; private set; } }
07DDDFoundation

Event vs command in DDD

Answer

  • A command is an intent to change something: PlaceOrder, CancelShipment. Named as an imperative.
  • An event is a fact that already happened: OrderPlaced, ShipmentCancelled. Named in past tense.
  • Commands usually target one handler or aggregate. Events may notify many subscribers.
  • Commands can be rejected by validation. Events describe accepted outcomes after a successful change.
  • In CQRS, the write side handles commands; read models and other contexts react to events.
  • Example: PlaceOrder fails if stock is missing. OrderPlaced never fires for that attempt.
  • Pitfall: treating an event as a remote procedure call that must succeed. Events are notifications of truth already committed.
  • Name carefully. PlaceOrderCommand vs OrderPlacedEvent makes intent obvious in .NET codebases.
Flow diagram: Command vs event
C#
public record PlaceOrder(Guid CustomerId, IReadOnlyList<Line> Lines); // command public record OrderPlaced(Guid OrderId, DateTimeOffset At); // event
08DDDFoundation

Domain services vs application services

Answer

  • A domain service holds domain logic that does not naturally belong on one entity or value object.
  • An application service orchestrates use cases: load aggregate, call domain behavior, save, publish events.
  • Keep application services thin. Put business rules in the domain model or domain services.
  • Infrastructure (EF, email, queues) stays outside the domain project when you can.
  • Example: TransferService (domain) moves money between two Account aggregates with shared rules. PlaceOrderAppService loads, calls, saves.
  • Pitfall: dumping all rules into application services creates anemic entities that only hold data.
  • Evans: domain services are part of the model language. Application services are the use-case script around that model.
  • If logic needs two aggregates and no natural owner, a domain service is appropriate. Prefer the entity when one root owns the rule.
Flow diagram: Application orchestrates; domain decides
C#
public sealed class PricingService // domain service: pure business rule { public Money ApplyDiscount(Money price, decimal percent) { if (percent is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(percent)); var amount = price.Amount * (1 - percent / 100m); return new Money(amount, price.Currency); } } public sealed class PlaceOrderAppService // application service: use case { private readonly IOrderRepository _orders; private readonly PricingService _pricing; public PlaceOrderAppService(IOrderRepository orders, PricingService pricing) { _orders = orders; _pricing = pricing; } public async Task<Guid> ExecuteAsync(PlaceOrderRequest req, CancellationToken ct) { var price = _pricing.ApplyDiscount(req.UnitPrice, req.DiscountPercent); var order = Order.Place(req.CustomerId, req.ProductId, req.Qty, price); await _orders.AddAsync(order, ct); return order.Id; } }
09DDDFoundation

Repositories in DDD

Answer

  • In DDD, a repository loads and saves aggregate roots and speaks in domain terms.
  • Prefer methods like GetById and Add over a generic CRUD surface for every table.
  • Do not expose IQueryable from a repository if that leaks persistence concerns into the domain.
  • One repository per aggregate root is the usual rule of thumb.
  • Example: ICustomerRepository.GetById returns Customer. There is no IAddressRepository if Address is only a value object inside Customer.
  • Pitfall: repositories for every entity in the aggregate encourage outside code to bypass the root.
  • Microsoft DDD samples keep repository interfaces in the domain layer and implementations in infrastructure.
  • Query methods that return DTOs for screens often belong on a separate read side, not on the write repository.
Flow diagram: DDD repository
C#
public interface IOrderRepository { Task<Order?> GetAsync(Guid id, CancellationToken ct); Task AddAsync(Order order, CancellationToken ct); }
10DDDFoundation

Anti-corruption layer

Answer

  • An anti-corruption layer (ACL) translates between your model and an external or legacy model.
  • It stops foreign terms and shapes from leaking into your bounded context.
  • Map at the edge (adapter), then work only with your own types inside the context.
  • Example: a legacy SOAP CustomerDTO maps to your Customer and Address value objects before domain code runs.
  • Pitfall: calling the ACL from deep inside domain services. Keep translation at the integration boundary.
  • Evans lists ACL as a context-mapping pattern when you must consume a model you do not control.
  • The ACL can wrap facades, adapters, and translators. The goal is isolation, not a second domain model for its own sake.
  • When you own both sides, prefer shared events or explicit contracts; ACL is for protecting against foreign models.
Flow diagram: Translate at the edge
C#
public sealed class LegacyOrderDto { public string order_id { get; set; } = ""; public string cust_nm { get; set; } = ""; } public sealed class OrderAcl { public Order ToDomain(LegacyOrderDto dto) => new() { Id = Guid.Parse(dto.order_id), CustomerName = dto.cust_nm.Trim(), }; } public sealed class Order { public Guid Id { get; init; } public string CustomerName { get; init; } = ""; }
11DDDFoundation

DDD and CQRS

Answer

  • With DDD, commands land on aggregates that protect invariants on the write path.
  • Queries skip the aggregate and read a projection or DTO built for the screen.
  • DDD does not require CQRS. Keep a clear write model first, then split reads when the shapes diverge.
  • The Design Patterns CQRS topic covers command, aggregate, query, and API wiring in more depth.
  • Example: PlaceOrder updates the Order aggregate; GetOrderSummary reads a denormalized view for the UI.
  • Pitfall: forcing event sourcing just because you adopted aggregates and CQRS. Those choices are independent.
  • Microsoft guidance often shows CQRS as an option once read models need different shapes from write models.
  • Start simple: one store, separate handlers. Add projections and messaging when scale or shape demands it.
Flow diagram: Where DDD sits on the write path
C#
// Write: command on aggregate public sealed class CancelOrderHandler { private readonly IOrderRepository _orders; public CancelOrderHandler(IOrderRepository orders) => _orders = orders; public async Task Handle(CancelOrderCommand cmd, CancellationToken ct) { var order = await _orders.GetAsync(cmd.OrderId, ct) ?? throw new InvalidOperationException("Order missing"); order.Cancel(cmd.Reason); await _orders.SaveAsync(ct); } } // Read: projection, no aggregate invariants public Task<OrderListItem[]> ListOpenOrders(AppDbContext db, CancellationToken ct) => db.Orders.AsNoTracking() .Where(o => o.Status == "Placed") .Select(o => new OrderListItem(o.Id, o.Total)) .ToArrayAsync(ct);
12DDDIntermediate

Bounded Contexts and Context Mapping

Answer

  • 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)
public class LegacyInventoryAdapter : IInventoryStockService { private readonly LegacySapSoapClient _soapClient; public LegacyInventoryAdapter(LegacySapSoapClient soapClient) => _soapClient = soapClient; public async Task<StockAvailability> CheckStockAsync(Sku sku, CancellationToken ct) { // Translate clean domain value object into legacy SAP request format var legacyResponse = await _soapClient.GetItemStockAsync(sku.Value, ct); // Translate raw SAP response into native domain model return new StockAvailability(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#
public sealed class Order { private readonly List<OrderLine> _lines = new(); public IReadOnlyList<OrderLine> Lines => _lines; public void AddLine(Guid productId, int qty, decimal price) { if (qty <= 0) throw new InvalidOperationException("Qty must be positive."); _lines.Add(new OrderLine(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.
Flow diagram: Raise, commit, then project
C#
public sealed class Order { private readonly List<object> _events = new(); public IReadOnlyList<object> DomainEvents => _events; public void Cancel(string reason) { Status = "Cancelled"; _events.Add(new OrderCancelled(Id, reason)); } public string Status { get; private set; } = "Placed"; public Guid Id { get; private set; } }
Sources and further reading