Skip to main content
SEMastery
Architecturebeginner

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.

12 min readUpdated November 3, 2025

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.

An invariant must hold true before a change and still hold true after the change.

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.

IdeaQuestion it answersExampleWhere it lives
Input validationIs this raw input shaped correctly?Is the email field non-empty? Is age a number?Edge of the app (API, form)
InvariantIs 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

Controller checks
Service checks
Job forgets check
Bad state saved

Steps

1

Controller checks

One copy of the rule

2

Service checks

Another copy

3

Job forgets check

Rule skipped

4

Bad state saved

Invariant broken

When rules live outside the object, every new code path is a chance to break them.

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.

With a rich domain model, every change goes through one guarded method.

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

Caller
Order (root)
Check rules
OrderLines

Steps

1

Caller

Calls a root method

2

Order (root)

Owns the rules

3

Check rules

Validate the whole group

4

OrderLines

Updated only via root

Outside code talks only to the root, never to inner items directly.

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 rulesGood atWeak at
User interfaceFast feedback to the userEasily bypassed by API calls or scripts
Application servicesCoordinating stepsRules get duplicated and drift apart
Database constraintsUniqueness, not-null, foreign keysRich rules like "ship only if paid"
Domain modelAll business rules in one placeNeeds 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.

Defence in layers, but the domain model is the last and strongest gate.

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 IReadOnlyCollection and 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 Account class, 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

Related Posts