Syllabus · B8
Template method or lifecycle hook — the skeleton that calls down into its subclasses
- Tier 2
- Seams
- 7 min read
- after C1
The idea
Template method / lifecycle hook
An order moves through six stages. The requirement says the same five things happen every time: check the move is legal, apply it, timestamp it, do that stage's own work, tell people. Only the last two vary. Write that as an if-chain inside one advance() method and every new stage is a new branch that has to remember all five steps. The sixth branch someone adds will forget one.
corpus/food-ordering's OrderStage.enter() is final. A stage fills in two hooks: check before anything moves, onEntered after it does. It cannot reorder, skip, or wrap the other three. That is the difference from a policy interface like Tariff: a policy answers one question, and the caller keeps control of the sequence. A template method owns the sequence and calls into the subclass. Swapping a policy changes an answer. Overriding a hook changes a step.
The measured payoff: three of the six stages override neither hook, and each is one constructor call, as data. When the restaurant needs a way to refuse an order, writing the transition inside OrderBook.reject() costs 25 added lines and a fifth copy of the five steps — measured, in DECISION_LOG.md. The stage version costs 7 lines (budget 11), and OrderStage, the file that runs every order, is not touched.
Reach for this when several variants share one fixed sequence and only a couple of steps differ between them. One variant with nothing else planned does not need it — see when-not.md.
Worked walkthrough
NOTES — one skeleton, two hooks, and the line that has to run before the other two
Run it first
..\..\..\.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out src\*.java
..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main
javac -Xlint:all prints nothing and exits 0. Real output, verbatim:
1. an order delivered 60 minutes after placement
CUSTOMER told: order-1 -> PLACED
RESTAURANT told: order-1 -> PLACED
CUSTOMER told: order-1 -> ACCEPTED
CUSTOMER told: order-1 -> DELIVERED
RESTAURANT told: order-1 -> DELIVERED
charged 1200 of 1500 quoted — the delivery fee was waived for lateness
2. a cancellation refused because the order already arrived
refused: an order that is DELIVERED can no longer be cancelled
order is still DELIVERED — the refused transition changed nothing
Two things worth stopping on before reading OrderStage. Block 1 charges 1200 of the 1500 quoted. DeliveredStage waived the delivery fee on its own, and nothing in Main told it to. Block 2 throws, and the order stays DELIVERED afterward. A refused transition really changed nothing, not merely reported that it changed nothing.
OrderStage.java — the skeleton, and the one method everything else in this lesson defends
public final StageEvent enter(Order order, Clock clock, Notifier notifier) {
Instant at = clock.instant(); // 3 · the timestamp, read once and up front
check(order, at); // 1 · validate the transition
order.moveTo(state); // 2 · apply it
onEntered(order, at); // 4 · this stage's own work
List<Audience> notified = tell(order, notifier); // 5 · notify
StageEvent event = new StageEvent(state, at, notified);
order.record(event);
return event;
}
final is the whole seam. A stage fills in check and onEntered and cannot reorder, skip or wrap the other three lines. Without final, "a refusal changes nothing" would be a property every subclass author has to remember, rather than a property of where the guard sits.
check runs before moveTo. The order is still in its old stage while the guard asks whether it may leave. GapTest.checkRunsBeforeTheOrderMoves catches the reordered version directly: a probe stage asserts order.state() inside check and would see the new state if moveTo ran first.
onEntered runs after moveTo, not before it. A stage's own work should see itself as the order's current stage. DeliveredStage below reads order.placedAt() from history and compares it against at. That particular comparison does not depend on order.state(), but GapTest.onEnteredSeesTheOrderAlreadyMoved checks the ordering directly with a probe stage, and a reordered onEntered would see order.state() still null there.
The clock is read once, before check runs, and handed to both hooks. A guard about timing and the instant recorded in history must be the same instant. Read the clock twice instead, once for the guard and once for the record, and a transition could be refused for being early, then recorded on retry as on time.
order.record(event) is not optional because enter also returns the event. The caller gets a copy to look at. The order keeps the only copy anything else can find later. DeliveredStage.onEntered calls order.placedAt(), which reads history.get(0). Delete the record call from the PLACED stage, and that throws `IllegalStateException: order o6 has not been placed yet` the next time any later stage runs.
private List<Audience> tell(Order order, Notifier notifier) {
List<Audience> told = new ArrayList<>(audiences.size());
for (Audience audience : audiences) {
try {
notifier.send(audience, order.id(), state);
told.add(audience);
} catch (RuntimeException channelDown) {
// Swallowed on purpose...
}
}
return told;
}
A failed channel is lost, not invented as delivered. told only grows on success, so StageEvent.notified() is an honest list of who actually heard, never a guess.
PlainStage.java — the argument for the skeleton, not an aside about it
public class PlainStage extends OrderStage {
public PlainStage(OrderState state, List<Audience> audiences) {
super(state, audiences);
}
}
Nothing to override means nothing to write. ACCEPTED in Main is one constructor call, not a class, because the five fixed steps already cover everything that stage needs. Without the skeleton, a stage with no behaviour would still need its own code to read the clock, append to history and notify in the right order.
CancelledStage.java — both hooks, and between them the entire requirement
private static final Set<OrderState> CANCELLABLE_FROM =
EnumSet.of(OrderState.PLACED, OrderState.ACCEPTED);
@Override
protected void check(Order order, Instant at) {
if (!CANCELLABLE_FROM.contains(order.state())) {
throw new IllegalStateException(
"an order that is " + order.state() + " can no longer be cancelled");
}
}
@Override
protected void onEntered(Order order, Instant at) {
order.charge(0);
}
One Set, read by one line, is the entire cancellation window. GapTest's block 2 exercises exactly this: a DELIVERED order is outside CANCELLABLE_FROM, so check throws before moveTo ever runs, and order.state() still reads DELIVERED afterward.
onEntered only charges; it never touches state or history. Those two are the skeleton's job. A stage that tried to timestamp itself would be duplicating step 3.
DeliveredStage.java — a hook that decides from recorded facts, never from a live clock
@Override
protected void onEntered(Order order, Instant at) {
if (at.isAfter(order.placedAt().plus(ON_TIME_WITHIN))) {
order.charge(order.itemsMinor());
}
}
at is the instant enter already read, not a fresh Instant.now(). A hook that called the wall clock here could disagree with its own stage's timestamp, and would be untestable besides. The fixed clocks in Main only work because no hook reads time on its own.
placedAt() depends on the first stage having called order.record(event). That is the same invariant GapTest.laterStageCanFindPlacedAt checks: delete the record call anywhere upstream and this line throws instead of deciding the late-fee rule.
Main.java — the driver
A missing driver caps D2 at 0, whatever OrderStage does. This one shows a late delivery and a refused cancellation, not only the happy path — the two properties this lesson's gaps exist to protect.
new DeliveredStage().enter(order, deliveredLate, notifier);
This line is where the late-fee waiver actually happens. Main computes nothing, because the rule lives inside the hook, not at the call site.
When not to
When a template method is not worth its file
D3 level 3 reads "the seam set is minimal — no speculative interface with a single implementation and no foreseeable second one". An abstract base class with one final method and two hooks, written for a single concrete stage that will ever exist, is exactly that.
The bad example
Say a delivery tracker has exactly one kind of event: the courier arrives. Nothing else in the requirements varies by event type, and no second kind is named as coming. The tempting version:
public abstract class DeliveryEvent {
public final void record(Delivery delivery, Clock clock) {
Instant at = clock.instant();
validate(delivery, at);
delivery.markArrived(at);
onRecorded(delivery, at);
}
protected void validate(Delivery delivery, Instant at) { }
protected void onRecorded(Delivery delivery, Instant at) { }
}
public final class ArrivalEvent extends DeliveryEvent { }
What it costs
A skeleton with one subclass that overrides neither hook. ArrivalEvent is an empty class whose entire content is its name. A reader has to open DeliveryEvent to find out that nothing in it varies, then open ArrivalEvent to confirm that, yes, nothing does.
Two hooks nobody has a reason to fill. validate and onRecorded exist because the pattern's shape requires them. No requirement asked a stage to validate, or to do its own work, differently from any other stage, because there is no other stage.
Compare it to a plain method:
public void recordArrival(Delivery delivery, Clock clock) {
delivery.markArrived(clock.instant());
}
One method, no hierarchy, and the requirement (mark the delivery arrived) is a method name on the class whose job it is.
The threshold
Reach for the seam when one of these holds:
- A second variant of the sequence exists now, in the requirements or the tests.
corpus/food-orderingstarts with six stages, and three of them already differ in what they do at step four. - The fixed steps are the actual risk. Ordering, timestamping, or notification guarantees must hold for variants nobody has written yet. A plain method sequence would let a future author get that order wrong.
One stage, one caller, no second kind of event in sight: write the method. Add the skeleton the day a second kind actually needs steps 1, 2, 3 and 5 to stay identical to the first.
Worked source
The 10 files of the worked design
Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.
worked/src/Audience.java4 linesworked/src/CancelledStage.java34 linesworked/src/DeliveredStage.java28 linesworked/src/Notifier.java7 linesworked/src/Order.java76 linesworked/src/OrderStage.java80 linesworked/src/OrderState.java4 linesworked/src/PlainStage.java14 linesworked/src/StageEvent.java16 linesworked/src/Main.java35 lines
worked/src/Audience.java4 lines
/** Who might be told when an order reaches a stage. */
public enum Audience {
CUSTOMER, RESTAURANT, COURIER
}
worked/src/CancelledStage.java34 lines
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
/**
* {@code CANCELLED} — the one branch off the straight line. It overrides both hooks, and
* between them they are the entire cancellation requirement: {@link #check} is "cancellation
* stops being allowed once the kitchen starts", and {@link #onEntered} is "a cancelled order
* is charged nothing".
*/
public final class CancelledStage extends OrderStage {
/** Cancel while it is still only paperwork. After that the food exists. */
private static final Set<OrderState> CANCELLABLE_FROM =
EnumSet.of(OrderState.PLACED, OrderState.ACCEPTED);
public CancelledStage() {
super(OrderState.CANCELLED, List.of(Audience.CUSTOMER, Audience.RESTAURANT));
}
@Override
protected void check(Order order, Instant at) {
if (!CANCELLABLE_FROM.contains(order.state())) {
throw new IllegalStateException(
"an order that is " + order.state() + " can no longer be cancelled");
}
}
@Override
protected void onEntered(Order order, Instant at) {
order.charge(0);
}
}
worked/src/DeliveredStage.java28 lines
import java.time.Duration;
import java.time.Instant;
import java.util.List;
/**
* {@code DELIVERED} — the end of the happy path. Its own work is the late-delivery promise:
* if the order took longer than 45 minutes from placement, the customer does not pay for the
* delivery.
*
* <p>It reads the instant it is handed — the same one that goes into the history — and
* compares it with the instant the order was placed, read out of the history. There is no
* second notion of "now" anywhere in this decision.
*/
public final class DeliveredStage extends OrderStage {
private static final Duration ON_TIME_WITHIN = Duration.ofMinutes(45);
public DeliveredStage() {
super(OrderState.DELIVERED, List.of(Audience.CUSTOMER, Audience.RESTAURANT));
}
@Override
protected void onEntered(Order order, Instant at) {
if (at.isAfter(order.placedAt().plus(ON_TIME_WITHIN))) {
order.charge(order.itemsMinor()); // waive the delivery fee; the food is still paid for
}
}
}
worked/src/Notifier.java7 lines
/**
* One channel out. A stage never knows which implementation it is talking to, only that
* this call might throw if the channel is down.
*/
public interface Notifier {
void send(Audience audience, String orderId, OrderState state);
}
worked/src/Order.java76 lines
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* One order. It holds the facts a stage needs — its id, its current stage, its history,
* what it is charged — and knows none of the rules. It has no idea which stage may follow
* which, when it may be cancelled or what a late delivery costs. Every rule lives in a
* stage, which is why a new stage never edits this class.
*/
public final class Order {
private final String id;
private final long itemsMinor;
private final long deliveryFeeMinor;
private final List<StageEvent> history = new ArrayList<>();
private OrderState state;
private long chargedMinor;
public Order(String id, long itemsMinor, long deliveryFeeMinor) {
this.id = Objects.requireNonNull(id, "id");
if (id.isBlank()) {
throw new IllegalArgumentException("an order needs an id");
}
this.itemsMinor = itemsMinor;
this.deliveryFeeMinor = deliveryFeeMinor;
this.chargedMinor = itemsMinor + deliveryFeeMinor;
}
public String id() {
return id;
}
/** Where the order is. Null until the first stage has been entered. */
public OrderState state() {
return state;
}
public long itemsMinor() {
return itemsMinor;
}
public long chargedMinor() {
return chargedMinor;
}
/** When the order was placed — the first thing that ever happened to it. */
public Instant placedAt() {
if (history.isEmpty()) {
throw new IllegalStateException("order " + id + " has not been placed yet");
}
return history.get(0).at();
}
/** Every stage reached, oldest first. A copy: nothing can append to this but a stage. */
public List<StageEvent> history() {
return List.copyOf(history);
}
/** Step 2 of the skeleton. Only {@link OrderStage#enter} has any business calling this. */
void moveTo(OrderState next) {
this.state = Objects.requireNonNull(next, "next");
}
/** Step 5 of the skeleton, once the event is complete. */
void record(StageEvent event) {
history.add(Objects.requireNonNull(event, "event"));
}
/** Move what is charged. The only mutator that can touch the money. */
void charge(long amountMinor) {
this.chargedMinor = amountMinor;
}
}
worked/src/OrderStage.java80 lines
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* One stage of an order's life, and the skeleton every stage runs when an order enters it.
*
* <p>The same five things happen at every stage: validate the transition, apply it,
* timestamp it, run the stage's own work, notify. Only the last two vary. {@link #enter} is
* {@code final} — a stage fills in the two hooks below and cannot reorder, skip, or wrap
* the other three.
*/
public abstract class OrderStage {
private final OrderState state;
private final List<Audience> audiences;
protected OrderStage(OrderState state, List<Audience> audiences) {
this.state = Objects.requireNonNull(state, "state");
this.audiences = List.copyOf(Objects.requireNonNull(audiences, "audiences"));
}
public final OrderState state() {
return state;
}
/**
* The skeleton. Move an order into this stage, doing the five steps in the one order
* they are ever done in.
*
* <p>The clock is read once, at the top, before the guard runs — a guard about time and
* the instant that ends up in the history must be the same instant, or an order could be
* refused for being early and then recorded as having happened on time.
*
* @throws IllegalStateException if {@link #check(Order, Instant)} refuses. Nothing has
* changed when it does
*/
public final StageEvent enter(Order order, Clock clock, Notifier notifier) {
Instant at = clock.instant(); // 3 · the timestamp, read once and up front
check(order, at); // 1 · validate the transition
order.moveTo(state); // 2 · apply it
onEntered(order, at); // 4 · this stage's own work
List<Audience> notified = tell(order, notifier); // 5 · notify
StageEvent event = new StageEvent(state, at, notified);
order.record(event);
return event;
}
/**
* Hook 1 · may this order enter this stage right now? Throw {@link IllegalStateException}
* to refuse. The default has nothing to refuse.
*/
protected void check(Order order, Instant at) {
// Nothing to refuse by default.
}
/**
* Hook 2 · whatever this stage does for itself, if anything. Most stages do nothing.
*/
protected void onEntered(Order order, Instant at) {
// Deliberately empty.
}
/** Tells everybody on the list, in order, and reports who was actually told. */
private List<Audience> tell(Order order, Notifier notifier) {
List<Audience> told = new ArrayList<>(audiences.size());
for (Audience audience : audiences) {
try {
notifier.send(audience, order.id(), state);
told.add(audience);
} catch (RuntimeException channelDown) {
// Swallowed on purpose: the order really has reached this stage. Recording
// only who was told is how the loss stays visible instead of being invented.
}
}
return told;
}
}
worked/src/OrderState.java4 lines
/** Where an order is. Six stages, one straight line plus one branch off it. */
public enum OrderState {
PLACED, ACCEPTED, PREPARING, OUT_FOR_DELIVERY, DELIVERED, CANCELLED
}
worked/src/PlainStage.java14 lines
import java.util.List;
/**
* A stage that has no work of its own: it is applied, timestamped and notified like every
* other stage, and that is all it does. {@code ACCEPTED}, {@code PREPARING} and
* {@code OUT_FOR_DELIVERY} are each one call to this constructor, not a class, because a
* stage with no behaviour is data — a name and a list of people to tell.
*/
public class PlainStage extends OrderStage {
public PlainStage(OrderState state, List<Audience> audiences) {
super(state, audiences);
}
}
worked/src/StageEvent.java16 lines
import java.time.Instant;
import java.util.List;
import java.util.Objects;
/**
* One completed transition: which stage was reached, when, and who was actually told about
* it. {@code notified} lists only the audiences that were told successfully — a channel
* that failed is not invented as having succeeded.
*/
public record StageEvent(OrderState state, Instant at, List<Audience> notified) {
public StageEvent(OrderState state, Instant at, List<Audience> notified) {
this.state = Objects.requireNonNull(state, "state");
this.at = Objects.requireNonNull(at, "at");
this.notified = List.copyOf(Objects.requireNonNull(notified, "notified"));
}
}
worked/src/Main.java35 lines
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
/** Two orders: one that arrives on time, and a refused cancellation on a finished one. */
public final class Main {
public static void main(String[] args) {
Notifier notifier = (audience, orderId, state) ->
System.out.println(" " + audience + " told: " + orderId + " -> " + state);
Clock placedAt = Clock.fixed(Instant.parse("2026-08-19T12:00:00Z"), ZoneOffset.UTC);
Clock deliveredLate = Clock.fixed(Instant.parse("2026-08-19T13:00:00Z"), ZoneOffset.UTC);
System.out.println("1. an order delivered 60 minutes after placement");
Order order = new Order("order-1", 1_200, 300);
new PlainStage(OrderState.PLACED, List.of(Audience.CUSTOMER, Audience.RESTAURANT))
.enter(order, placedAt, notifier);
new PlainStage(OrderState.ACCEPTED, List.of(Audience.CUSTOMER))
.enter(order, placedAt, notifier);
new DeliveredStage().enter(order, deliveredLate, notifier);
System.out.println(" charged " + order.chargedMinor() + " of " + (1_200 + 300)
+ " quoted — the delivery fee was waived for lateness");
System.out.println("2. a cancellation refused because the order already arrived");
try {
new CancelledStage().enter(order, deliveredLate, notifier);
} catch (IllegalStateException refused) {
System.out.println(" refused: " + refused.getMessage());
}
System.out.println(" order is still " + order.state()
+ " — the refused transition changed nothing");
}
}
The faded stage is not here, on purpose
In the app, the third stage of a lesson hands you the worked design with a few lines
replaced by // GAP: markers, then compiles your completion and runs a JUnit suite
against it. That needs javac, and a static site has no compiler — so rather than show a
control that cannot work, this page stops at the worked source.
Run the app for the drill: it is the download in the header, and it works offline once unpacked.