Low-level designCore9 min
Domain model and service layer
Put business rules on the objects whose data they protect, so every caller gets them. A thin service layer on top finds the objects, asks them to act, and records the result, without holding rules of its own.
A cafeteria account
Account holds a balance, a daily limit and what was spent today. Each service checks what it remembers to check.
1 · The situation
A cafeteria account with a daily limit
A school's cafeteria runs on prepaid accounts. Parents top up an Account and set a daily limit, so a student can spend at most $8.00 a day. The till at the counter charges a student through TillService, which checks the balance and the daily limit and then updates the account.
Account itself is a record of numbers with a getter and setter for each, the shape many codebases start with. In JavaScript and Python the same shape is plain public fields and a dataclass. A small Money helper formats cents as dollars.
class Account {
private final String student;
private final int dailyLimitCents;
private int balanceCents;
private int spentTodayCents;
Account(String student, int balanceCents, int dailyLimitCents) {
this.student = student;
this.balanceCents = balanceCents;
this.dailyLimitCents = dailyLimitCents;
}
String getStudent() { return student; }
int getDailyLimitCents() { return dailyLimitCents; }
int getBalanceCents() { return balanceCents; }
void setBalanceCents(int cents) { balanceCents = cents; }
int getSpentTodayCents() { return spentTodayCents; }
void setSpentTodayCents(int cents) { spentTodayCents = cents; }
}
class TillService {
String pay(Account account, int cents) {
if (account.getBalanceCents() < cents) return "refused, balance too low";
int limit = account.getDailyLimitCents();
if (account.getSpentTodayCents() + cents > limit) {
return "refused, daily limit " + Money.format(limit) + " reached";
}
account.setBalanceCents(account.getBalanceCents() - cents);
account.setSpentTodayCents(account.getSpentTodayCents() + cents);
String balance = Money.format(account.getBalanceCents());
return "paid " + Money.format(cents) + ", balance " + balance;
}
}The class diagram of the first version, drawn from its code: the account holds data, and each service holds the rules.
2 · The change
A snack kiosk, and a limit it doesn't know
The school adds a snack kiosk by the gym. Its developer writes KioskService the same way, checking the balance before updating the account. The daily limit lives in TillService, and nothing about Account says it exists:
class KioskService {
String pay(Account account, int cents) {
if (account.getBalanceCents() < cents) return "refused, balance too low";
account.setBalanceCents(account.getBalanceCents() - cents);
account.setSpentTodayCents(account.getSpentTodayCents() + cents);
String balance = Money.format(account.getBalanceCents());
return "paid " + Money.format(cents) + ", balance " + balance;
}
}Mia buys lunch twice at the till, then a snack at the kiosk:
Till: paid $4.50, balance $15.50 Till: paid $3.00, balance $12.50 Kiosk: paid $2.50, balance $10.00 Spent today: $10.00
Mia spends $10.00 in a day her parents limited to $8.00, and every line says paid.
Nothing is wrong with the kiosk's code on its own. The problem is where the rule lives. Any code that holds an Account can change its balance with a setter, and only the code that remembers the rule enforces it:
- Each caller repeats the rules. A third front end, such as an online top-up page, would need its own copy.
- The copies drift. One service is updated when the limit rule changes, and another isn't.
- The account can't protect itself.
setBalanceCents(-500)is accepted from anywhere.
The practice checks give three hints: Account only holds data, and TillService.pay and KioskService.pay each mostly work on Account.
3 · The fix
Move the rules onto the account
Start from the first version. The services keep asking the account for numbers and doing arithmetic on them. That's the account's work, so move it there, then give both front ends one place to go:
Account.pay() checks the balance and the daily limit and updates both totals, or throws with the reason. The setters are gone: nothing outside the account can change its balance any other way.
class Account {
private final String student;
private final int dailyLimitCents;
private int balanceCents;
private int spentTodayCents;
Account(String student, int balanceCents, int dailyLimitCents) {
this.student = student;
this.balanceCents = balanceCents;
this.dailyLimitCents = dailyLimitCents;
}
String student() { return student; }
int balanceCents() { return balanceCents; }
int spentTodayCents() { return spentTodayCents; }
void pay(int cents) {
if (balanceCents < cents) throw new IllegalStateException("balance too low");
if (spentTodayCents + cents > dailyLimitCents) {
String limit = Money.format(dailyLimitCents);
throw new IllegalStateException("daily limit " + limit + " reached");
}
balanceCents -= cents;
spentTodayCents += cents;
}
}After the first step the kiosk refuses the snack. After the last, the service layer also counts the payments it recorded:
Till: paid $4.50, balance $15.50 Till: paid $3.00, balance $12.50 Kiosk: refused, daily limit $8.00 reached Spent today: $7.50 Payments recorded: 2
Front ends on top, one service layer, and the rules in the account.
4 · The idea
A domain model, and a thin layer of services
A domain model is a set of objects that represent the business and carry both its data and its rules. Account is one now: it knows its balance and limit, and pay() is the only way to spend.
Its opposite is an anemic domain model: objects with fields, getters and setters, and every rule in service classes that read and write them. It looks object-oriented, but the code is procedural, with data in one place and behavior in another.
A service layer is the set of operations the application offers, such as "charge a student" or "top up an account". Each operation coordinates the work: find the objects, ask them to act, record or save the result, and send any notifications. It stays thin because the rules live in the domain objects it calls.
Question | Domain model | Service layer |
|---|---|---|
Can Mia spend $2.50 now? | Yes: | No |
Which account is Mia's? | No | Yes: it finds it |
Record the payment, in one transaction | No | Yes |
Format the answer for a screen | No | No: the front end does |
The service coordinates; the account decides.
5 · When breaking it is fine
Simple data, and scripts that are enough
A rich model costs effort. Objects with private state need a way to be saved and loaded, and every new rule asks which object should own it. When there are few rules, simpler shapes work:
- Transaction script. One procedure per operation, reading and writing data directly. For an app that mostly stores and lists records, it's shorter and just as clear.
- Data carriers at the edges. Objects that only move data across a boundary, such as a request body or an API response, are meant to be plain data. They're not a domain model and don't need behavior.
The application | A good fit |
|---|---|
Mostly forms that save and list records | Transaction scripts, or plain data with simple services |
Rules that several operations must all respect | A domain model, with a thin service layer |
Data passing through an API | Plain data objects at the boundary, mapped to the domain inside |
6 · Getting it right
Tell the object, don't ask for its numbers
- Tell, don't ask.
account.pay(250)instead of reading the balance and the limit and deciding outside. - No setters for fields a rule guards. If a balance must never go below zero, the only ways to change it are methods that check.
- Keep the service layer free of rules. If a service has an
ifabout the business, ask which domain object should own it. - Some rules span objects. Moving money between two accounts belongs to neither alone; a domain service such as
Transfersholds it, and still lives in the domain, not in the service layer.
Language | Guarding the account's state |
|---|---|
Java | Private fields and methods that check; no setters |
C++ | Private members and member functions; |
JavaScript |
|
Python | Underscore names by convention; nothing stops a determined caller, so the methods are the documented way in |
7 · Look-alikes
Kinds of services, and models that hold no rules
Several names come up around this pattern. What tells them apart is where the rules live.
Idea | Where the rules live |
|---|---|
Domain model | In the objects that represent the business |
Anemic domain model | In services; the objects only hold data |
Transaction script | In one procedure per operation, with no domain objects to speak of |
Service layer (application service) | Nowhere: it coordinates the operation and calls the domain |
Domain service | In a domain class for a rule that belongs to no single object |
Data transfer object | Nowhere: it only carries data across a boundary |
An anemic model and a transaction script put the rules in the same place. The difference is that the anemic model also builds a set of objects that look like a domain model, so it pays for both and gets the benefits of neither.
8 · In practice
Hints from the checks
The practice checks give two hints that point here. "Account only holds data" means a class has only getters and setters while another class does the work on its data. "TillService.pay mostly works on Account" means a method calls more of another class's methods than its own.
Both are hints, not errors: a data carrier at the edge is meant to hold only data. When the data has rules, move them next to it. In the parking-lot problem, a spot that decides what fits it, and a ticket that knows its own entry time, are the domain model; the lot coordinates them. Design the parking lot.
Check yourself
4 questions
Takeaways
Remember this
- A domain model's objects carry both data and the rules that guard it, so every caller gets the rules.
- An anemic model keeps objects as data bags and puts rules in services, where they get copied and drift.
- A service layer is the application's set of operations: it finds objects, calls them, and records results, with no rules of its own.
- Tell objects what to do instead of asking for their numbers; remove setters for fields a rule guards.
- For simple record keeping a transaction script is fine; data carriers at the edges are meant to be plain data.