Tell, Don't Ask — Practice Tasks¶
Each task starts from anemic, getter-heavy Java and asks you to push the decision into the object that owns the data. The goal isn't to make code compile — it is to make the verbs at the call site read like the business rule.
Global constraints for every task: no public setters on domain objects (verbs only); no train-wreck chains (
a.getB().getC().setD(...)); domain methods returnvoid, a result object, or a computed value — never raw internal collections; existing tests must still pass.
Task 1 — Loan disbursement with credit check chain¶
Domain. A small-business loan platform. Before money moves, the applicant's credit score, debt-to-income ratio, and collateral must all clear thresholds. Today the controller does the asking.
Starting point:
public class LoanController {
public void disburse(LoanApplication app, BankAccount account) {
if (app.getApplicant().getCreditReport().getScore() < 680) {
app.setStatus("REJECTED");
return;
}
if (app.getApplicant().getFinancials().getDebtToIncome() > 0.43) {
app.setStatus("REJECTED");
return;
}
if (app.getCollateral().getAppraisedValue()
.compareTo(app.getRequestedAmount()) < 0) {
app.setStatus("REJECTED");
return;
}
app.setStatus("APPROVED");
account.setBalance(account.getBalance().add(app.getRequestedAmount()));
app.setDisbursedAt(Instant.now());
}
}
Objective. Refactor so the controller calls app.disburseTo(account) and every rule lives inside the relevant object. The applicant answers credit questions; the collateral answers value questions; the loan application orchestrates.
Constraints. No getCreditReport(), getFinancials(), or getCollateral() on LoanApplication's public API. Applicant exposes verbs like isCreditworthy(threshold) and canService(debtToIncomeCap) — not raw scores. setStatus and setBalance are deleted. Disbursement is atomic in intent: either the loan moves to DISBURSED and the account is credited, or neither happens.
Acceptance criteria:
- Controller body is one or two lines with no
ifon loan internals; zeroapp.getX().getY()chains. - A
DisbursementResult(or domain exception) communicates rejection reasons without leaking internal fields. - Tests cover low credit, high DTI, undervalued collateral, and happy path — each asserting on the verb's outcome.
- Renaming a private field in
Applicantdoes not require touchingLoanController.
Task 2 — Refund flow gated by order status¶
Domain. An e-commerce backend issues refunds. The current code reads the order's status, the payment's capture state, and the customer's flags before deciding.
Starting point:
public class RefundService {
public void refund(Order order, Money amount) {
if (!order.getStatus().equals("DELIVERED")
&& !order.getStatus().equals("RETURNED")) {
throw new IllegalStateException("not refundable");
}
if (order.getPayment().getCapturedAmount().compareTo(amount) < 0) {
throw new IllegalStateException("over-refund");
}
if (order.getCustomer().isFraudFlagged()) {
throw new IllegalStateException("manual review required");
}
order.getPayment().setRefundedAmount(
order.getPayment().getRefundedAmount().add(amount));
order.setStatus("REFUNDED");
}
}
Objective. Move the decision and the bookkeeping into Order. The service becomes a thin transport layer: order.refund(amount).
Constraints. Payment is part of Order's internals; the service must not touch it directly. Fraud handling stays on Customer, but only Order asks — via a verb like customer.mayReceiveRefund(). Order.refund rejects over-refunds without exposing the captured amount. No public setter for status or refundedAmount after the refactor.
Acceptance criteria:
-
RefundService.refundreduces to a single call plus logging;Orderexposes onlyrefund(Money)andrefundable(). - Over-refund and wrong-status cases throw distinct domain exceptions, not generic
IllegalStateException. - Tests pass for partial refund, full refund, double refund rejection, and fraud-flag rejection.
- Renaming
Payment.capturedAmounttoPayment.capturedrequires no changes outsideOrder/Payment.
Task 3 — Inventory reservation across warehouses¶
Domain. A fulfillment service reserves stock for an order. Today it loops through warehouses, asks each one how much it has, picks the closest sufficient one, and updates the count.
Starting point:
public class FulfillmentPlanner {
public Warehouse reserve(Sku sku, int qty, List<Warehouse> warehouses, Address ship) {
Warehouse best = null;
double bestDistance = Double.MAX_VALUE;
for (Warehouse w : warehouses) {
int available = w.getStockLevels().get(sku).getOnHand()
- w.getStockLevels().get(sku).getReserved();
if (available < qty) continue;
double d = ship.distanceTo(w.getAddress());
if (d < bestDistance) { bestDistance = d; best = w; }
}
if (best == null) throw new OutOfStockException();
best.getStockLevels().get(sku).setReserved(
best.getStockLevels().get(sku).getReserved() + qty);
return best;
}
}
Objective. Replace the loop with a WarehouseNetwork you tell: network.reserve(sku, qty, shipTo). Each Warehouse answers canFulfill(sku, qty) and performs reserve(sku, qty) on itself. The planner stops reading stock counts.
Constraints. Warehouse.getStockLevels() is removed; stock levels are an internal map. WarehouseNetwork owns the choice of warehouse; warehouses don't know about each other. Reservation is idempotent per (orderId, sku) — calling twice with the same order reserves once. No null returns; out-of-stock is a domain exception or a Reservation.failed(...) result.
Acceptance criteria:
-
FulfillmentPlannershrinks to a thin wrapper aroundWarehouseNetwork; no call site readsonHandorreserved. - A
Reservationvalue type carries warehouse identity, sku, quantity, and idempotency key. - Tests cover nearest-chosen, fallback when nearest is short, total out-of-stock, and idempotent retry.
- Swapping closest-warehouse for cheapest-shipping is a one-strategy-class change.
Task 4 — Claims processing through stages (worked solution sketch)¶
Domain. Insurance claims move through Intake → Adjustment → Approval → Payout. Today, a ClaimWorkflow class drives transitions by reading fields and flipping the status string.
Starting point:
public class ClaimWorkflow {
public void advance(Claim c) {
if (c.getStage().equals("INTAKE") && c.getDocuments().size() >= 3) {
c.setStage("ADJUSTMENT");
c.setAdjustedAmount(null);
} else if (c.getStage().equals("ADJUSTMENT") && c.getAdjustedAmount() != null) {
if (c.getAdjustedAmount().compareTo(c.getPolicy().getMaxPayout()) > 0) {
c.setStage("REJECTED");
} else {
c.setStage("APPROVED");
}
} else if (c.getStage().equals("APPROVED")) {
c.getCustomer().getAccount().setBalance(
c.getCustomer().getAccount().getBalance().add(c.getAdjustedAmount()));
c.setStage("PAID");
}
}
}
Objective. Replace the string-based stage machine with a polymorphic ClaimStage (sealed type) so each stage knows how to advance itself. The caller becomes claim.advance().
Constraints. Claim.getStage() returning a string is gone; stages are objects. No instanceof chains in the workflow class. The payout step uses a verb like customer.credit(amount) — no getAccount().setBalance(...). A claim cannot regress or skip stages.
Acceptance criteria:
- Each stage class implements
advance(Claim)and returns the next stage;ClaimWorkflowis deleted or reduced to a logging shell. - Adding
FRAUD_REVIEWbetween Adjustment and Approval requires one new class plus one transition edit. - Tests cover each transition plus one end-to-end happy path.
- Advancing a
REJECTEDclaim throws a domain exception.
Worked solution sketch.
public final class Claim {
private ClaimStage stage = new IntakeStage();
private final List<Document> documents = new ArrayList<>();
private final Policy policy;
private final Customer customer;
private Money adjustedAmount;
public Claim(Policy p, Customer c) { this.policy = p; this.customer = c; }
public void attach(Document d) { documents.add(d); }
public void adjustTo(Money amount) { this.adjustedAmount = amount; }
public void advance() { this.stage = stage.advance(this); }
// package-private — visible to stage classes, hidden from the world
int documentCount() { return documents.size(); }
Money adjustedAmount() { return adjustedAmount; }
Money policyMax() { return policy.maxPayout(); }
void payOut() { customer.credit(adjustedAmount); }
}
sealed interface ClaimStage permits IntakeStage, AdjustmentStage, ApprovedStage, TerminalStage {
ClaimStage advance(Claim c);
}
final class IntakeStage implements ClaimStage {
public ClaimStage advance(Claim c) {
if (c.documentCount() < 3) throw new NotReadyException("need 3 documents");
return new AdjustmentStage();
}
}
final class AdjustmentStage implements ClaimStage {
public ClaimStage advance(Claim c) {
if (c.adjustedAmount() == null) throw new NotReadyException("adjust first");
return c.adjustedAmount().compareTo(c.policyMax()) > 0
? TerminalStage.REJECTED : new ApprovedStage();
}
}
final class ApprovedStage implements ClaimStage {
public ClaimStage advance(Claim c) { c.payOut(); return TerminalStage.PAID; }
}
enum TerminalStage implements ClaimStage {
PAID, REJECTED;
public ClaimStage advance(Claim c) { throw new TerminalStageException(); }
}
No public getters for stage, documents, or adjustedAmount; the workflow class is gone; stage objects coordinate by telling the claim to payOut() rather than reaching into the customer's account.
Task 5 — Traffic signal as a state machine¶
Domain. A traffic signal cycles Red → Green → Yellow → Red, with pedestrian-button overrides and emergency-vehicle preemption. A SignalController reads the current color and calls setters.
Starting point:
public class SignalController {
public void tick(TrafficSignal s) {
if (s.getColor().equals("RED") && s.getSecondsInColor() > 30) {
s.setColor("GREEN"); s.setSecondsInColor(0);
} else if (s.getColor().equals("GREEN") && s.getSecondsInColor() > 25) {
s.setColor("YELLOW"); s.setSecondsInColor(0);
} else if (s.getColor().equals("YELLOW") && s.getSecondsInColor() > 4) {
s.setColor("RED"); s.setSecondsInColor(0);
} else {
s.setSecondsInColor(s.getSecondsInColor() + 1);
}
if (s.isPedestrianRequested() && s.getColor().equals("GREEN")) {
s.setColor("YELLOW"); s.setSecondsInColor(0);
s.setPedestrianRequested(false);
}
if (s.isEmergencyPreempted()) {
s.setColor("GREEN"); s.setSecondsInColor(0);
}
}
}
Objective. Push the rules into TrafficSignal. The controller becomes a clock: signal.tick(). Pedestrian and emergency events become verbs: signal.requestPedestrianCrossing() and signal.preemptForEmergency().
Constraints. No string color; use an internal enum Aspect { RED, GREEN, YELLOW }. No public getter for secondsInColor. The display layer may read the aspect to render — a legitimate getter use. Emergency preemption returns the signal to its prior state when cleared, not always to RED.
Acceptance criteria:
-
signal.tick()is the only state-changing call in the controller's loop. - A test can fast-forward 60 ticks and assert the exact aspect sequence.
- Pedestrian requests queue, not get lost, while the light is RED.
- Adding a flashing-yellow off-hours aspect is localized inside
TrafficSignal; no call site checks the aspect to decide what to do next — only to render.
Task 6 — Parking lot ticket validation¶
Domain. A parking lot validates exit tickets. Current code asks the ticket for entry time, fare table, validations, and grace period, then computes the amount due.
Starting point:
public class ExitGate {
public Money charge(Ticket t, Instant now, FareTable fares) {
Duration stayed = Duration.between(t.getEntryTime(), now);
if (t.getValidations().contains("MERCHANT_2H")) {
stayed = stayed.minusHours(2);
}
if (stayed.isNegative() || stayed.compareTo(Duration.ofMinutes(15)) <= 0) {
return Money.ZERO;
}
Money owed = fares.lookup(stayed);
t.setPaidAmount(owed);
t.setExitedAt(now);
return owed;
}
}
Objective. Refactor so ticket.priceAt(now) returns the amount due and ticket.exit(now, payment) records the exit. The fare table is injected into the ticket (or the ParkingLot that owns the table). The gate becomes gate.process(ticket, now).
Constraints. getEntryTime, getValidations, and getPaidAmount lose their public modifier. Merchant validations are added via ticket.applyValidation(Validation), not by mutating a list. A ticket cannot exit twice; the second call throws. The grace period and fare table belong to ParkingLot, not the gate.
Acceptance criteria:
-
ExitGate.processhas no arithmetic and no conditionals on ticket state. - Tests assert that a 14-minute stay returns zero, a 2-hour stay with
MERCHANT_2Hreturns zero, and a 4-hour stay charges the 4-hour rate minus the validation. - Changing the fare table at runtime affects only future
priceAtcalls; double-exit throws a domain exception. - No field on
Ticketis exposed via a public setter.
Task 7 — Mortgage prepayment with penalty calculation¶
Domain. A mortgage allows prepayments. Some are penalty-free (within an annual allowance); the rest incur a penalty based on remaining principal and rate differential. Today the code asks the mortgage for everything and computes outside.
Starting point:
public class PrepaymentService {
public Money apply(Mortgage m, Money extra, LocalDate on) {
Money allowanceLeft = m.getAnnualAllowance().subtract(m.getPrepaidThisYear());
Money penaltyFree = extra.min(allowanceLeft);
Money penaltyBearing = extra.subtract(penaltyFree);
Money penalty = Money.ZERO;
if (penaltyBearing.isPositive()) {
BigDecimal diff = m.getRate().subtract(m.getMarketRate(on)).max(BigDecimal.ZERO);
penalty = penaltyBearing.multiply(diff).multiply(
BigDecimal.valueOf(m.monthsRemaining(on))).divide(BigDecimal.valueOf(12));
}
m.setPrincipal(m.getPrincipal().subtract(extra));
m.setPrepaidThisYear(m.getPrepaidThisYear().add(extra));
return penalty;
}
}
Objective. Replace the service with mortgage.prepay(extra, on) returning a PrepaymentReceipt (penalty-free portion, penalty-bearing portion, penalty charged, new principal). The market-rate lookup is injected into the mortgage at construction.
Constraints. The market-rate provider is a collaborator the mortgage owns, not an argument every caller passes. Money.min, allowance subtraction, and penalty arithmetic must not appear outside Mortgage. A prepayment greater than remaining principal is rejected outright — no partial application. The annual allowance resets on the anniversary of origination, not the calendar year.
Acceptance criteria:
-
PrepaymentReceiptcarries every number the caller needs; the caller never readsmortgage.principalto find the new balance. - Prepaying twice within one year correctly applies the remaining allowance the second time; anniversary resets are tested one day before and one day after.
- A zero rate-differential produces zero penalty even for the penalty-bearing portion.
-
PrepaymentServiceis deleted or reduced to a logging wrapper.
Submission checklist¶
- No public setters on any domain object you touched.
- No
x.getY().getZ()chains in code you wrote or kept. - Every conditional that read an object's field has moved inside that object or become polymorphic dispatch.
- Test names describe behaviors (
refundsFullAmountForDeliveredOrder), not setter calls (setsStatusToRefunded). - The diff at the call site reads like the business rule out loud — if you can't say it as one sentence, the verb is wrong.