Low-level designCore7 min
Responsibilities and collaborations
Every class has responsibilities, things it knows and things it does, and collaborators it asks for help. Give each job to the class that has the information to do it, and tell objects what to do instead of pulling their data out.
A courier prices parcels
Billed weight is the larger of actual and volumetric weight. The price is the zone's base fee plus a rate per kilogram.
1 · The situation
A courier's counter prices parcels
A courier prices each parcel by its billed weight: the larger of what it weighs and its volumetric weight, length × width × height in centimeters divided by 5,000, rounded up to a whole kilogram. A large, light box is billed for the space it takes. The price is then the zone's base fee plus a rate per kilogram.
The first version keeps the data in plain classes, Parcel and Zone, with a getter per field, and puts the rule in a QuoteService that reads what it needs. Python writes the names in snake case, and reads the attributes directly.
class Parcel {
private final String id;
private final int grams;
private final int lengthCm;
private final int widthCm;
private final int heightCm;
Parcel(String id, int grams, int lengthCm, int widthCm, int heightCm) {
this.id = id;
this.grams = grams;
this.lengthCm = lengthCm;
this.widthCm = widthCm;
this.heightCm = heightCm;
}
String id() {
return id;
}
int grams() {
return grams;
}
int lengthCm() {
return lengthCm;
}
int widthCm() {
return widthCm;
}
int heightCm() {
return heightCm;
}
}
class Zone {
private final String name;
private final int baseCents;
private final int centsPerKg;
Zone(String name, int baseCents, int centsPerKg) {
this.name = name;
this.baseCents = baseCents;
this.centsPerKg = centsPerKg;
}
String name() {
return name;
}
int baseCents() {
return baseCents;
}
int centsPerKg() {
return centsPerKg;
}
}
class QuoteService {
// Billed weight: the larger of actual and volumetric (L x W x H / 5000), in whole kilograms.
int quoteCents(Parcel p, Zone z) {
int volumetricGrams = p.lengthCm() * p.widthCm() * p.heightCm() / 5;
int kg = (Math.max(p.grams(), volumetricGrams) + 999) / 1000;
return z.baseCents() + z.centsPerKg() * kg;
}
}Drawn from the code: Parcel and Zone only answer questions; QuoteService uses both for the length of one call.
A to Local: $10.00 B to National: $19.60 A: 5 kg billed B: 7 kg billed
Parcel A weighs 2 kg but takes the space of 4.8, so it's billed 5. Parcel B is small and heavy, so it's billed its real weight, rounded up to 7.
2 · The change
The rule gets copied, then changed
The counter starts printing labels, and each label shows the billed weight. The rule lives in QuoteService.quoteCents(), inside a calculation that also prices the parcel, so LabelPrinter gets its own copy:
class LabelPrinter {
// The label shows the billed weight, so the rule is repeated here.
String label(Parcel p) {
int volumetricGrams = p.lengthCm() * p.widthCm() * p.heightCm() / 5;
int kg = (Math.max(p.grams(), volumetricGrams) + 999) / 1000;
return p.id() + ": " + kg + " kg billed";
}
}Then the courier changes the divisor from 5,000 to 4,000, so bulky parcels cost more. The change has to be made in two classes. Miss one, and the label says 5 kg while the invoice charges for 6, and nothing fails until a customer complains.
Places that hold the billed-weight rule, and must all change when it does.
The cause is where the rule lives. It's about a parcel and uses nothing but the parcel's own data, yet Parcel can't compute it, so every class that needs it reaches in for four values and works it out. The practice checks flag both halves: Parcel only holds data, and QuoteService.quoteCents() and LabelPrinter.label() mostly work on a Parcel.
3 · The fix
Give each job to the class with the information
Ask, for each piece of the calculation, which class already has the data it needs, and move the piece there:
The billed weight needs the weight and the three sides, all of them a parcel's own fields. Parcel.billedKg() computes it, and the getters for the sides go away: nobody needs them now.
class Parcel {
private final String id;
private final int grams;
private final int lengthCm;
private final int widthCm;
private final int heightCm;
Parcel(String id, int grams, int lengthCm, int widthCm, int heightCm) {
this.id = id;
this.grams = grams;
this.lengthCm = lengthCm;
this.widthCm = widthCm;
this.heightCm = heightCm;
}
String id() {
return id;
}
// The larger of actual and volumetric (L x W x H / 5000) weight, in whole kilograms.
int billedKg() {
int volumetricGrams = lengthCm * widthCm * heightCm / 5;
return (Math.max(grams, volumetricGrams) + 999) / 1000;
}
}A to Local: $10.00 B to National: $19.60 A: 5 kg billed B: 7 kg billed
The same prices and labels as before; the divisor now changes in one place, Parcel.billedKg().
4 · Responsibilities
What a class knows, does, and asks of others
A responsibility is an obligation a class takes on: something it knows (a parcel knows its id) or something it does (a parcel computes its billed weight). A collaborator is another class it asks for help to meet them. Writing these down, one small card per class, is a quick way to test a design before any code: each card has the class's name on top, its responsibilities on the left and its collaborators on the right.
Class | Responsibilities | Collaborators |
|---|---|---|
| Knows its id; works out its billed weight | None |
| Knows its name; prices a billed weight | None |
| Quotes a parcel to a zone |
|
| Writes a parcel's label |
|
These are CRC cards (class, responsibilities, collaborators), written as a table. Their use is the conversation: take a scenario, such as "quote parcel B to the national zone", and walk it through the cards. A card that collects too many responsibilities, or a responsibility no card can meet without reaching into another, shows up within minutes.
5 · Tell, don't ask
Ask for results, not for data
The first version asked: it pulled four values out of the parcel and decided what they meant. The second tells, or more exactly asks for a result: p.billedKg(). The difference decides where the rule lives. When callers pull data out, every caller holds a copy of the rule; when they ask for the result, only the owner does.
This is a guide, not a ban on getters. Parcel.id() stays, because a label must show the id and showing it isn't a rule. A getter is a warning sign only when callers use what it returns to make a decision that the object could make itself.
One quote after the fix: two questions, each answered by the object that knows.
6 · Roles
Common shapes of responsibility
Most classes play one of a few recognizable roles. Naming the role helps decide what belongs in a class and what doesn't:
Role | Its job | In the courier |
|---|---|---|
Information holder | Knows facts, and answers questions that use them |
|
Coordinator | Asks other objects for their parts and combines them |
|
Interfacer | Turns information into a form for the outside world |
|
Controller | Decides what happens next in response to events | The counter screen, not shown |
Not every rule fits inside one object. A price needs the parcel and the zone, and neither holds both, so a coordinator asks each for its part. That's still the same principle: each piece of the rule sits with its data, and the coordinator only connects them. Formatting stays out of Parcel for the same reason the page Single responsibility principle gives: a label changes for different reasons than weight rules do.
7 · In practice
Who finds the spot, who prices the stay
In the parking-lot problem, finding a free spot needs every level's spots, and pricing a stay needs the ticket's times and the rates. Give each job to the class that has that information. The checks flag classes that only hold data while another class does their work, and methods that mostly use another object's data.
Design the parking lot and assign its responsibilities.
Check yourself
4 questions
Takeaways
Remember this
- A responsibility is something a class knows or does; collaborators are the classes it asks for help.
- Give a job to the class that has the information to do it.
- Ask objects for results, not for their data; a rule computed by callers ends up copied.
- Getters are fine for showing data; they're a smell when callers use them to make the object's decisions.
- When a rule needs several objects, each answers its own part and a coordinator combines them.