What Invariants Are and Why the Domain Model Enforces Them Best
Learn what invariants are in DDD with simple examples, and why your .NET domain model and aggregate roots are the safest place to keep business rules true.
Think about a cricket match. There is one rule that can never be broken: a batting side can lose only ten wickets. Not eleven, not twelve. The moment the tenth wicket falls, the innings is over. Nobody on the field is allowed to say, "Let us play one more batter today." That rule holds true at every single moment of the game.
A rule like this, one that must always be true, is called an invariant. The word sounds heavy, but the idea is simple. In-variant means "does not vary". It does not change. It stays true no matter what.
Software is full of these rules too. A bank balance should never go below zero. An order should never ship with no items in it. A user's email should never be empty. When we build software the right way, we want these rules to hold true forever, the same way the ten-wicket rule holds true in cricket.
In this article you will learn what invariants are, where they tend to leak and break, and why your domain model is the safest home for them. We will use C# and a few small examples, and we will keep things friendly.
What exactly is an invariant?
An invariant is a condition about your data that must hold true before and after every change.
Let us make that concrete. Imagine a simple bank account. Here are some invariants:
- The balance must never be negative.
- You cannot withdraw more money than you have.
- A frozen account must not accept any new transactions.
These are not "nice to have" checks. They are promises. If even one of them breaks, the account is in a wrong state, and wrong states cause real harm: a customer sees minus money, a report shows a wrong total, or a bug becomes very hard to find later.
The key word is always. A rule that is true "most of the time" is not an invariant. It is just a hope. An invariant is a guarantee.
Invariants versus simple input validation
People often mix up input validation with invariants. They are related but not the same.
| Idea | Question it answers | Example | Where it lives |
|---|---|---|---|
| Input validation | Is this raw input shaped correctly? | Is the email field non-empty? Is age a number? | Edge of the app (API, form) |
| Invariant | Is the business object always in a legal state? | Can this account go below zero? | Inside the domain model |
Input validation checks the shape of data coming in. Invariants protect the meaning of your business. You can pass every input check and still break a business rule. For example, "withdraw 500" is perfectly valid input, but it breaks an invariant if the account only holds 200.
Where invariants go to die
In many codebases, business rules get scattered. A little check here in a controller, another check there in a service, a third copy somewhere in a background job. This spreading out is where invariants quietly die.
Here is a classic example of the problem. The account is just a bag of data with public setters, and the rule lives outside it.
// The anemic style: data with no protection.
public class Account
{
public Guid Id { get; set; }
public decimal Balance { get; set; } // anyone can set anything
public bool IsFrozen { get; set; }
}
// The rule lives far away, in a service.
public class WithdrawService
{
public void Withdraw(Account account, decimal amount)
{
if (account.IsFrozen) throw new InvalidOperationException("Frozen");
if (amount > account.Balance) throw new InvalidOperationException("No funds");
account.Balance -= amount;
}
}This looks fine at first. But the Balance setter is public. Any other piece of code, anywhere in the whole project, can write account.Balance = -9999 and nobody stops it. The rule lives in one service, but the door is wide open everywhere else.
When a class is just data with public setters and no behavior, we call it an anemic domain model. It is a known antipattern. The rules end up spread across many service classes, and each new developer has to remember to call the right check in the right order. Sooner or later, someone forgets.
How invariants leak in a scattered design
Steps
Controller checks
One copy of the rule
Service checks
Another copy
Job forgets check
Rule skipped
Bad state saved
Invariant broken
The domain model: one gate, one guard
A domain model is the part of your code that captures the real business: the accounts, orders, students, or whatever your app is truly about. A rich domain model does not just hold data. It holds behavior too. The object knows its own rules and protects itself.
The big idea is this: instead of letting outside code change the data freely, you close the doors. You make the fields read-only from outside, and you expose clear methods that describe what can happen. Every method checks the invariants before it changes anything.
Here is the same account, but now it guards itself.
public class Account
{
public Guid Id { get; private set; }
public decimal Balance { get; private set; } // no public setter
public bool IsFrozen { get; private set; }
private Account() { } // for EF Core
public Account(Guid id, decimal openingBalance)
{
if (openingBalance < 0)
throw new ArgumentException("Opening balance cannot be negative.");
Id = id;
Balance = openingBalance;
IsFrozen = false;
}
public void Withdraw(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Amount must be positive.");
if (IsFrozen)
throw new InvalidOperationException("Account is frozen.");
if (amount > Balance)
throw new InvalidOperationException("Insufficient funds.");
Balance -= amount; // the only place balance ever drops
}
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Amount must be positive.");
if (IsFrozen)
throw new InvalidOperationException("Account is frozen.");
Balance += amount;
}
}Notice what changed. There is no public way to set Balance from outside. The only way money leaves the account is through Withdraw, and that method refuses to break the rules. The account can never reach a bad state, because there is no door that leads to one.
This is the heart of the lesson. When the rule lives inside the object, there is exactly one gate and one guard. Every code path, today and in the future, has to walk through that same gate.
The aggregate root: guarding a small group
Real business objects rarely sit alone. An order has order lines. A class has students. A shopping cart has items. The rules often stretch across the whole group, not just one object. For example: "an order's total must equal the sum of its lines", or "an order must have at least one line before it can be placed".
In Domain-Driven Design, a small cluster of related objects that change together is called an aggregate. One object in that cluster is chosen as the boss. It is called the aggregate root. All changes to anything inside the cluster must go through the root. The root becomes the single guard for the whole group's invariants.
Changes flow through the aggregate root
Steps
Caller
Calls a root method
Order (root)
Owns the rules
Check rules
Validate the whole group
OrderLines
Updated only via root
Here is an order acting as an aggregate root. See how the inner list of lines is hidden, and only the root can change it.
public class Order
{
private readonly List<OrderLine> _lines = new();
public Guid Id { get; private set; }
public bool IsPlaced { get; private set; }
// A read-only view. Outside code can look but cannot change.
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public decimal Total => _lines.Sum(l => l.LineTotal);
public void AddLine(string product, int quantity, decimal price)
{
if (IsPlaced)
throw new InvalidOperationException("Cannot change a placed order.");
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive.");
_lines.Add(new OrderLine(product, quantity, price));
}
public void Place()
{
// The invariant: an order needs at least one line to be placed.
if (_lines.Count == 0)
throw new InvalidOperationException("An order needs at least one line.");
IsPlaced = true;
}
}Because Lines is exposed only as IReadOnlyCollection, no outside code can sneak in a bad line or empty the list. The Total is always correct because it is computed from the lines the root controls. The "at least one line" rule lives in Place, the only method that can place the order. The group can never end up in a half-broken state.
Why the domain model beats the alternatives
You might ask: why not just put all rules in the database, or in the user interface? Both have their place, but neither can hold the full set of invariants. Let us compare honestly.
| Place to enforce rules | Good at | Weak at |
|---|---|---|
| User interface | Fast feedback to the user | Easily bypassed by API calls or scripts |
| Application services | Coordinating steps | Rules get duplicated and drift apart |
| Database constraints | Uniqueness, not-null, foreign keys | Rich rules like "ship only if paid" |
| Domain model | All business rules in one place | Needs discipline to keep logic inside |
The user interface is helpful for friendly messages, but you should never trust it as your only guard. Anyone can call your API directly and skip the screen. The database is great for simple structural rules, but it cannot express deep business meaning. Application services are needed to orchestrate work, yet if the rules live there, they get copied and slowly fall out of sync.
The domain model is the one place that sits close to the meaning of the business and can hold every rule in one spot. As Microsoft's own architecture guidance puts it, an entity should not be able to exist in an invalid state, and enforcing invariants is the responsibility of the domain entities, especially the aggregate root.
Think of it like the security at a stadium. The ticket counter (UI) does a quick check. The gate staff (application service) check again. But the final gate into the ground (domain model) is the one nobody can walk past without a valid pass. Even if someone slips through earlier checks, the last gate holds.
A few practical tips
These habits keep your invariants strong over time:
- Make setters private. If outside code can write a field directly, your rules can be skipped. Expose methods, not raw fields.
- Validate inside the constructor. An object should be born valid. If the inputs are bad, throw right away so a broken object never exists.
- Hide collections. Expose lists as
IReadOnlyCollectionand add items only through root methods. - Throw clear exceptions. When a rule is broken, fail loudly with a message that explains which rule. Silent failures hide bugs.
- Keep rules where the data is. If a rule is about an account, it belongs in the
Accountclass, not in three different services.
You do not need a giant framework for any of this. Plain C# classes with private setters and meaningful methods already give you most of the protection. Start small, protect one important rule, and grow from there.
Quick recap
- An invariant is a rule that must always be true for your data, like "a balance can never go negative" or "an order needs at least one line".
- Invariants are different from input validation. Input validation checks the shape of data; invariants protect the meaning of the business.
- When rules live scattered across services with public setters, you get an anemic domain model, and the rules leak and break.
- A rich domain model hides its data behind methods. Each method checks the invariants, so there is one gate and one guard.
- An aggregate root is the boss object that guards a small group of related objects. All changes flow through it, keeping the whole group valid.
- The domain model is the best home for invariants because it sits close to the business meaning and holds every rule in one place, where no code path can skip it.
References and further reading
- Designing validations in the domain model layer - Microsoft Learn
- Designing a DDD-oriented microservice - Microsoft Learn
- Implementing a microservice domain model with .NET - Microsoft Learn
- Designing a microservice domain model - Microsoft Learn
Related Posts
Value Objects in .NET: DDD Fundamentals Made Simple
Learn value objects in .NET with simple examples. Understand equality, immutability, records vs base class, and EF Core mapping in domain-driven design.
Refactoring From an Anemic Domain Model to a Rich Domain Model in .NET
A friendly, step-by-step guide to turning a data-only anemic domain model into a rich domain model in C# .NET, with rules living inside your objects.
CQRS Pattern in .NET: The Way It Should Have Been From the Start
A friendly, hands-on guide to the CQRS pattern in .NET 10. Learn commands, queries, handlers, diagrams, and when to actually use it.
From Anemic Models to Behavior-Driven Models: A Practical DDD Refactor in C#
Learn to refactor anemic C# classes into rich, behavior-driven domain models using DDD. A simple, step-by-step guide with diagrams and real code.
The Interview Question That Changed How I Think About System Design
One simple interview question taught me that good system design is not about fancy tools. It is about honest trade-offs, in plain .NET.
Refactoring a Modular Monolith Without MediatR in .NET
Learn to remove MediatR from a .NET modular monolith using plain handlers and a tiny dispatcher, with CQRS, pipeline behaviors, and clear module boundaries.