Skip to content
intermediate Phase 3 · Design Patterns at Scale

Behavioral Patterns: Strategy and Observer

Swap algorithms at runtime and broadcast state changes to subscribers.

55m
0 problems
Topic Progress 0%

Shipping a Pricing Rule as a New Class Instead of a New Branch

Strategy encapsulates interchangeable algorithms behind a shared interface, which makes it the runtime sibling of the open-closed idea you met in the SOLID phase: the difference is that strategy selection can be decided per call, per tenant, or per configuration, rather than fixed at compile time. The pricing engine from earlier topics returns as the running example, now with the question the pattern actually answers: where does the choice of algorithm live?

public interface PricingStrategy {
    Money price(Cart cart, PricingContext ctx);
    String code();                        // for config: 'standard'|'vip'|'bogo'
}

public final class VipPricing implements PricingStrategy {
    public Money price(Cart cart, PricingContext ctx) {
        return cart.subtotal().minus(percent(cart.subtotal(), 10));
    }
    public String code() { return "vip"; }
}

public final class PricingEngine {                 // the context
    private final Map<String, PricingStrategy> byCode;
    private final PricingStrategy fallback;

    public Money price(Cart cart, PricingContext ctx) {
        PricingStrategy s = byCode.getOrDefault(ctx.segment(), fallback);
        return s.price(cart, ctx);                  // no conditionals here
    }
}

Notice what the selection map buys beyond the class-per-rule refactor: the choice is data. A configuration row (segment=vip → strategy=vip) selects the algorithm, so pricing experiments become configuration changes with audit trails instead of deployments — the property that makes strategy the natural pattern for anything a business stakeholder will want to vary without an engineer on the keyboard. The context owns everything shared across strategies (currency rules, rounding mode, the audit log of which strategy ran and why), which keeps each strategy a pure expression of its one rule and keeps the unit tests trivially small.

Three design details interviews probe. Strategy versus conditional: the pattern pays when strategies are real (several, stable, independently tested) and when selection has a life of its own; a two-branch if for a binary flag stays an if until the second real variant arrives — the same timing discipline as before. Composition: when two strategies can legitimately apply (VIP discount plus seasonal BOGO), decide composition explicitly in the context (sum, cap, precedence order) rather than letting map lookup pick one; unstated composition rules are how pricing incidents happen. Stateless vs stateful strategies: keep strategies stateless and pass per-call data through the context object; a strategy holding mutable fields becomes a hidden shared singleton in most DI configurations, which is a concurrency incident waiting for traffic. The pattern's classic counterpart fixes the other axis — the template method fixes an algorithm skeleton in an abstract class while subclasses fill in steps, which is where the third chapter takes the comparison. A note on discoverability, which separates production strategy implementations from textbook ones: at runtime, someone will ask which strategy a given cart used and why. Strategies therefore carry a stable code(), the context logs the selection decision alongside the result, and the configuration map has one obvious owner with a documented default. A strategy engine nobody can interrogate becomes an incident investigationblack hole, and the fix costs one log line per decision plus the map in version control. The same interrogability standard applies to the fallback: when a cart carries no segment at all, the engine must resolve to the standard strategy and say so in the audit trail, because silent defaults are how a pricing experiment ends up billing customers at a rate nobody chose.

Broadcasting OrderPlaced to Listeners That Must Never Know Each Other

The order use case has just saved an order. Now four things must happen: the loyalty service adds points, the warehouse reservation system is notified, analytics receives an event, and the confirmation email is queued. The naive version calls them in sequence inside the use case — and with every addition, the use case grows another collaborator, another failure mode, and another reason for the next engineer to hesitate before touching the happy path. Observer decouples publishers from subscribers so listeners evolve independently: the use case announces that something happened; interested parties react without the announcer knowing who they are.

The in-process form is small and sufficient when the listeners are in the same deployment:

public record OrderPlaced(UUID orderId, UUID customerId,
                          Money total, List<Line> lines) {}

public interface OrderPlacedListener {
    void onOrderPlaced(OrderPlaced event);
}

public final class RefundOrder {            // publisher: knows no listeners
    private final List<OrderPlacedListener> listeners;
    public RefundOrder(OrderRepository repo, List<OrderPlacedListener> ls) {
        this.listeners = List.copyOf(ls);   // wired at composition root
    }
    public CheckoutReceipt place(PlaceCheckout cmd) {
        Order order = orders.save(Order.from(cmd));
        var event = new OrderPlaced(order.id(), order.customerId(),
                                    order.total(), order.lines());
        listeners.forEach(l -> l.onOrderPlaced(event));   // notification
        return new CheckoutReceipt(order.id());
    }
}

The use case's collaborator count is now fixed forever; the loyalty team's change ships in the loyalty module; and unit tests subscribe a recording listener instead of mocking four services. But the pattern's power is exactly where its design questions concentrate, and the first question is failure semantics: a synchronous in-process listener that throws will break the publisher's flow unless you catch per listener — and if you do catch, you have silently converted the notification into fire-and-forget with best-effort logging. That is a legitimate choice, but make it explicit: per-listener try/catch with error logging and a metric, or a queue that retries. What must never happen is one flaky listener rolling back a customer's order.

The second question is consistency of delivery model: once listeners cross process boundaries, the same conceptual event wants to be a message on a bus (Kafka, SQS), which changes the guarantees — at-least-once delivery means every listener must be idempotent, and ordering must be argued for per partition key. Many architectures keep both tiers deliberately: an in-process observer for the fast, same-transaction concerns (audit row written with the order), and a bus event for the cross-service concerns (analytics, warehouse), with the outbox pattern bridging them so the event is durably recorded in the same transaction that saved the order. Naming both tiers and their different guarantees is a strong systems answer.

The third question, asked by every reviewer eventually: does the publisher block on slow listeners? The in-process loop is sequential and bounded; the bus decouples in time but adds infrastructure. Choose by the listener's nature — an audit write wants the transaction's fate; an analytics sink wants independence from it — and write the choice into the ADR so the next listener is placed by policy rather than by whoever wires it that week. Wiring deserves a sentence of its own because it is where observer deployments go wrong in review. The listener list must be assembled at the composition root, immutable for the process lifetime, and ordered deliberately when order matters (audit before analytics, say) — an unordered set of listeners whose relative sequence is accidental is a flakiness generator. In frameworks that auto-discover listeners by annotation, the discovery itself becomes configuration, and the composition-root principle says: make the list visible in one place a reviewer can read, rather than scattered across modules that happen to be on the classpath.

Template Method vs Strategy: Where the Variation Axis Sits

Both patterns solve 'the same flow with different steps', and teams pick between them by instinct. The choice is not taste; it follows from where the variation axis sits and whether the varying pieces need to be composed or reused outside the flow.

Template method fixes the skeleton in a base class and lets subclasses override the hooks:

public abstract class ImportJob {                 // skeleton is the contract
    public final void run(ImportSource src) {      // final: not overridable
        Payload p = read(src);                     // fixed
        List<Row> rows = parse(p);                 // HOOK: csv vs json vs xml
        validate(rows);                           // fixed
        load(rows);                               // HOOK: db vs warehouse
        report(rows.size());                      // fixed, with hook for format
    }
    protected abstract List<Row> parse(Payload p);
    protected abstract void load(List<Row> rows);
    protected void report(int n) { /* default email summary */ }
}

The strengths are real: the invariant ordering of the flow is enforced by the base class (run is final, so no subclass can skip validation), the shared steps are written once, and a reader sees the whole algorithm in a single method. The weaknesses are structural: the variation axis is fixed at subclassing time (no runtime switching without creating a different subclass), the hooks couple subclass to base-class internals (a protected method change breaks every subclass at once — the fragile base class problem), and Java-style single inheritance means the subclass cannot also extend anything else.

Strategy pushes the variation into sibling objects selected at runtime, which is the same skeleton implemented as composition:

public final class ImportPipeline {                // no inheritance involved
    private final RowParser parser;                // strategy
    private final RowLoader loader;                // strategy
    public void run(ImportSource src) {
        List<Row> rows = parser.parse(read(src));
        validate(rows);                            // owned here, not a hook
        loader.load(rows);
    }
}

The chooser table:

Concern Template method Strategy
When variants are chosen subclassing (compile time) runtime, config, per tenant
Adding a variant new subclass new class + registration
Sharing one step across flows copy or deeper hierarchy one strategy class reused
Enforcing flow order base class owns final skeleton context owns the sequence
Composition of variants hard (subclass per combination) natural (inject both strategies)
Coupling risk fragile base class more wiring objects

Practical guidance for architecture reviews: reach for strategy when selection is data (config-driven, per-request, per-tenant) or when a step is shared by several flows; accept template method when the skeleton itself is the invariant you want enforced by the type system and the variants are closed, few, and never selected at runtime. And note the honest observation interviewers appreciate: in languages with first-class functions, many template methods and single-method strategies collapse into a passed function — the pattern remains valuable when the varying piece has structure (several methods, state-free logic worth naming), and the function is the right answer when it does not. This calibration — pattern or plain code, chosen by need — is the subject that closes the phase: Pattern Selection and Trade-offs.

Quiz

1. A pricing engine selects its algorithm from a config-driven map keyed by customer segment. What does this buy beyond a class-per-rule refactor?

Question 1 options

2. A synchronous in-process OrderPlaced listener throws an exception during the notification loop. What is the correct handling policy?

Question 2 options

3. The same order event goes to an in-process audit listener and to a Kafka topic consumed by analytics. Why keep both tiers?

Question 3 options

4. An import flow has a fixed sequence (read → parse → validate → load → report) with parse and load varying by format and destination, chosen at runtime per job. Which is the better fit?

Question 4 options

Flashcards

Question

What does strategy encapsulate, and what does moving selection into a configuration map change?

Answer

Interchangeable algorithms behind a shared interface, with a context owning shared services (currency rules, rounding, audit logging). A config-keyed selection map turns the choice into data: experiments become auditable configuration changes instead of deployments. Duties the pattern does not remove: explicit composition rules when multiple strategies apply, and stateless strategies with per-call context to avoid hidden shared state.

Question

What does the observer pattern decouple, and what three design questions concentrate at its use?

Answer

Publishers from subscribers: the use case announces OrderPlaced and its collaborator count stays fixed while listeners evolve independently. The questions: failure semantics (per-listener catch with metric = explicit best-effort, never let a listener roll back the happy path), delivery model (in-process vs bus, with idempotency under at-least-once), and blocking (sync loop is sequential; bus decouples in time — choose by listener nature).

Question

Why do many architectures keep both an in-process observer tier and a message-bus tier for the same conceptual event?

Answer

Different guarantees for different concerns: fast same-transaction work (audit rows, cache updates) can join the save's atomicity in-process or via an outbox, while cross-service consumers (analytics, warehouse) need durable at-least-once delivery decoupled in time — which forces idempotent listeners and per-key ordering arguments. One transport cannot honestly promise both consistency and temporal decoupling.

Question

Template method vs strategy: what determines the choice?

Answer

Where the variation axis sits and whether steps need reuse. Template method: skeleton enforced by a final base method, variants fixed at subclassing, risk of fragile base class, single inheritance limits. Strategy: runtime/config selection, steps reusable across flows, composition of variants is natural, at the cost of more wiring objects. With first-class functions, single-step variations often collapse to a passed function.

Question

Give the mechanical difference between an observer listener failing and a queue consumer failing.

Answer

In-process observer failure happens inside the publisher's call stack: without per-listener catching it aborts the flow, with catching it is silently best-effort — no retry, no durability. A queue consumer fails outside the publisher: the broker retains the message, retries apply, redelivery is at-least-once (idempotency required), and backpressure and poison-message handling become explicit operational concerns.

Revision Notes

Key Takeaways

  • 1. Strategy turns algorithm selection into data when the map is config-driven, which is why pricing experiments become auditable configuration changes instead of deployments
  • 2. Strategy does not absolve design duties: composition rules when strategies stack, statelessness when DI singletons are the default, and per-call data through the context object
  • 3. Observer fixes the publisher's collaborator count forever; the real design work is failure semantics — explicit per-listener best-effort in-process, durable at-least-once on the bus
  • 4. Keeping both event tiers matches concerns to guarantees: same-transaction consistency for audit, temporal decoupling with idempotency for cross-service consumers
  • 5. Template method vs strategy follows the variation axis: compile-time skeleton enforcement versus runtime data-driven selection, with fragile-base-class and reuse costs on one side and wiring volume on the other

Interview Tips

  • • 'When do you use strategy over if/else?' — several real variants plus data-driven selection; then volunteer the composition question (VIP + seasonal) and how the context resolves it explicitly
  • • 'How do you notify other services when an order is placed?' — in-process observers for same-transaction concerns, outbox plus bus for cross-service, idempotent consumers, and per-listener failure isolation on the sync tier
  • • 'What breaks when a listener throws in a synchronous observer loop?' — the publisher's flow, unless caught per listener; then discuss what the catch means (best-effort, metric) and which concerns truly need durability instead
  • • 'Template method or strategy?' — answer with the variation axis and selection timing; add the functional note (single-step variation = passed function) to show you are not pattern-bound

Cheat Sheet

Behavioral Patterns: Strategy and Observer

Strategy shape:

interface PricingStrategy { Money price(Cart c, PricingContext ctx); String code(); }
class PricingEngine { Map<String,PricingStrategy> byCode;  // selection = data
  Money price(Cart c, PricingContext ctx) {
    return byCode.getOrDefault(ctx.segment(), fallback).price(c, ctx); } }

Duties: explicit composition (sum/cap/precedence), stateless strategies, per-call data via context, selection map keyed by config.

Observer shape: publisher holds List<OrderPlacedListener>, wired at composition root; event is an immutable record; notification loop is per-listener try/catch + metric (explicit best-effort). Never let a listener roll back the happy path.

Two delivery tiers:

Tier Guarantees Concerns
In-process / outbox joins the save transaction audit, cache, fast projection
Bus (Kafka/SQS) durable, at-least-once, decoupled in time analytics, warehouse — idempotent listeners

Template method vs strategy:

Axis Template method Strategy
Selection subclass (compile time) runtime / config / per tenant
Flow order final base method enforces context owns sequence
Reuse of one step copy or hierarchy one strategy class, many flows
Risk fragile base class wiring volume

Single-step variations in languages with functions → pass a function instead.

Continue Learning