Where the verb sits
Entity vs service responsibilities
A domain service holds behavior that does not naturally belong on a single entity or value object. Example: who calculates the parking fee? Ticket knows the times. PricingPolicy knows the rate. Neither should swallow the other. A small domain service can ask both at exit. The failure mode is a ParkingLotService that parks, prices, emails, and writes SQL.
Application service
Use case. Thin. Loads and saves.
park(), exit()
Domain service
Spans two objects plus a policy.
PriceStay, TransferBalance
Entity / value
Has the data. Owns the local rule.
Spot.occupy(), Money.plus()
Repository
Memory. Not the model.
save, findById
A useful test for placement
Put on the entity when the entity has the data. Use a when the work spans more than one entity and those entities should not depend on each other. Keep the even thinner. That is the use-case entry: park, exit. It loads, calls the domain, saves. If ParkingService contains allocation, GST, persistence, and SMS, you did not split entity from service. You renamed the God object.
If the method would still make sense with only this object’s fields, it belongs on the object. If it needs two aggregates plus a policy, it belongs on a domain service. If it needs HTTP, it belongs outside the domain. A is how you remember. It is not how you occupy a spot.
PriceStay coordinates. Ticket still owns times.
Ticket can answer duration because it has entry and a clock reading. It should not know airport surcharges. PricingStrategy should not know ticket status. PriceStay — or exit on a thin application service — asks both.
final class PriceStay {
private final PricingStrategy pricing;
PriceStay(PricingStrategy pricing) {
this.pricing = pricing;
}
Money quote(Ticket ticket, Instant now) {
Duration parked = ticket.duration(now);
return pricing.feeFor(parked, ticket.vehicle());
}
}
class ExitParking {
private final TicketRepository tickets;
private final ParkingLot lot;
private final PriceStay priceStay;
Receipt exit(TicketId id, Instant now) {
Ticket ticket = tickets.findById(id).orElseThrow();
Money fee = priceStay.quote(ticket, now);
ticket.pay(fee);
lot.release(ticket.spotId());
ticket.close();
tickets.save(ticket);
return new Receipt(id, fee);
}
}ParkingLotService contains the system
A service that allocates, prices, persists, and notifies is a Manager with a fashionable name. Split the verbs again. Allocation is a strategy. Occupancy is the spot. Persistence is the repository. Notify is outside.
Empty entities, all verbs in services
If Ticket is only getters, every rule lives far from the data. That is an anemic model, not “clean architecture.” Services then disagree about when pay is legal.
When a domain service is the right size
- TransferBalance needs two wallets and must not let Wallet import Wallet.
- MatchRide needs a rider request and a set of drivers plus a scoring policy.
- SettleGroup in Splitwise needs several balances and a minimization policy.
- PriceStay needs a ticket’s times and a policy the ticket should not swallow.
Each of those is a handful of lines that call rich objects. None of them is “the application.” If you cannot name the two things being coordinated, you probably wanted a method on one entity. That instinct is put behavior where the data lives.
Application services stay boring
park and exit look like “the design” because they are the story the product tells. In code they should be dull: load, decide, save, maybe publish an event. The interesting rules sit lower. Interviewers who have seen a 400-line ParkingLotService are waiting to see whether you can keep exit under a screenful without emptying Ticket.
GRASP Controller is this layer with an older name. You do not need the name to use it. You need the discipline: the controller starts the use case. It does not become the model. Read GRASP and responsibility assignment when you want the vocabulary.
Transfer is a service. Debit is not.
class Wallet {
private Money balance;
void debit(Money amount) {
if (balance.lessThan(amount)) throw new IllegalStateException("funds");
balance = balance.minus(amount);
}
void credit(Money amount) {
balance = balance.plus(amount);
}
}
final class TransferBalance {
void transfer(Wallet from, Wallet to, Money amount) {
from.debit(amount);
to.credit(amount);
}
}One sentence for the interviewer
“Occupy lives on Spot. Pay lives on Ticket. PriceStay is a domain service because ticket and policy should not depend on each other. Exit is a thin use case that loads, prices, pays, releases, and saves.” If that sentence is true in the code, you do not have a God service.
Ride matching spans two sides on purpose
A RiderRequest should not import a fleet. A Driver should not score every other driver. MatchRide reads both, applies a policy, and returns a pair. After the match, the request and the driver still own their own status transitions — accept, start, cancel. The service does not setStatus on either.
final class MatchRide {
private final ScoringPolicy scoring;
Optional<Driver> match(RiderRequest request, List<Driver> nearby) {
return nearby.stream()
.filter(d -> d.isAvailable() && d.serves(request.area()))
.max(Comparator.comparingInt(d -> scoring.score(request, d)));
}
}
class Driver {
void accept(RideId id) {
if (!isAvailable()) throw new IllegalStateException("busy");
this.currentRide = id;
}
}GST is a policy, not ExitParking
The follow-up “add tax” tempts people to grow the application service. Tax is a calculation on Money plus a rate table. Put it on a TaxPolicy or on Money.plus after a policy returns the tax line. ExitParking still loads, quotes, pays, releases, saves. If the receipt needs two lines, Receipt is a small value that holds them. The use case does not become an accountant.
Naming that keeps the layer honest
PriceStay, TransferBalance, MatchRide, SettleGroup — verbs that span. ParkingLotService, Manager, Processor, Handler — nouns that collect leftovers. If you cannot name the two things being coordinated, rename the class until you can, or delete it and put the method on the entity that already has the data.
Application services can keep use-case names: ParkVehicle, ExitParking, BookSeats. Those names are allowed to be a little dull. They are the product story. They should still be thin. The moment ExitParking computes GST line by line, you are missing a policy object.
SettleGroup is a service. Balance.debit is not.
Splitwise settlement looks at many balances and picks a set of transfers. No single User should import the whole group to do that. SettleGroup is the coordinator. Each Balance still refuses a negative if that is the rule. The service is allowed to be clever about who pays whom. It is not allowed to assign amounts through setters.
If SettleGroup grows email and SQL, split again. Persistence is the repository. Email is a notifier. The domain service stays a pure coordination of balances and a minimization policy you can test with a list of Money values.
A domain service that needs HTTP headers is in the wrong layer. Parse at the edge. Pass Money, TicketId, Instant. The service should be callable from a test with no servlet in sight.
Count the layers on the board
Four boxes: use case, domain service (only if needed), entity, repository. If you have one box called Service, you have not designed yet. If you have eight services and empty entities, you have the other failure. Aim for boring use cases and stubborn objects.