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.

Watch it evolve1 / 9
The problem

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.

80
Start
Stage 1
Final

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.

Data in two classes, the rule in a thirdJava
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;
    }
}
Parcel− id: String− grams: int− lengthCm: int− widthCm: int− heightCm: int~ id(): String~ grams(): int~ lengthCm(): int~ widthCm(): int~ heightCm(): intZone− name: String− baseCents: int− centsPerKg: int~ name(): String~ baseCents(): int~ centsPerKg(): intQuoteService~ quoteCents(Parcel, Zone): intLabelPrinter~ label(Parcel): String

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:

The same rule, a second timeJava
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.

Before
2
After
1

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.

Java
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;
    }
}
Parcel− id: String− grams: int− lengthCm: int− widthCm: int− heightCm: int~ id(): String~ billedKg(): intZone− name: String− baseCents: int− centsPerKg: int~ name(): String~ priceCents(int): intQuoteService~ quoteCents(Parcel, Zone): intLabelPrinter~ label(Parcel): String
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

Parcel

Knows its id; works out its billed weight

None

Zone

Knows its name; prices a billed weight

None

QuoteService

Quotes a parcel to a zone

Parcel, Zone

LabelPrinter

Writes a parcel's label

Parcel

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

Parcel, Zone

Coordinator

Asks other objects for their parts and combines them

QuoteService

Interfacer

Turns information into a form for the outside world

LabelPrinter

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

1. Where should the billed-weight calculation live?
2. QuoteService.quoteCents() calls four of Parcel's getters and none of its own fields. What does that suggest?
3. After the fix, Parcel.id() is still a getter. Is that a problem?
4. A price needs the parcel's billed weight and the zone's rates. Which design keeps each rule with its data?

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.