Domain-Driven Design: Defining and Enforcing Domain Invariants
I have worked on several teams that claimed to follow Domain-Driven Design. In every case, the teams had the folder structure and the tactical patterns in place, but none of them had defined the rules of their domain. The underlying code let you put a domain object into any state you wanted, at any time, from anywhere. The invariants existed only in the developers’ heads.
This is the gap I see most often between DDD in theory and DDD in practice. The tactical patterns (entities, value objects, aggregates, repositories) are well documented and relatively easy to implement. The strategic part, which is defining what rules your domain must always enforce and then coding those rules so they cannot be violated, is almost always missing. And that missing piece is what makes a codebase maintainable or not.
This is not an exhaustive guide to DDD. It focuses on the one part that I believe makes the biggest difference for long-term maintainability: defining domain invariants and making them enforceable in code.
The Difference Between DDD Structure and DDD Discipline
A codebase with entities and repositories looks like DDD but does not behave like DDD. The structure alone changes nothing. What changes the maintainability characteristics of a codebase is the discipline of keeping the domain model consistent at all times.
Consider an order management system. A team using DDD structure might have an Order entity, an OrderItem value object, an OrderRepository interface, and an OrderService that orchestrates the flow. The code compiles, the tests pass, and the application ships features.
But if I can do this from anywhere in the codebase:
Order order = new Order();
order.setStatus("shipped");
order.setItems(emptyList());
orderRepository.save(order);
then there is no domain model. There is a data container wrapped in a domain-sounding name. The rule “a shipped order must have at least one item” exists as tribal knowledge, or as a database constraint, or as a validation method somewhere that someone might forget to call. It is not enforced by the code itself.
The difference between a data container and a domain model is whether the object can be put into an invalid state. Mechanisms like a value object that cannot represent an invalid value, an entity method that checks preconditions before mutating state, and an aggregate root that enforces invariants across its children are what separate real DDD from folder-structure DDD.
Domain Invariants Are the Core of DDD
A domain invariant is a rule that must always hold true for the domain model to be in a correct state. It is not a nice-to-have validation that shows an error message in the UI. It is a constraint that the domain model cannot violate, period.
Some examples from common domains:
- An order cannot be shipped before it is paid
- A bank account balance must never go below zero
- A booking cannot overlap with an existing booking for the same resource
- An email address in a contact system must be syntactically valid
- A user cannot have more than five active sessions
Each of these is a rule that, if broken, puts the system into a state that contradicts the business requirements. The code should make these states impossible to reach, not just warn against them.
The reason many teams skip this step is that defining invariants is hard. You have to sit down with domain experts and ask questions like “what states should never happen?” and “what sequence of events would break the system?” These conversations are uncomfortable because they expose assumptions that everyone has been working under but nobody has written down.
This difficulty does not show up in toy examples. A demo project with two entities and one invariant is trivial to model: the entity methods are short, the rules are obvious, and a single developer understands the whole thing. The problems start when the codebase is five years old, the domain has grown to a dozen aggregates with rules that interact in unexpected ways, and nobody on the team was around when the original invariants were defined. That is when the discipline around enforcement actually matters, and it is also when most teams drop it in favor of a quick setter or a validation that only runs in the UI layer.
I see this most clearly in systems that expose both a web UI and a public API. The UI controller validates some subset of the domain rules and returns a form error. The API endpoint validates a different subset, because the public API has different constraints (bulk operations, different authentication, different error formats). Both sets of validation live in the infrastructure layer, not in the domain model. Over time they drift: a rule changes in the API validation but not in the UI, or the UI adds a new check that the API never learns about. The domain invariants leak into controllers and handlers, and the domain model itself becomes a passive data structure that accepts anything. The only way to know all the rules is to read every controller and every API handler and reconcile them manually.
But every invariant you leave undefined is an invariant you will rediscover as a production bug.
Enforcing Invariants Through Constructor Design
The simplest enforcement mechanism is to make it impossible to construct an object in an invalid state.
public class EmailAddress {
private final String value;
public EmailAddress(String value) {
if (value == null || !value.matches("^[^@]+@[^@]+\\.[^@]+$")) {
throw new IllegalArgumentException("Invalid email: " + value);
}
this.value = value;
}
public String getValue() {
return value;
}
// no setters, no way to change the value after construction
}
This class guarantees that every instance of EmailAddress in the system contains a syntactically valid email. No other code needs to check this condition again. The type system provides the guarantee.
The same principle applies to entities and aggregates, but the enforcement moves from constructor validation to behavioral methods.
Enforcing Invariants Through Behavioral Methods
An entity with public setters is not a domain model. It is a struct. The point of an entity is to encapsulate state changes behind methods that enforce the rules.
public class Order {
private OrderId id;
private OrderStatus status;
private List<OrderLine> lines;
private PaymentId paymentId;
public void addLine(Product product, Quantity qty) {
if (status == OrderStatus.SHIPPED) {
throw new DomainException("Cannot add items to a shipped order");
}
lines.add(new OrderLine(product, qty));
}
public void markPaid(PaymentId payment) {
if (status != OrderStatus.PENDING) {
throw new DomainException("Only pending orders can be paid");
}
this.paymentId = payment;
this.status = OrderStatus.PAID;
}
public void markShipped() {
if (status != OrderStatus.PAID) {
throw new DomainException("Only paid orders can be shipped");
}
if (lines.isEmpty()) {
throw new DomainException("Cannot ship an order with no items");
}
this.status = OrderStatus.SHIPPED;
}
// No setStatus method. No setLines method.
// No setPaymentId method.
// The only way to change state is through these methods.
}
Every state transition is guarded by explicit precondition checks. A developer adding a new code path that tries to ship an unpaid order does not discover the problem during code review or during testing. They discover it when the code throws a DomainException at the point of the violation, with a clear message explaining which rule was broken.
The precondition checks serve double duty: the method body documents the business rules, and the throw statements enforce them at runtime.
Designing Interfaces That Prevent Misuse
The enforcements inside the domain model only help if external code is forced to go through them. This is where interface design matters.
If your repository interface offers a save(Order order) method that accepts any order in any state, then nothing prevents a caller from constructing an order with new Order(), modifying its fields through setters, and saving it. The domain enforcement is bypassed.
The fix is to design interfaces that match the lifecycle of the domain object.
For aggregates, this often means:
- The aggregate root exposes no setters or mutators that could leave it in an inconsistent state
- The repository accepts only valid aggregate instances
- The factory or creation method enforces invariants at construction time
- The aggregate controls its own state transitions through behavioral methods
A side effect of this design is that the interface becomes a reflection of the ubiquitous language. The method names match the business operations: markPaid, markShipped, addLine. Not setStatus, updateItems, saveChanges. The language of the code and the language of the domain converge.
When Enforcement Moves Across Aggregates
Some invariants span multiple aggregates. An inventory check, for instance, might involve both the Order aggregate and the Product aggregate. In this case, a single aggregate cannot enforce the invariant on its own.
The standard approaches are:
- Domain service: A stateless service that coordinates multiple aggregates and throws if the precondition fails
- Eventual consistency with saga: Accept that the invariant will be checked asynchronously and handle violations through compensating actions
- Tactical design change: Merge the aggregates if the invariant is strict enough to warrant it
The key is that the invariant is still defined and enforced somewhere explicit. It is not left implicit with the hope that the UI will prevent the problematic input.
The Maintainability Payoff
Enforcing invariants through code requires more up-front effort than adding validation in the service layer or trusting the frontend to prevent invalid input. The payoff comes after the second or third year of the project, when the rules have been modified several times, new developers have joined, and the codebase has accumulated features.
In codebases without enforced invariants, a developer making a change to a business rule must trace through every caller to see if the new precondition is met. In codebases with enforced invariants, the developer changes the guard condition in the entity method and gets immediate feedback the next time the code compiles and runs. The objects themselves enforce the contract.
Codebases with enforced invariants also have a property that is hard to achieve otherwise: you can trust the domain objects. When you receive an EmailAddress, you know it is valid. When you receive a PaidOrder, you know payment was collected. This trust eliminates entire categories of defensive checks and null checks from the rest of the code.
Recognizing Whether Your Codebase Actually Does DDD
A practical test for whether your team is doing DDD or just using DDD folder names:
Look at any entity in your codebase. Can you construct an instance that violates a business rule by calling a setter, passing null to a constructor, or calling methods in the wrong sequence? If yes, your codebase has DDD-shaped classes but the domain rules live elsewhere (the service layer, the database, or nowhere).
The fix is not to rename classes. Identify the most critical invariants, encode them at the most natural enforcement point (constructor, entity method, domain service, aggregate boundary), and remove the public API that bypasses them. Start with the rules that would cause the most damage if broken, and work outward from there.