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.

Watch it evolve1 / 10
The problem

A cafeteria account

Account holds a balance, a daily limit and what was spent today. Each service checks what it remembers to check.

80
Start
Stage 1
Stage 2
Final

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.

The account and the till's serviceJava
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;
    }
}
Account− student: String− dailyLimitCents: int− balanceCents: int− spentTodayCents: int~ getStudent(): String~ getDailyLimitCents(): int~ getBalanceCents(): int~ setBalanceCents(int)~ getSpentTodayCents(): int~ setSpentTodayCents(int)TillService~ pay(Account, int): StringKioskService~ pay(Account, int): String

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:

The kiosk's serviceJava
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.

Java
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;
    }
}
Money~ format(int): StringAccount− student: String− dailyLimitCents: int− balanceCents: int− spentTodayCents: int~ student(): String~ balanceCents(): int~ spentTodayCents(): int~ pay(int)TillService~ pay(Account, int): StringKioskService~ pay(Account, int): String

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
*Account− student: String− dailyLimitCents: int− balanceCents: int− spentTodayCents: int~ student(): String~ balanceCents(): int~ spentTodayCents(): int~ pay(int)PaymentService− accounts: Map<String, Account>− payments: List<String>~ open(Account)~ pay(String, int): String~ spentToday(String): int~ paymentCount(): intTill− payments: PaymentService~ charge(String, int)Kiosk− payments: PaymentService~ charge(String, int)

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: Account.pay() decides

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 if about 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 Transfers holds 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; const accessors

JavaScript

#private fields, so outside code can't write the balance

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

1. The school adds an online top-up page that also lets students buy meal credits. In the final version, how does it get the daily limit?
2. Which of these belongs in the service layer rather than the domain model?
3. Why is an anemic domain model called an anti-pattern rather than just a simple design?
4. Money moves from a parent's wallet to a student's account. Where does that rule go?

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.