Behavior-First Mindset — Practice Tasks¶
Seven exercises that force you to design from verbs first and let the data fall out. Every starting point below is a struct in disguise — your job is to grow real objects out of them.
Task 1 — From OrderService to a real Order¶
public class Order {
public List<Line> lines;
public String status; // "NEW" | "PAID" | "SHIPPED" | "CANCELLED"
public BigDecimal total;
public Long customerId;
}
public class OrderService {
public void pay(Order o, PaymentMethod pm) {
if (!"NEW".equals(o.status)) throw new IllegalStateException();
// charge pm for o.total ...
o.status = "PAID";
}
public void ship(Order o, Carrier c) {
if (!"PAID".equals(o.status)) throw new IllegalStateException();
// hand off to c ...
o.status = "SHIPPED";
}
public void cancel(Order o) {
if ("SHIPPED".equals(o.status)) throw new IllegalStateException();
o.status = "CANCELLED";
}
}
Objective. Move all rules into Order. OrderService should disappear entirely — callers send messages to the order directly.
Constraints. - No public fields. No setters. - No getter for status. Callers must not branch on status strings from the outside. - The state machine (NEW → PAID → SHIPPED, NEW → CANCELLED, PAID → CANCELLED) lives inside the order.
Acceptance. - [ ] OrderService is deleted from the codebase. - [ ] Calling order.ship(carrier) on an unpaid order throws a meaningful exception. - [ ] Calling order.cancel() after ship(...) throws. - [ ] Reading the public method list of Order reads like a story: place, pay, ship, cancel.
Bonus. Add refund() that only works on a PAID-but-not-yet-SHIPPED order. Don't add a getter to do it — the order itself decides whether refund is legal.
Task 2 — A subscription that knows its own lifecycle¶
public class Subscription {
public LocalDate startedOn;
public LocalDate endsOn;
public String plan; // "FREE" | "PRO" | "TEAM"
public boolean cancelled;
public int graceDays;
}
Callers everywhere do things like:
Objective. Refactor so a Subscription answers behavioral questions, not state questions. The rule "is this subscription currently entitling the user to PRO features?" must live in the subscription.
Constraints. - The class exposes operations like renew(Period), cancel(), upgradeTo(Plan), and a single behavioral query entitles(Feature). - No getEndsOn(), no isCancelled(), no getPlan(). If a UI needs to display the end date, expose a narrow summary() method that returns a small immutable view object. - Plan is an enum, not a string.
Acceptance. - [ ] No if (sub.something) branches survive at call sites. - [ ] Cancelled subscriptions still entitle the user during the grace window — and the test for that lives by exercising entitles(...), not by reading dates. - [ ] Adding a new plan (e.g. ENTERPRISE) requires editing only the subscription/plan files.
Bonus. Add pauseFor(Period). Decide whether entitles(...) returns false during a pause without leaking that a "paused" state exists.
Task 3 — Inventory that doesn't bleed its quantities¶
public class StockItem {
public String sku;
public int onHand;
public int reserved;
public int reorderThreshold;
}
// somewhere else:
if (item.onHand - item.reserved >= qty) {
item.reserved += qty;
} else {
throw new OutOfStockException();
}
Objective. Turn StockItem into an object that handles reservations, releases, and fulfillment itself. The arithmetic above must vanish from every caller.
Constraints. - Method set: reserve(int qty), release(int qty), fulfill(int qty), restock(int qty), plus one behavioral query: canReserve(int qty). - No getters for onHand or reserved. The only externally visible quantity is via a report() that returns an immutable snapshot — and report() is for a dashboard, not for branching logic. - reorderThreshold is an internal concern: the item itself raises a ReorderNeeded event (or returns one from a method) when crossing the threshold.
Acceptance. - [ ] No call site computes onHand - reserved. - [ ] Double-reserving the same physical unit is impossible by construction. - [ ] fulfill(qty) requires a prior matching reserve; otherwise it throws. - [ ] Calling restock(...) while items are reserved doesn't corrupt the reservation count.
Bonus. Support partial fulfillment: a reservation of 10 can be fulfilled as 6 + 4. The item — not the caller — keeps track of how much of a reservation remains.
Task 4 — A loan that decides whether it can disburse¶
public class Loan {
public BigDecimal principal;
public BigDecimal interestRate;
public int termMonths;
public String status; // "APPLIED" | "APPROVED" | "DISBURSED" | "CLOSED"
public List<Payment> payments;
public BigDecimal outstanding;
}
public class LoanCalculator {
public BigDecimal outstanding(Loan l) {
BigDecimal paid = l.payments.stream()
.map(p -> p.amount).reduce(BigDecimal.ZERO, BigDecimal::add);
return l.principal.add(/* interest */).subtract(paid);
}
public boolean canDisburse(Loan l) {
return "APPROVED".equals(l.status);
}
}
Objective. Eliminate LoanCalculator. The loan knows how to be approved, disbursed, paid, and closed. The "outstanding" amount is behavior, not a field.
Constraints. - outstanding must not be stored — it is computed by the loan whenever needed. - The state transitions APPLIED → APPROVED → DISBURSED → CLOSED are enforced inside the loan. - The loan exposes apply(...), approve(Approver a), disburse(Account into), recordPayment(Money m). The only query is isSettled().
Acceptance. - [ ] LoanCalculator is gone. - [ ] You cannot record a payment on an APPLIED loan. - [ ] You cannot disburse twice. - [ ] Closing happens automatically when the final payment is recorded — callers don't call close() themselves.
Bonus. Add late fees: when recordPayment is called past a scheduled date, the loan internally adjusts the schedule. No fee calculator class allowed.
Task 5 — A meeting-room reservation that polices itself¶
public class Reservation {
public Room room;
public Employee organiser;
public LocalDateTime start;
public LocalDateTime end;
public Set<Employee> attendees;
public boolean cancelled;
}
public class ReservationValidator {
public void validate(Reservation r) {
if (r.end.isBefore(r.start)) throw new IllegalArgumentException();
if (r.attendees.size() > r.room.capacity) throw new IllegalArgumentException();
if (Duration.between(r.start, r.end).toMinutes() < 15) throw new IllegalArgumentException();
}
}
Objective. Replace the validator-shaped procedure with a Reservation that cannot exist in an invalid state. Add behavior — extending, shortening, transferring ownership, inviting attendees — that respects those rules.
Constraints. - All invariants live in the constructor or in operations that change state. No external validator. - invite(Employee e) refuses if the room is at capacity. - extendBy(Duration d) refuses if the room is double-booked during the new window (the reservation is aware of its calendar context — pass a Calendar collaborator in if needed, but don't expose start/end to it). - No getStart(), no getEnd(). Display logic goes through a single displayLine() that returns a formatted string.
Acceptance. - [ ] Trying to construct a 5-minute reservation throws. - [ ] Inviting a 9th person to an 8-seat room throws. - [ ] After cancel(), extendBy(...) throws. - [ ] No code outside Reservation calculates Duration.between(start, end).
Bonus. Add transferTo(Employee newOrganiser) with the rule: only the current organiser may transfer, and not within 10 minutes of the start. The reservation knows the rule; the caller passes "who is asking".
Task 6 — A chess piece that knows its own moves¶
public class Piece {
public String type; // "KING" | "QUEEN" | "ROOK" | "BISHOP" | "KNIGHT" | "PAWN"
public String color; // "WHITE" | "BLACK"
public int file; // 0..7
public int rank; // 0..7
public boolean hasMoved;
}
public class MoveValidator {
public boolean isLegal(Piece p, int toFile, int toRank, Board b) {
switch (p.type) {
case "ROOK":
return p.file == toFile || p.rank == toRank;
case "BISHOP":
return Math.abs(p.file - toFile) == Math.abs(p.rank - toRank);
// ... and so on for every piece, all reading p.type and p.file/p.rank
}
return false;
}
}
This is the classic switch-on-type smell — a sign that behavior has been lifted out of the objects it belongs to.
Objective. Replace the giant switch with polymorphic pieces. Each piece type owns its move rules.
Constraints. - Piece is an interface (or sealed interface) with legalMoves(Position from, Board b) and moveTo(Position to, Board b). - Concrete classes: King, Queen, Rook, Bishop, Knight, Pawn. No type field, no instanceof at call sites. - No getter for hasMoved — that fact only matters to castling (King/Rook) and to pawn double-pushes (Pawn). It stays inside those classes. - The Board asks pieces to move; pieces don't reach into the board's array.
Acceptance. - [ ] No switch on piece type anywhere outside Piece's subtype hierarchy. - [ ] No instanceof outside a sealed-type exhaustive switch (if you use one). - [ ] Adding a fairy piece (e.g. archbishop) requires adding one class — nothing else. - [ ] Castling lives in King (and cooperates with Rook), not in a Rules class.
Bonus. Add en passant. Where does the "pawn just moved two squares" memory live — on the pawn, on the board, or somewhere else? Defend your answer in a one-paragraph comment in the code.
Task 7 — A smart locker terminal¶
public class Locker {
public String id;
public boolean occupied;
public String parcelTrackingNumber;
public String pinCode;
public Instant depositedAt;
public Instant expiresAt;
}
public class LockerService {
public void deposit(Locker l, Parcel p, String pin) {
if (l.occupied) throw new IllegalStateException();
l.occupied = true;
l.parcelTrackingNumber = p.trackingNumber();
l.pinCode = pin;
l.depositedAt = Instant.now();
l.expiresAt = l.depositedAt.plus(Duration.ofHours(72));
}
public Parcel collect(Locker l, String pin) {
if (!l.occupied || !l.pinCode.equals(pin)) throw new IllegalStateException();
l.occupied = false;
return /* parcel from tracking number */;
}
}
Objective. Turn Locker into an object with agency. It accepts deposits, dispenses parcels to whoever proves identity, expires items, and reports its own status to a fleet supervisor — without exposing internals.
Constraints. - Operations: accept(Parcel, Pin), dispenseTo(Pin), markExpired(Clock), audit(). - audit() returns an immutable snapshot for monitoring (timestamps, occupancy) — but the snapshot is read-only and not used by any control-flow. - No getter for pinCode. Authentication happens via dispenseTo(Pin), which compares internally and throws on mismatch. - The 72-hour expiry policy lives inside the locker. Inject a Clock for testability.
Acceptance. - [ ] Two deposits in a row throw. - [ ] A wrong PIN never reveals whether the locker is empty (no information leak via different exception types). - [ ] After expiry, dispenseTo(...) refuses even with the correct PIN — the parcel is reclaimed by markExpired(...). - [ ] The locker is unit-testable without freezing wall-clock time globally.
Bonus. Replace accept and dispenseTo with a more behavior-rich vocabulary: parcel.depositInto(locker, pin) and locker.handHandOut(pin). Decide which class should initiate each interaction. Justify in two sentences in your README.
Validation¶
| Task | How to check |
|---|---|
| 1 | Grep for OrderService — it's gone. Status strings appear nowhere in callers. |
| 2 | No call site reads endsOn, cancelled, or plan. All gating goes through entitles(...). |
| 3 | Grep for onHand — only inside StockItem. No caller does arithmetic on stock. |
| 4 | LoanCalculator is deleted. outstanding is computed, not stored. |
| 5 | A 5-minute reservation throws at construction. An external validator class does not exist. |
| 6 | grep -r 'switch.*type' src/ returns nothing relevant. Adding a new piece needs one new class. |
| 7 | Tests inject a fake Clock. Wrong-PIN and expired exceptions are indistinguishable to the caller. |
A meta-check before you submit¶
Open each refactored class. Cover the field declarations with your hand. Read only the methods. Ask yourself:
- Does the class read like a list of actions a real-world thing performs?
- Could a caller use it without ever asking "what's inside"?
- Are the rules in one place, or scattered across helpers?
If you can answer yes, yes, one place — you've internalised the mindset.
Solution sketch — Task 1¶
A reference shape, not the only valid answer:
public final class Order {
private enum Status { NEW, PAID, SHIPPED, CANCELLED }
private final List<Line> lines;
private final CustomerId customer;
private Status status;
public Order(CustomerId customer, List<Line> lines) {
if (lines.isEmpty()) throw new IllegalArgumentException("empty order");
this.customer = customer;
this.lines = List.copyOf(lines);
this.status = Status.NEW;
}
public Receipt pay(PaymentMethod pm) {
require(Status.NEW, "only new orders can be paid");
Money charge = totalAmount();
Receipt r = pm.charge(charge);
status = Status.PAID;
return r;
}
public Shipment ship(Carrier carrier) {
require(Status.PAID, "only paid orders can be shipped");
Shipment s = carrier.dispatch(lines, customer);
status = Status.SHIPPED;
return s;
}
public void cancel() {
if (status == Status.SHIPPED) {
throw new IllegalStateException("cannot cancel a shipped order");
}
status = Status.CANCELLED;
}
private Money totalAmount() {
return lines.stream()
.map(Line::subtotal)
.reduce(Money.ZERO, Money::add);
}
private void require(Status expected, String msg) {
if (status != expected) throw new IllegalStateException(msg);
}
}
Notice what is not in the class:
- No
getStatus(). The state is internal. - No
getLines(). If a caller needs to display lines, give them asummary()returning a read-only view. - No
OrderService. Every rule lives next to the data it constrains. - No
set*. The only way state changes is through behavioral operations.
That's behavior-first — the object owns its verbs, and the verbs own the rules.
Memorize this: when you finish each task, the noun has a thin public surface (a handful of verbs) and a fat private one. If your refactor ended up with twenty getters and one method, you went the wrong way. Start the methods, end with the methods, fields are a private implementation detail.