This is the second article in the hands-on series about shaping a real-world problem into code using Ontologic. Each article focuses on one main idea and points to the library-examples repo so you can read, run, and change the code yourself.
In this article we pick up the librarian's project from Part 1 and tackle the next thing that lives on paper: the Loan. Along the way we meet invariants rules that must hold true about an entity at all times, not just when a specific command runs.
If you haven't read Part 1 yet, I'd recommend starting there. We modeled the Book entity, introduced domain events and typed domain errors.
Picking up where we left off
In Part 1 the librarian was happy: the staff could add books, mark one as lost, and every change produced an event they could audit later. Now they want to actually lend books.
So a staff member opens the "new loan" form. They pick a book, they pick a member, the system proposes a due date three weeks out, and they save. Good.
But what if the form has a manual due-date override, and someone types in, by mistakes, a date before the loan date? What if a "return" gets recorded with a timestamp from two months ago?
We could validate in the form (and we must). We could validate in the use case. We could validate in the repository before writing. But every new entry point is another place to remember the rule and the moment someone forgets, we have a Loan in the database with a corrupted state.
Meet the Loan
Just like the Book in Part 1, the Loan is a DomainEntity with its own state, a static create that emits a LoanCreatedEvent, and a returnBook method that returns a Result<LoanReturnedEvent, LoanAlreadyReturnedError>. The full file is here: loan.entity.ts.
// src/domain/entities/loan/loan.entity.ts
export interface LoanState {
bookId: string;
memberId: string;
loanDate: string;
dueDate: string;
returnedAt: string | null;
}
For simplicity in this tutorials, Dates are stored as ISO strings because they cross layers (JSON over HTTP, rows in Postgres). We'll parse them when we need comparisons.
The rule we keep repeating
Now look at the dates. A few rules should always hold for any Loan that exists in our system:
- The due date must be after the loan date. You can't borrow a book that's due yesterday.
- The return date must not be before the loan date. You can't return a book you haven't borrowed yet.
We could check these in the form. And in the use case. And when hydrating from the database. And in the next endpoint someone adds six months from now.
But these aren't rules about "when the user clicks save" or "when we call returnBook". They are rules about what it means to be a Loan at all. If a Loan in memory ever has a due date before the loan date, something is wrong with the code it doesn't matter how we got there.
That's what an invariant is: a property that must be true about the entity's state, always, regardless of which path produced that state.
Declaring an invariant
In Ontologic, an invariant is a small object with a name, and a predicate over the entity's state. It is built with BaseDomainInvariant.
// src/domain/entities/loan/invariants/dueDateAfterLoanDate.invariant.ts
import { BaseDomainInvariant } from "ontologic";
import { LoanState } from "../loan.entity";
export const dueDateAfterLoanDate = new BaseDomainInvariant<LoanState>(
"Due date must be after loan date",
(state) => new Date(state.dueDate) > new Date(state.loanDate),
);
full code here
Two things worth pointing at:
- The name is human-readable. When an invariant is violated, this is what shows up in the error so name it like you'd write it in the spec, not like a code symbol.
- The predicate returns
boolean. True means "the rule holds for this state". False means "this state is illegal".
The second invariant is almost as short, with a small twist: if returnedAt is null (the book is still out) the rule obviously doesn't apply, so we return true.
// src/domain/entities/loan/invariants/returnDateAfterLoanDate.invariant.ts
export const returnDateAfterLoanDate = new BaseDomainInvariant<LoanState>(
"Return date must be after loan date",
(state) =>
state.returnedAt === null ||
new Date(state.returnedAt) >= new Date(state.loanDate),
);
Wiring invariants to the entity
Here's where Ontologic's DomainEntity earns its keep. The base class accepts an array of invariants as its third constructor argument. We pass the list once, in the Loan's private constructor, and then we never have to think about them again:
import { DomainEntity } from "ontologic";
import { dueDateAfterLoanDate } from "./invariants/dueDateAfterLoanDate.invariant";
import { returnDateAfterLoanDate } from "./invariants/returnDateAfterLoanDate.invariant";
export class Loan extends DomainEntity<LoanState> {
private constructor(id: string, state: LoanState) {
super(id, state, [dueDateAfterLoanDate, returnDateAfterLoanDate]);
}
// ...static create, returnBook, etc.
}
That single line is the whole contract: any Loan instance will guard these two rules for the rest of its life in memory.
Ontologic also exposes an
addInvariant(invariant)method onDomainEntity, so you can register an invariant after construction if you ever need to. A feature-flagged rule, an invariant whose threshold comes from runtime config, or one that's only relevant in some sub-types. For the common case the constructor list is enough and reads more clearly, but it's good to know the option exists.
When do invariants fire?
Every time the entity's state is read through readState(), the registered invariants run. If any predicate returns false, readState() throws — the library's default message is something like "Corrupted state detected", plus the name of the invariant that broke.
That might sound aggressive, and it is on purpose. An invariant violation is not a user error. It is not "the member tried to borrow too many books" that would be a domain error, handled with Result.
An invariant violation means that somehow, somewhere, we produced a Loan with a corrupted state. There is no sensible business response to that. The only safe thing is to stop the current operation, log loudly, and page someone.
That distinction is worth internalizing:
Domain error (via
Result+DomainError): an expected failure from a user action. Callers handle it and usually map it to a 4xx response.LoanAlreadyReturnedErroris like that.Invariant violation (via a thrown corruption): a bug. Callers don't "handle" it — they let it bubble up to the top-level error handler, which logs, alerts, and returns a 5xx.
Designing the domain with this split means the happy path has no exception handling for expected failures, and the unexpected failures make noise at exactly the right layer.
Not every rule belongs on the entity
Look back at the business rules we listed in Part 1:
- One book, one active loan.
- Lost books cannot be lent.
- A member may have at most three concurrent active loans.
- Dates have to make sense on a loan.
Only the last one fits cleanly on the Loan entity it's a property of a single loan's state. The first three all need more than one thing to decide: you need to know about other open loans, or you need to know about the Book's lost status.
Take the three-loans cap. You can't answer "does this member have three active loans?" by looking at the Loan you're about to create. You have to ask the repository. And the repository is infrastructure it doesn't belong inside a DomainEntity.
So where does that rule live? On a use case: in the application-layer that coordinates entities and repositories to perform one business action. The domain error for it already has a home in loan.errors.ts:
export class MemberActiveLoanLimitExceededError extends DomainError<
"MEMBER_ACTIVE_LOAN_LIMIT_EXCEEDED",
{ memberId: string; limit: number }
> { /* ... */ }
We're not going to write that use case in this article it's the subject of the next one. For now, the important thing is the split:
- Invariants live on the entity and guard what must be true about this one thing.
- Business rules that coordinate multiple entities live in use cases.
Getting that split right keeps entities small and testable, and prevents the application layer from becoming a grab bag of validation.
Wrapping up
We modeled the Loan as a DomainEntity with its own state, its own creation event, and a single command returnBook that returns a typed Result.
Then we pulled two date rules out of scattered validation and into invariants, registered once in the constructor. Those rules now hold regardless of how a Loan was produced: through create, through rehydration from the database, or through any future code path.
Finally we marked the line between what belongs on the entity and what belongs in a use case. The former protects this thing; the latter coordinates many things.
Let me know in the comments if you've used invariants in your own codebases, how you decide which rules deserve to live on the entity, and what kind of rules you'd have added to the Loan. And tell me if the next article, about the use case that enforces the three-loans-per-member rule, would be interesting for you.
Where to see it in the repo
The Loan entity lives in src/domain/entities/loan/loan.entity.ts. The two invariants are in src/domain/entities/loan/invariants/, the events in events/, and the domain errors in errors/loan.errors.ts.
This article was originally published by DEV Community and written by Sacha Clerc-Renaud.
Read original article on DEV Community