In this series, we first defined simplicity as the ease of reasoning about a system. We then identified that the core complexity in modern software comes from its “non-trivial,” stateful nature, where behavior is shaped by past events.

To manage this, we concluded that we must define and enforce invariants—strong, unyielding rules that protect the integrity of the system’s state during transitions.

However, a rule described in documentation is merely a hope. This post is about turning that hope into a guarantee. We will explore how to build our most important rules into the very structure of our code, giving essential complexity a home where it is explicit, contained, and impossible to violate by design.

A Strong Statement Link to heading

Let’s begin with a clear and universal rule:

Every Order always has a shipping address.

If we can uphold this, much of our reasoning becomes easier. Code that touches Orders no longer needs to worry about missing addresses. The rule acts as a guarantee across the whole system.

So how is this typically implemented? Link to heading

When we want a rule to hold everywhere, the natural instinct is to control the path where changes enter the system. We create a narrow corridor: input is accepted at the edge, decisions are applied in the middle, and only then is state written. In most teams, that corridor takes a familiar shape: a controller receives the request, a service applies the business rules, and a repository stores the result. It’s the standard pattern we see in countless systems.

The Controller–Service–Repository Architecture Link to heading

This three-layered structure has become the default way of building business applications. It shows up in frameworks, tutorials, and production systems alike. Each layer has a distinct role:

  • Controller — the entry point. It receives requests from the outside world (HTTP, messages, CLI), translates them into method calls, and returns responses. Its focus is I/O, not business rules.

  • Service — the home of application and business logic. This is where rules are applied and decisions are made. If the system has an invariant like “Every Order must always have a shipping address”, the Service is usually where that rule lives.

  • Repository — the gateway to persistence. It hides database details behind an interface, responsible for retrieving and saving entities. It doesn’t make decisions about rules — it only stores state.

Together, these layers form a corridor: data flows from the edge inward, business rules are applied in the middle, and state is written at the bottom.

Implementing the Rule the Classic Way Link to heading

The requirement is clear: Every Order must always have a shipping address. To even talk about this rule, we need an Order—a place to hold that address.

public class Order
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public required string ShippingAddress { get; set; }
    // …other fields…
}

This is straightforward: our Order has a property for the shipping address.

Next, we want to place an order. In the classical style, this logic usually lives in a Service:

public class OrderService(IOrderRepository repository)
{
    public void PlaceOrder(string shippingAddress)
    {
        if (string.IsNullOrWhiteSpace(shippingAddress))
        {
            throw new ArgumentException("Shipping address must not be empty.");
        }

        var order = new Order
        {
            ShippingAddress = shippingAddress
        };

        repository.Save(order);
    }
}

Here, the rule is enforced: the Service checks the input before creating and saving the Order. If the shipping address is missing, an exception is thrown.

Revisiting the Statement Link to heading

Let’s check: does our implementation uphold the invariant?

The rule we want is universal:

Every Order always has a shipping address.

In our example, the only way to create an order is through the PlaceOrder method on the OrderService. That method explicitly enforces the rule by rejecting empty or missing addresses.

So the statement that actually holds in this design is:

Every Order created by the PlaceOrder service method always has a shipping address.

And since no other service methods exist yet, this narrower statement is equivalent to our intended universal rule. In practice, we’re safe — for now.

But things rarely stay that simple.

A New Requirement Link to heading

Suppose the business now asks for a feature to let customers update their shipping address. We add a new method to support that:

public void ChangeShippingAddress(Guid orderId, string newAddress)
{
    var order = repository.GetById(orderId);
    order.ShippingAddress = newAddress;
    repository.Update(order);
}

This works — but notice what’s missing: there’s no validation. We’ve quietly weakened the invariant. Now an Order can exist in the database without a valid address, simply by calling this method with an empty string.

Patching the Rule Link to heading

We notice the hole and patch it the same way we did before — by adding validation:

public void ChangeShippingAddress(Guid orderId, string newAddress)
{
    if (string.IsNullOrWhiteSpace(newAddress))
    {
        throw new ArgumentException("Shipping address must not be empty.");
    }

    var order = repository.GetById(orderId);
    order.ShippingAddress = newAddress;
    repository.Update(order);
}

Now both PlaceOrder and ChangeShippingAddress validate shipping addresses. At first glance, the invariant seems restored.

But Are We Really Safe?

Not quite. Let’s revisit the statement again:

Every Order always has a shipping address.

This still doesn’t hold universally, because:

  • We load an existing order from the repository and simply assume it’s valid.
  • If the database already contains an invalid Order (through old code, migrations, tests, or manual fixes), our system will happily carry it forward.
  • Our guarantees only cover new assignments made through these service methods, not the full lifecycle of the entity.

So the strongest claim we can honestly make now is:

Every new shipping address assigned through our service methods is validated.

That’s a much weaker statement than we started with. The invariant isn’t universal anymore — it’s conditional on how the Order was created or modified.

Piling On More Checks Link to heading

At this point, you might object: “But we can catch this in code review. Or we can just be more disciplined. Or hire better engineers.”

That’s a common argument. And in small, simple systems, it might even seem to work. A careful review process and a team that knows the rules by heart can hold things together for a while.

Another objection: “We can guard the repository too. If every update goes through a validation step there, then we’re safe, right?”

But here’s the catch: none of these measures are absolute. Even with a guarded repository:

  • I can still create an Order object with an empty string in a test, a migration script, or some other helper code. Nothing stops me, and nothing in the code signals that this is a violation of a core business rule.
  • The rule has become a convention that we hope is followed, not a guarantee that is enforced.

All of this points to the same problem: every time we discover a gap, we patch it with yet another safeguard — a check in the service, a constraint in the repository, maybe even a database trigger. Each patch makes the system feel safer, but it never truly closes the loop. Instead, the rule gets scattered into multiple places, and our confidence erodes.

At some point we have to step back and ask: if invariants are so central to our system, why are we treating them as afterthoughts? Why don’t they have a single, authoritative home?

Where Do These Rules Really Belong? Link to heading

When we step back and look at the architecture, we often say: “the service layer contains our business logic.” And at first, that sounds fine. In fact, if we inspect our OrderService, it often looks like a collection of “use case” functions:

  • CreateOrder(): maybe it checks that the ShippingAddress is not null.
  • ShipOrder(): maybe it checks that the order is paid before shipping.
  • CancelOrder(): maybe it ensures you can’t cancel a shipped order.

Each function enforces a bit of logic. Each function is coherent on its own. But notice what’s happening here: every rule about what an Order is — rules that are meant to define the very concept of an order in our domain — are scattered across procedural guard clauses in different services.

That means the statements we can make are all contextual and conditional. We can say things like “if you create an order through this method, it will always have a shipping address.” But we cannot say “an order always has a shipping address” — because nothing in the Order itself enforces that.

Real systems rarely revolve around one rule. They’re full of invariants: minimum prices, stock limits, tax rules, user permissions, subscription states. If all of these are scattered across dozens of service methods, how can anyone — a developer, an auditor, or even a future you — know with confidence that they are always enforced?

So if the service layer can’t provide those guarantees, and the repository can’t either, where should these rules live?

The answer is: in the domain model itself.

The Domain Model Link to heading

Martin Fowler describes the domain model as:

“An object model of the domain that incorporates both behavior and data.”

In other words, it’s not just a bag of properties to hold state. It’s a model of the problem space itself — complete with the rules, relationships, and behaviors that give that data meaning.

How you visualize this depends on your architectural style:

  • In the classic layered architecture, the domain model is a deeper layer below the services and controllers.
  • In the hexagonal (ports and adapters) view, it’s the very core of the system, with everything else arranged around it.

Either way, the point is the same: the domain model is where the concepts of your problem domain live, and it’s where the rules that define those concepts must be enforced.

Seen this way, building a domain model is like creating your own little world inside the system. A world where:

  • Global invariants can be enforced.
  • Concepts can be encoded directly into the structure of the code.
  • Invalid states simply cannot be represented.

But to make this work, we need one new meta-rule:

All data must go through the domain model.

That’s the one rule that enforces all others. If every change to the system’s state passes through the model, then the model becomes the single source of truth for what is valid and what is not.

Trust Nothing, Verify Everything Link to heading

If this feels familiar, it should. The idea of pushing all data through the domain model has strong parallels to security principles like the principle of least privilege and zero trust.

  • Least privilege says: “Never grant more access than is strictly required.”
  • Zero trust says: “Never assume trust just because something is inside the system — always verify.”

In the same way, our domain model treats all data as untrusted until it passes through the rules that give it meaning. A string from a database, a JSON payload from an API, even a test fixture — none of these are considered “valid Orders” until they can be represented in the domain model.

Only then does the data stop being a raw value and become part of a well-defined concept. The model itself is the gatekeeper: it either admits the data into the valid world, or rejects it as something the domain simply does not allow.

That’s why we say:

The domain model is the one rule to enforce all others.

Making the Rule Structural Link to heading

Let’s rewrite our Order. Instead of treating it as a passive bag of data, we let it guard its own validity. The invariant is baked directly into the type itself.

public class Order
{
    public Guid Id { get; }
    public string ShippingAddress { get; private set; }

    public Order(string shippingAddress)
    {
        if (string.IsNullOrWhiteSpace(shippingAddress))
        {
            throw new ArgumentException("Shipping address must not be empty.");
        }

        Id = Guid.NewGuid();
        ShippingAddress = shippingAddress;
    }

    public void ChangeShippingAddress(string newAddress)
    {
        if (string.IsNullOrWhiteSpace(newAddress))
        {
            throw new ArgumentException("Shipping address must not be empty.");
        }

        ShippingAddress = newAddress;
    }
}

Now the invariant is enforced in a single place — inside the domain model itself. Invalid orders can no longer be constructed, and invalid updates are impossible. The universal statement is restored:

Every Order always has a shipping address.

In a real application, of course, we’d also need a way to hydrate an Order from stored data. That’s a practical concern for persistence — but for clarity, we’ll omit it here. The important point is that any valid Order in memory can only exist if it passes through these guards.

And notice what happened to our OrderService:

public class OrderService(IOrderRepository repository)
{
    public void PlaceOrder(string shippingAddress)
    {
        var order = new Order(shippingAddress);
        repository.Save(order);
    }

    public void ChangeShippingAddress(Guid orderId, string newAddress)
    {
        var order = repository.GetById(orderId);
        order.ChangeShippingAddress(newAddress);
        repository.Save(order);
    }
}

The service no longer contains business rules. It has been reduced to orchestration — fetching data, delegating to the domain model, and persisting the results. The strong statement now lives where it belongs: in the domain itself.

Revisiting the Architecture Link to heading

Notice what changed in our architecture.

Before, the service layer carried both application flow and the burden of enforcing core rules. That made every statement conditional on the method being used.

Now the invariant lives in the domain model itself. The statement “Every Order always has a shipping address” is no longer tied to a particular use case — it describes the concept of an Order universally.

That frees the service layer to focus on application logic.

With invariants at home in the model, the rest of the system becomes easier to trust and easier to understand. It becomes simpler.


This post focused on one invariant to show why the domain model matters. In practice, domain models carry many such rules, shaping a “valid world” inside the system. Domain-Driven Design builds on this idea, offering deeper patterns for managing complexity. For now, it’s enough to see that giving invariants a home in the model is the first step toward making simplicity real.