Rich domain modelling: a library story

java dev.to

Most software doesn't have a domain model. It has a database schema, a set of service classes that orchestrate calls to it, and a collection of user stories that have been implemented one by one, each leaving a small deposit of logic somewhere convenient. This works, until it doesn't — until a framework needs replacing, a regulation changes, or someone asks a question the system was never quite designed to answer, and the answer turns out to be scattered across fourteen service methods and three database joins.

This article is about a different approach, illustrated through a deliberately simple example: a library system. The example is old-fashioned on purpose. The familiarity lets you focus on the reasoning, not the subject matter.

The core argument is this: a rich domain model is not something you design once at the start of a project and then implement. It is something you grow, continuously, as your understanding of the business deepens. Every requirement, every refinement session, every new user story is not just a work order — it is new information about the domain. The question to ask at each step is not "how do we implement this?" but "does this change what we understand the domain to be?"

If the answer is yes, the model changes. Not in a future story. Not as tech debt. Now. The implementation timeline is not sacred. The correctness of the domain is. The cost of a misaligned domain compounds over time — it gets into every new feature, every workaround, every "we can't easily change that" conversation. A missed sprint to correct the model is almost always cheaper than six months of working around a wrong abstraction.

The other side of this is: you only model what you understand. If something is unclear, that is not a reason to guess at an abstraction — it is a reason to ask more. Refinement sessions exist precisely for this. The domain expert knows things the model doesn't yet reflect. The job is to close that gap, incrementally, with each new piece of understanding.

That is what this article shows. Not a perfect model arrived at in one go, but a model that starts where the knowledge starts, and adapts as the knowledge grows.


User story 1: "We want to lend out books"

The first conversation with the domain expert goes predictably. The library wants to lend books. They want to know where each book is — on which shelf, or on loan to whom, from when until when.

From this, the initial domain objects emerge: Book, Lender, and somewhere, the loan dates. And this last point — where do the loan dates live? — is the first real decision.

The path of least resistance puts them in Book. The book knows where it is; if it's on loan, it knows to whom and for how long. It seems natural. But pause here, because this is the decision that will constrain everything that follows.

Ask a simple domain question: is knowing when it was borrowed, and by whom, part of what a book is? A book is a title, an author, a physical object. The loan is an event — an agreement between the library and a person, at a point in time, concerning that book. Two different things. Putting loan dates in Book is the same category of error as storing someone's employment history in their passport: adjacent subjects stitched together because it was convenient.

There is also a practical problem that makes the conceptual one concrete: a book can be borrowed many times, by different people, at different points in time. A single set of loan fields cannot represent that history without overwriting it. The model isn't just conceptually imprecise — it is structurally incapable of answering basic questions the business will eventually ask.

The first model, with its warning signs visible:

Recognising the problem, a Loan entity is introduced. It points to a book and a lender, and carries its own data: start date, end date, and a return date for when the item actually comes back.

Book is clean. Each entity is responsible for what it actually is.


Emergent behaviour: what the model now gives you for free

Here is something worth making explicit, because it tends to get overlooked.

When the domain is modelled correctly, it doesn't just solve the problem at hand — it makes available capabilities that nobody wrote a story for.

With Loan as a first-class entity, the model now contains the answers to questions like:

  • How many times has this book been borrowed in the last year?

  • Is it borrowed back-to-back — should we order a second copy?

  • Which items are overdue right now?

  • Which lender has the most active loans?

No one asked for any of this. And more importantly, no one needs to change the model to support it. These questions are answerable as a natural consequence of the right abstraction — zero additional structural cost. This is what correct domain modelling produces: not just a solution to the stated requirement, but a foundation that doesn't resist future questions.

The opposite — loan dates buried in Book — means that every one of those questions requires working around an accidental constraint. The data is there, technically, but it is in the wrong place conceptually, and that mismatch has a cost that accumulates with every new question the business wants to ask.

A correct abstraction doesn't just solve the current problem. It shapes every solution that follows.


User story 2: "We also want to lend out DVDs"

A new requirement arrives. The library wants to lend DVDs too.

On most teams, this is treated as a work order. There is now a DVD entity. Fields are defined — title, director, runtime. The ticket is closed.

This is precisely the failure mode the introduction described: a user story implemented rather than understood. The arrival of this requirement is not an instruction to add DVD. It is new information about the domain. And new information about the domain means it is time to re-examine the model.

The question is not "how do we add DVD?" The question is: was Book ever the right abstraction for this domain?

Think about what the lending system actually cares about. It doesn't care that a book has pages or that a DVD has a runtime. From the perspective of the lending domain, both are things that can be borrowed, returned, and tracked. If you add a DVD entity you are not modelling the lending domain — you are modelling a classification detail that the domain does not act on. And the next story will bring magazines. Then tools. Then a request that breaks the pattern entirely, and by then there are four parallel entity types, duplicated service logic, and a reporting layer full of unions.

The correct response to this user story is not implementation. It is evaluation. And the evaluation reveals that the concept the domain actually needs is not Book — it is a lendable item. Something that can be borrowed, regardless of what it is.

Modelling the domain, not the world

This is the point where a common objection appears: isn't LendableItem with a generic attribute collection just an EAV pattern with a different name? Isn't it losing type safety? Isn't it too abstract?

These are implementation concerns, not domain concerns. And that distinction matters enormously.

A book and a DVD are genuinely different things in the real world. They have different physical forms, different metadata, different cultural contexts. But the domain model is not a model of the real world. It is a model of how the business operates. And in the lending domain, a book and a DVD are the same thing: an item that can be lent to a person for a period of time, tracked, and returned. The domain acts on that concept. It does not act on the distinction between pages and runtime.

The risk in domain modelling is not abstraction. The risk is the wrong abstraction — and the most common wrong abstraction is modelling the real world instead of the business domain. When that happens, the model fills up with concepts that feel correct because they match physical reality, but that the business never actually operates on as distinct things. Book and DVD as separate domain entities is that mistake. The library doesn't lend books and DVDs differently. It lends items.

LendableItem is not generic for the sake of flexibility. It is precise — precisely what the domain requires.

This is not overengineering. Starting with Book was correct — at the time, only books existed, and naming the concept after the only known instance of it is entirely reasonable. Good domain modelling does not demand abstraction before there is evidence for it. But when the evidence arrives, the model must respond.

The revised model:

Book becomes LendableItem. The type — book, DVD, magazine, whatever comes next — is an ItemType instance defined in data, not in code. Each ItemType carries the attribute definitions relevant to it: a book has ISBN and author; a DVD has runtime and director. The LendableItem holds the attribute values as a key-value collection shaped by the ItemType — not arbitrary data, but controlled variation. A new lendable type can be defined through the UI, without a software release. The domain absorbs the variation without being touched.

Notice what also appears here: LendPolicy. Lending rules — how long something can be borrowed, whether it can be renewed — are not properties of items. They are policies, and policies have their own identity. A 7-day loan period might apply to all DVDs, a 21-day period to most books, and a specific rare edition might carry its own exception — all configurable, without code changes. By modelling LendPolicy as an entity that points to items rather than belonging to them, the granularity becomes a business decision. The domain reflects it correctly.


What this example is really about

Three things are worth naming directly.

The domain is not a one-off. The biggest misconception about domain modelling is that it happens at the start of a project, produces a diagram, and is then finished. In practice, a domain model is only as good as the understanding that produced it. Understanding grows — through refinement sessions, through new requirements, through conversations with domain experts who reveal nuance the model doesn't yet capture. Every one of those moments is an opportunity to improve the model. Treating them as implementation tickets instead is how misalignment accumulates.

Correctness compounds. A wrong abstraction doesn't just cause one problem. It causes every problem that grows on top of it. When the framework needs replacing five years from now, the core business logic should be the stable thing — the part that doesn't change because it correctly reflects the domain. If the logic has leaked into service methods, database queries, and framework-specific glue, the framework and the logic are inseparable. A rich domain model is what makes the core of the application resilient to the things around it changing.

User stories are input, not instructions. "We want to lend DVDs" is not a specification. It is a piece of information about the business. The correct response is to understand what it reveals about the domain, and let that understanding reshape the model if necessary. On teams where user stories are treated purely as work orders, DVD gets added, the ticket is closed, and the model silently drifts further from reality. On teams where user stories are treated as domain conversations, the arrival of DVD prompts the question that leads to LendableItem — and the system becomes more correct, not just more complete.


A note on SOLID

This article has used two principles from SOLID without naming them. It is worth naming them now — not to add jargon, but because these principles are widely known and almost as widely misunderstood, and the library example shows exactly what they were designed for.

SOLID is a tool for domain modelling. Applied to technical layers — controllers, services, repositories, packages — it is the wrong tool for the job. Not because it produces nothing useful there, but because it is answering questions that belong to a different space. Asking whether your BookService violates the Single Responsibility Principle is like applying flight-route optimisation to a city street map. You will get answers. They will be coherent. They will just not be answers to the right question. The right question is always about the domain.

When SOLID is applied only at the technical layer, the domain model is typically left untouched — a set of anemic objects with no real behaviour — while all the interesting decisions accumulate in a service class that nobody can coherently describe the responsibility of. The system is, in a narrow sense, well-structured. It models nothing.

The uncomfortable truth this produces is worth stating plainly: you can apply SOLID perfectly and still end up with a system that does not model the business. The principles do not tell you what to model. They evaluate whether what you have modelled makes sense. If what you have modelled is technical structure rather than domain concepts, SOLID will faithfully validate that structure — and the domain will remain a mess.

Applied to the domain, the principles are genuinely illuminating.

Single Responsibility Principle is what drove the BookBook + Loan split. The question it asks is not "does this class do too many technical things?" It asks: does this concept carry responsibility that belongs to a different concept? A book is not responsible for knowing when it was borrowed. That is the responsibility of the loan event. One domain question, one correct answer, one new entity. Applied at the domain level, SRP produces clean, stable concepts with clear boundaries. Applied only at the technical level, it tends to produce BookHelper, BookManager, and BookUtil — classes that exist to split code rather than to model anything.

Open/Closed Principle is what drove the Book + DVDLendableItem + ItemType move. The principle says a model should be open for extension but closed for modification. In domain terms: when new kinds of things appear, the model should absorb them without requiring existing concepts to change. A DVD entity requires a code change and a deployment every time a new item type is introduced. LendableItem with ItemType instances defined in data requires neither — the model is extended through configuration. The domain is open for new item types and closed against needing to touch LendableItem to accommodate them.

The remaining principles have domain equivalents too. But the point here is not to survey all five — it is to show that SOLID belongs in the domain conversation. Bringing it into the technical conversation is not a sequencing problem — it is a category problem. The principles ask domain questions. Technical layers are not a domain. The questions do not apply. It's like applying makeup to a horse. It works but the results have no benefit.


The model should always reflect the best current understanding of the domain. When that understanding changes, the model changes with it. Not later. Now.

Source: dev.to

arrow_back Back to Tutorials