Create your own
Lesson illustration

Defining Domain Model Invariants

Hello. In the previous lesson, you classified concepts from an LLD prompt as entities, value objects, or services. That classification gives us a place to put business rules. This lesson addresses the next question: which conditions must those objects never violate?

An invariant is what prevents a “valid-looking” domain model from quietly drifting into an impossible state. In an interview, naming invariants early makes your class design more credible: it explains why certain fields are private, why behavior belongs on particular objects, and why callers cannot freely change state.

By the end, you should be able to turn business rules into precise invariant statements, place enforcement at the right boundary, and describe safe state transitions for a small design such as a parking lot.


Invariants: truths the model preserves

A constraint limits the states the model may enter. An invariant is a condition that must be true whenever a domain operation has finished.

For a valid state , think of an invariant as a predicate . A correctly designed public domain operation has this property:

If an operation cannot preserve the invariant, it must fail without leaving behind a partly changed object.

Consider a parking ticket:

  • A ticket cannot be closed twice.
  • Its exit time cannot be earlier than its entry time.
  • An active ticket must have an assigned parking spot.
  • A closed ticket must record an exit time.

These are not merely API checks. They are truths that the model itself should protect regardless of whether it was called from an HTTP endpoint, a message consumer, a CLI script, or a future internal workflow.

Designing validations in the domain model layer - .NET | Microsoft Learn

Read Microsoft Learn’s “Design validations in the domain model layer.” It establishes why invariants belong in domain objects and shows an important failure mode: performing several mutations before discovering that one is invalid.

In the introductory discussion before “Implement validations in the domain model layer,” read the invariant rationale. Focus on the claim that an entity should never be allowed to exist invalidly. Then read the first part of “Implement validations in the domain model layer,” from constructor and update validation. Pay particular attention to the partial-update example: an exception does not automatically undo earlier assignments.

An invariant is different from a field being immutable. A ticket’s state is expected to change from active to closed. The invariant is that every allowed state is coherent.

It is also different from a general request-format error. The following distinctions are useful in an interview:

Contract elementMeaningParking example
PreconditionWhat must be supplied for an operation to proceedexit_time is present and can be interpreted as a timestamp
InvariantA truth preserved by the domain modelA ticket is never closed more than once
PostconditionWhat is true after a successful operationAfter exit(), the ticket is closed and its spot is free
Input validationWhether outside-world data is syntactically usableRequest field vehicle_plate is not an empty string

The boundaries can overlap. For example, an API can reject an absent exit_time before domain code runs. But ParkingTicket.close() must still reject an earlier exit time. Otherwise another entry point can construct invalid state.


Translating requirements into invariants

A business rule is often written as a sentence about an action:

“A vehicle may occupy only one spot at a time.”

Turn it into a statement about state:

Invariant: Within a parking lot, each active ticket refers to one occupied spot, and each occupied spot is associated with no more than one active ticket.

This translation matters because it forces you to ask: which objects must be considered together for this statement to remain true?

Here are examples from a modest parking-lot design.

Requirement languageInvariant statementLikely owner
“A parking fee cannot be negative.”A Money value used as a charge has a non-negative amount.Money value object
“A spot cannot be assigned twice.”An occupied ParkingSpot has exactly one active ticket ID.ParkingSpot, coordinated by the lot
“A ticket cannot be closed twice.”A closed ParkingTicket has one exit time and cannot transition back to active.ParkingTicket
“Only compatible vehicles can use a spot.”An occupied spot’s vehicle type is compatible with its configured size/type.ParkingSpot or ParkingLot
“No spot can be free and occupied at once.”A spot’s status and its active-ticket reference always agree.ParkingSpot

Notice the difference in scope:

  1. Value-level invariant: Money(-5, "USD") is invalid at construction.
  2. Single-entity invariant: a closed ticket cannot be closed again.
  3. Cross-object invariant: an active ticket and the assigned spot must agree with each other.

Do not promote every validation to an invariant. “The search query must be at most 200 characters” is usually a request or application constraint. “An order must not be paid twice” is a domain invariant because violating it corrupts business state.


Keep invalid data outside the model

Outside inputs are untrusted: user requests may be malformed, and even a trusted provider can return incomplete or unexpected data. A boundary layer should parse, normalize, and reject obviously invalid input. But after accepted data becomes a domain object, the domain model must protect its own rules.

External user input and external-system data pass through a filtration boundary before entering the domain model; valid input reaches domain state transitions, while invalid input is rejected before it can corrupt internal state.

The filtration boundary is useful, but it is not a substitute for invariants. Suppose a POST /tickets/{id}/exit endpoint validates that an exit time exists. Later, a batch job closes abandoned tickets directly. If only the controller checks that an exit time is after entry time, the batch job can create invalid tickets.

A reliable division of responsibility is:

  • Boundary/application layer: parse JSON, authenticate users, validate basic shape, translate input into domain values, coordinate persistence.
  • Domain model: defend business rules that must remain true irrespective of the caller.
  • Database: add constraints that provide a final safety net for critical data integrity rules.

The database should reinforce important invariants, not become the only location where the business rule exists. A database CHECK constraint can prevent a negative stored amount, but it cannot make an in-memory Money object valid before it is persisted.


Encode state transitions, not public setters

An anemic design exposes unrestricted state and hopes every caller remembers the rules:

ticket.status = "closed"
ticket.exit_time = exit_time
spot.status = "available"

Nothing here guarantees that exit_time is valid, that the spot belonged to this ticket, or that the ticket was previously active. Every new caller must rediscover the rules, and different callers eventually implement them differently.

A richer model exposes meaningful transitions instead:

from datetime import datetime
from enum import Enum


class TicketStatus(Enum):
    ACTIVE = "active"
    CLOSED = "closed"


class TicketAlreadyClosedError(Exception):
    pass


class InvalidExitTimeError(Exception):
    pass


class ParkingTicket:
    def __init__(
        self,
        ticket_id: str,
        spot_id: str,
        entry_time: datetime,
    ) -> None:
        if not ticket_id:
            raise ValueError("ticket_id is required")
        if not spot_id:
            raise ValueError("spot_id is required")

        self.ticket_id = ticket_id
        self.spot_id = spot_id
        self.entry_time = entry_time
        self._status = TicketStatus.ACTIVE
        self._exit_time: datetime | None = None

    @property
    def status(self) -> TicketStatus:
        return self._status

    @property
    def exit_time(self) -> datetime | None:
        return self._exit_time

    def ensure_can_close(self, exit_time: datetime) -> None:
        if self._status is TicketStatus.CLOSED:
            raise TicketAlreadyClosedError(self.ticket_id)
        if exit_time < self.entry_time:
            raise InvalidExitTimeError(
                "Exit time cannot precede entry time."
            )

    def close(self, exit_time: datetime) -> None:
        self.ensure_can_close(exit_time)
        self._exit_time = exit_time
        self._status = TicketStatus.CLOSED

The key design decision is not the exact exception classes. It is that callers have no generic set_status() operation. They must invoke close(), which preserves the ticket’s invariants.

A state transition method should generally follow this order:

  1. Validate all conditions needed for the transition.
  2. Change the object’s state.
  3. Return normally only when the new state is valid.

That ordering avoids partial mutation. In Python especially, an exception after one assignment does not roll back a preceding assignment.

Is an Anemic Domain Model an Anti-Pattern?

Watch Milan Jovanović’s “Is an Anemic Domain Model an Anti-Pattern?” for a concrete contrast between externally mutable data objects and a model that controls creation and transitions through behavior.

Watch the rich model. Focus on the use of non-public setters, factory construction, value objects, and controlled collection updates. The language shown is C#, but the encapsulation principle transfers directly to Python: do not give arbitrary callers a route around business rules.

Python cannot enforce privacy as strongly as some languages; a leading underscore is a convention, not a security boundary. Still, a clean public interface, read-only properties, and avoiding exposure of mutable collections makes the intended route for state changes clear and testable.


Cross-object invariants need a consistency boundary

Some invariants cannot be owned by one object in isolation. Closing a ticket may require both of these changes:

  • The ticket becomes closed and receives an exit time.
  • Its associated parking spot becomes available.

If external code can fetch a ParkingTicket and ParkingSpot separately and mutate each at will, it can easily create contradictions: a closed ticket pointing to an occupied spot, or a free spot with an active ticket.

Group the objects that must change consistently behind a single entry point. In DDD, this group is often called an aggregate, and the entry-point entity is its aggregate root. For this small interview model, ParkingLot can act as the coordinator for spot assignment and vehicle exit.

Conceptually, its exit() behavior would work like this:

class ParkingLot:
    def exit(self, ticket_id: str, exit_time: datetime) -> None:
        ticket = self._find_active_ticket(ticket_id)
        spot = self._find_spot(ticket.spot_id)

        # Validate everything before changing either object.
        ticket.ensure_can_close(exit_time)
        spot.ensure_occupied_by(ticket_id)

        # These state changes now form one domain operation.
        spot.release(ticket_id)
        ticket.close(exit_time)

This design makes the contract visible:

  • ParkingLot.exit() is the public business operation.
  • It ensures the ticket and spot agree before and after completion.
  • A failure during validation leaves both objects unchanged.

7. Aggregates and Consistency Boundaries

Read the selected parts of Cosmic Python’s “Aggregates and Consistency Boundaries.” It gives a precise definition of invariants and explains why a group of related objects sometimes needs one controlled consistency boundary.

In the opening section, read constraints and invariants, including the hotel-booking example. Then move to “What Is an Aggregate?” and read the consistency-boundary explanation. For now, retain the practical idea: choose a small owner that prevents related objects from being changed in contradictory ways.

A consistency boundary is not a reason to put the entire application behind one giant System object. Keep it as small as the invariant allows. If two pieces of state do not need to be correct together immediately, they should not automatically be in the same boundary.

Concurrency adds a second challenge. Two simultaneous “park vehicle” operations may both observe the same spot as available. Correct domain methods define the rule, but persistence and synchronization must enforce it across concurrent requests. You will address race conditions and synchronization in a later module; for now, recognize this interview distinction:

“The domain model prevents invalid transitions within one operation. For concurrent writes, I would also enforce the invariant at the persistence boundary using an appropriate transaction, lock, or optimistic version check.”


An interview method for identifying invariants

After identifying entities and value objects, spend one or two minutes making an invariant list before drawing methods. Use this compact format:

StepWhat to say
Rule“A parking spot cannot have two active tickets.”
Invariant“Each occupied spot references exactly one active ticket; each active ticket references one occupied spot.”
Boundary“The parking lot coordinates assignment and release because this rule spans tickets and spots.”
Allowed operationspark_vehicle() assigns a compatible free spot; exit() releases the matching spot and closes the ticket.”
Failure behavior“If any condition fails, the operation raises a domain error and changes nothing.”

This is more effective than listing vague validations such as “check nulls” or “validate ticket.” A strong invariant is specific enough that you can identify:

  • what state would violate it,
  • which operation could cause that violation,
  • the object responsible for preventing it, and
  • the error or result when the transition is rejected.

A useful verbal answer in an LLD interview might be:

“I’ll protect local ticket rules inside ParkingTicket, including valid entry and exit times and preventing a second close. The lot owns cross-object consistency: an active ticket and occupied spot must refer to each other. Therefore callers use ParkingLot.park_vehicle() and ParkingLot.exit() rather than mutating ticket or spot status directly. Each operation validates first, then performs its state changes.”

That explanation connects business requirements, encapsulation, and class responsibilities—the elements interviewers are looking for.


Key takeaways

An invariant is a condition that remains true whenever a domain operation completes. It is not merely an HTTP validation rule or a database constraint.

To model invariants well:

  • Translate action-oriented business rules into statements about valid state.
  • Put value-level rules in value objects and lifecycle rules in the entity that owns the state.
  • Protect cross-object rules through a small consistency boundary with controlled operations.
  • Validate before mutating so failed operations do not leave partial state behind.
  • Use database transactions, locks, or version checks as additional protection when concurrent requests can violate the same invariant.

Next, you will use these invariants to assign object responsibilities. The central question will be: given a rule and the state it protects, which class should own the behavior without becoming overly coupled or turning into a giant service class?

Can't find a good explanation? Sign up and we'll make it for you

Sign up