Design Patterns / Article

Strategy: separate pickup ordering from session rules

Compare oldest-first and deadline-first preparation in KitDesk, then trace the context-policy contract and its fairness trade-offs.

Shared case study: KitDesk community workshop pickups

A pattern earns its place when a concrete change becomes easier without making the rest of the program harder to understand. This series follows KitDesk, an invented desktop application used by a community workshop to prepare equipment pickups. It stays inside one application: objects, interfaces, method calls and lifetimes. It does not prescribe services, deployment topology or an event platform.

Start each lesson with three questions: what problem repeats, which forces pull the design in different directions, and what cost will the proposed solution introduce? The original Design Patterns catalog by Gamma, Helm, Johnson and Vlissides treats applicability and consequences as part of a pattern. KitDesk's requirements, code and exercises below are original teaching examples.

The problem: two legitimate ways to order the same pickups

A volunteer opens a pickup session. KitDesk reads reservations, keeps only those whose kits are ready, orders them, and takes the first two for a preparation manifest. Usually the workshop serves the oldest reservation first. On a busy evening it prepares kits by pickup deadline instead. Neither rule changes what a ready reservation means, how many rows fit on the manifest, or how the manifest is exported.

The initial implementation has one branch inside prepare(). That is reasonable. The pressure appears when ordering becomes independently maintained: volunteers ask for a third rule, the two existing rules need separate acceptance examples, and a second screen must reuse them. Copying the branch makes two places responsible for the same policy. Creating a subclass of the whole pickup session for each ordering rule makes unrelated work part of the inheritance tree.

The forces are specific. We want ordering rules to vary independently, while eligibility and capacity remain consistent. We want predictable output for equal deadlines. We also want to avoid turning a three-line comparison into a framework. Strategy is useful when the first two forces are real enough to justify one explicit delegation point.

Choose a small contract before extracting a class

Here a reservation contains an uppercase internal ID, a safe kit code, ready, and two integer timestamps expressed as minutes on the same teaching-day clock. Real input validation belongs at the boundary; the ordering functions receive already validated records. Each policy returns a fresh array containing every input row exactly once. It must not change a reservation, drop an inconvenient deadline, or contact the cabinet.

This contract deliberately says more than “returns an array.” Two policies with different filtering behavior would be interchangeable in syntax but not in meaning. The session owns eligibility and capacity. The policy owns order alone. This is a behavioral design pattern: the context delegates a variable algorithm through a stable calling convention.

const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);

function oldestFirst(rows) {
  return [...rows].sort((a, b) =>
    a.requestedAt - b.requestedAt || byId(a, b));
}

function deadlineFirst(rows) {
  return [...rows].sort((a, b) =>
    a.pickupAt - b.pickupAt || byId(a, b));
}

class PickupSession {
  constructor(orderPolicy) {
    this.orderPolicy = orderPolicy;
  }

  prepare(rows, capacity) {
    if (!Number.isInteger(capacity) || capacity < 0) {
      throw new RangeError("capacity must be a nonnegative integer");
    }
    const ready = rows.filter(row => row.ready);
    return this.orderPolicy(ready).slice(0, capacity);
  }
}

const rows = [
  { id: "R31", kit: "DRILL", ready: true, requestedAt: 10, pickupAt: 90 },
  { id: "R32", kit: "SAW", ready: true, requestedAt: 20, pickupAt: 50 },
  { id: "R33", kit: "SANDER", ready: false, requestedAt: 5, pickupAt: 40 },
  { id: "R34", kit: "CLAMP", ready: true, requestedAt: 30, pickupAt: 50 }
];

const regular = new PickupSession(oldestFirst);
const closing = new PickupSession(deadlineFirst);
console.log(regular.prepare(rows, 2).map(row => row.id)); // R31, R32
console.log(closing.prepare(rows, 2).map(row => row.id)); // R32, R34

Functions are sufficient strategy objects in JavaScript. A language with explicit interfaces might express the same contract as OrderPolicy.order(rows); a stateful policy could be an object. A class per function adds no capability here. The important structure is a context holding a collaborator and calling it, rather than looking up its concrete type.

For a primary API example of separating an algorithm from its ordering choice, Java's Comparator contract allows a comparison function to be passed to sorting operations and specifies ordering consistency requirements. That API illustrates the delegation idea; KitDesk's complete-array policy is our own contract, not a copy of Comparator.

Live model · Architecture view

Locate the ordering responsibility

The shared KitDesk architecture separates PickupSession from the interchangeable ordering policies. The session retains eligibility and capacity decisions; policy selection changes ordering of the same normalized rows.

Find PickupSession and the policy collaboration. Compare these responsibilities with the saved Prepare by deadline sequence below; the map is a responsibility view, not an executable implementation. Open the public diagram ↗

Open this viewer on its own ↗ if the frame is unavailable. The explanation remains readable without the viewer.

Read the structure and trace one call

In the component view, place the composition code, PickupSession and the two policies in one KitDesk application boundary. Draw an association from the session to OrderPolicy, with both functions satisfying that role. The composition code selects the concrete function. Do not draw a network boundary between these objects.

In the sequence, a volunteer action calls prepare(rows, 2). The session removes R33 before calling deadlineFirst. The policy returns R32, R34, R31; the session slices that sequence to R32, R34. The ordering policy never sees the capacity. This placement matters: sorting after slicing would produce R32, R31 and incorrectly exclude R34.

Selection still needs a decision. A small switch in the application composition code can map an allowed screen option to a policy. Strategy moves knowledge of the algorithms out of the session; it does not remove every conditional in the application. Keep unknown options as explicit errors instead of silently choosing a policy that might prepare the wrong queue.

Live model · Sequence view

Trace the session-policy boundary

The KitDesk model shows PickupSession calling an OrderPolicy after readiness filtering and before capacity selection. The two policies order the same normalized rows without changing them.

Compare oldest-first and deadline-first on R31, R32 and R34. Explain why filtering, ordering and slicing in a different order changes the selected kits. Open the public diagram ↗
Explore scenarios in this model

These links open the same public model in a selected view. They do not change its source.

Open this viewer on its own ↗ if the frame is unavailable. The explanation remains readable without the viewer.

Exploration is local to your viewer. Reload or Reset view returns to the source layout.

Trade-offs, failure cases and when to keep the branch

Each policy adds another place a maintainer may need to inspect. A policy can violate the contract by sorting the caller's array in place or by quietly filtering a row. A stateful policy can also leak one session's preferences into another. Small pure functions make those risks easier to observe, but the contract still needs review.

Deadline ordering can indefinitely delay older reservations when urgent pickups keep arriving. That is a policy consequence, not a defect solved by a pattern. A workshop might adopt an aging rule or a manual override. Decide that with actual workflow evidence; do not imply that choosing Strategy makes an algorithm fair.

Keep the branch when there is one stable rule plus a trivial display option, no separate tests or ownership, and no second caller. Two named functions with an ordinary conditional may already be clear enough. Strategy is also the wrong abstraction for a reservation's lifecycle: “held,” “ready” and “collected” describe state transitions, whereas these policies compute alternative orderings from the same input.

Practice: protect the boundary, then add a rule

  1. Run the example with capacity zero, with no ready rows, and with two equal pickup times. Explain why ID order is an explicit tie-breaker rather than an accidental consequence of input order.
  2. Write one policy contract check: capture the input IDs and order, call a policy, then confirm the input is unchanged and the output has the same IDs with no duplicates. Test filtering and capacity through the session separately.
  3. Add kitFirst, sorting by kit code and then ID. Change only the composition code to select it. If delegating this extraction, use a brief with explicit scope and invariants: ordering may vary, while readiness and capacity stay with the session. If you have to edit PickupSession.prepare(), identify which policy knowledge leaked into the context.

A useful review answer is: “eligibility belongs to the session; ordering belongs to the policy; selecting a policy belongs to composition.” Next, Adapter: translate a cabinet SDK without leaking its contract obtains those same normalized rows from a mismatched API. Return to the Design Patterns hub or follow the KitDesk learning path.