skip to content

You are designing a pluggable-policy extension point (for example a pricing, ranking, or fraud-scoring policy) that other teams will implement using the Strategy pattern. What design and operational concerns go beyond the textbook pattern?

level: principalimportance: nice to knowfreq 18%

answer

  1. interface = published API, evolve additively
  2. parameter object in, rich decision out
  3. immutable, stateless, shared across threads
  4. selection: registry / flags / supports() + priority
  5. decorate for timeout, metrics, fallback, kill switch

basics

~20 s

Treat the strategy interface as a published API: keep its inputs a single evolvable object, make implementations stateless and side-effect-free, define how one is selected and what happens on unknown or failing policies, and make it observable — always log which policy ran.

solid answer

~60 s

Beyond "one interface, many classes": - **Contract as API.** Once other teams implement it you can't change the signature freely. Take a parameter object and return a rich result so both can evolve additively; version the interface rather than overloading it; document determinism, allowed side effects, latency budget, and thread-safety (implementations must be immutable and shareable). - **Selection.** Decide the source of truth — enum registry, config/feature flag, tenant setting, or self-selecting `supports()` with an explicit priority — and make it deterministic, testable, and auditable. Handle the unknown key explicitly: fail fast or use a Null Object; never silent fallback. - **Isolation.** Third-party policies fail, hang, or throw. Wrap each in a decorator adding timeout, error containment with a documented fallback, metrics, and caching. That wrapper, not the domain code, becomes the resilience boundary. - **Operations.** Emit the policy identity with every decision so results are explainable; support shadow/dual-run and gradual rollout; keep the chosen policy in the persisted record for reproducibility. - **Governance.** Who may add a variant, how it is reviewed, and how a bad one is disabled without a deploy.

code

typescript · 17 lines
typescript
interface PricingPolicy {
  readonly id: string;                      // logged + persisted with every decision
  price(req: PricingRequest): PriceDecision; // param object in, rich result out
}

// resilience lives in decorators over the same interface, not in the policies
class GuardedPolicy implements PricingPolicy {
  constructor(private inner: PricingPolicy, private fallback: PricingPolicy,
              private budgetMs: number, private metrics: Metrics) {}
  get id() { return this.inner.id; }
  price(req: PricingRequest): PriceDecision {
    const t = this.metrics.timer(this.inner.id);
    try { return withTimeout(() => this.inner.price(req), this.budgetMs); }
    catch (e) { this.metrics.failure(this.inner.id, e); return this.fallback.price(req); }
    finally { t.stop(); }
  }
}

go deeper

for a junior

Focus on the basics: one shared interface, stateless implementations, and one place that picks the right one.

for a middle

Add the practical concerns: a parameter object so the signature stays stable, explicit handling of unknown keys, and unit tests per implementation plus a test for the selector.

for a senior

Cover thread-safety of shared strategies, decorators for timeout/metrics/fallback, deterministic selection with documented tie-breaks, and logging which policy ran.

for a principal

Frame it as publishing an API with governance: additive/versioned evolution, a shared contract test suite, shadow runs and staged rollout, kill switches, persisted policy identity for auditability, and the discipline to refuse the seam until variation is real.

## Why the textbook version isn't enough The pattern's structure is trivial once you've seen it. What makes an extension point *survive* is everything around the interface: evolution, selection, failure containment, observability, and governance. Here is the checklist I'd apply to a policy seam other teams plug into. ### 1. The interface is a published API The moment implementations exist outside your module, the method signature is frozen in practice. - **Take one parameter object, return one result object.** `score(request: FraudRequest): FraudDecision` can gain fields on both sides without breaking anyone, whereas `score(user, amount, ip, deviceId)` cannot. This also solves the classic Strategy problem of variants needing different inputs. - **Prefer additive evolution**; when a breaking change is unavoidable, publish `PolicyV2` alongside V1 with an adapter, and migrate implementors on a schedule instead of forcing a flag day. - **Return a rich result, not a bare value.** A decision plus its explanation, confidence, and inputs-considered makes outcomes auditable — often a regulatory requirement for pricing/fraud decisions. - **Document the non-signature contract**: determinism (same input → same output?), permitted side effects (ideally none), a latency budget, whether blocking I/O is allowed, error semantics (throw vs. return a neutral decision), and idempotency. Unwritten expectations here are the usual source of production surprises. ### 2. Thread-safety and lifecycle Strategies are typically singletons shared across concurrent requests. Require **immutable, stateless** implementations: configuration in constructor fields, per-call data in the argument. A mutable field on a shared strategy is a data race that only shows up under load. If a policy needs caching or counters, it needs explicit concurrency-safe structures — that expectation must be stated. Also decide the lifecycle: created once at startup, or reloadable when configuration changes (and if reloadable, swapping the reference must be atomic so an in-flight request never sees a half-built policy). ### 3. Selection is its own design problem Options, with trade-offs: - **Static registry keyed by enum** — deterministic, testable exhaustively, but changing the mapping needs a deploy. - **Configuration / feature flag / tenant setting** — supports rollout and per-tenant behavior, but selection now depends on external state, so it must be logged and reproducible. - **Self-selecting `supports(request)` with explicit priority** — expressive for rich criteria, but overlapping predicates create precedence bugs; define the tie-break, forbid ambiguity, and test that exactly one policy matches representative inputs. - **Composite/chained policies** — combining several (first-match, weighted, or aggregate) is powerful and quickly becomes its own mini-language; if you go there, make the composition declarative and testable rather than emergent. Always define the unknown-key behavior: **fail fast** (best when a missing policy indicates a data error) or **Null Object** (a neutral, documented default — best when "no policy" is a legitimate state). Silent fallback to "the first one" is how a pricing bug ships. ### 4. Isolation: assume implementations misbehave Wrap every policy in a decorator implementing the same interface (Decorator over Strategy — the key composability win of the pattern): ``` MeteredPolicy(TimeLimitedPolicy(FailSafePolicy(thirdPartyPolicy), 50ms)) ``` That wrapper provides: a latency cap; error containment with a documented fallback (the previous policy, a conservative default, or a hard failure — chosen per domain, since "charge nothing" and "approve all" are very different defaults); metrics and tracing; optional caching/memoization; and rate limits if the policy calls out. None of this belongs in the domain code, and none of it should be re-implemented per policy. ### 5. Observability and explainability Every decision should record **which policy version produced it**, alongside its inputs summary. Consequences: incidents become explainable ("tenant X ran TIERED_V2 since Tuesday"); results are reproducible; and A/B or **shadow mode** becomes possible — run the candidate policy in parallel, log the delta, and never let it affect the response until the numbers look right. Persist the policy identifier with the decision record if the decision has a long life (an invoice, a risk score, a ranking snapshot). ### 6. Testing strategy - Per-implementation unit tests (fast, isolated). - A shared **contract test suite** every implementation must pass — the single most valuable artifact for a third-party extension point, since it encodes the unwritten contract as executable rules (determinism, no side effects, null/edge inputs, latency ceiling). - Selector tests: every key resolves; unknown key behaves as specified; predicates are unambiguous. - Golden/characterization tests when replacing a legacy policy: replay historical inputs and diff outputs before switching. ### 7. Governance and organizational cost An extension point invites contributions you didn't plan for. Decide up front: who reviews new implementations; whether they ship in your deployable or theirs; how a misbehaving policy is disabled *without a deploy* (a kill switch in the selection layer); and whether the interface is internal (freely changeable) or published (versioned, deprecation policy). Every published seam is a long-term maintenance liability — the strongest reason *not* to create one prematurely. ### 8. When to say no If there are two variants, both owned by your team, and no evidence of a third, a conditional is cheaper and clearer than a plugin architecture. If the variation is data, ship data. Reserve a published Strategy seam for genuine, ongoing, externally-driven variation — and keep it internal (no published contract) for as long as you can.

  • A third-party policy occasionally takes 4 seconds and sometimes throws. Where do you handle that?
    In a decorator implementing the same interface: enforce a latency budget, catch and count failures, and fall back to a documented default policy. Domain code stays unaware, every policy gets the same guarantees, and the fallback choice is an explicit product decision, not an accident.
  • How do you roll out a new policy safely?
    Shadow-run it: execute both, serve the incumbent's result, and log the diff. When the delta distribution is understood, ramp by tenant or percentage behind the selection layer, keep a kill switch, and persist the policy id with each decision so you can attribute any regression.
  • What is the single highest-value artifact for an interface other teams implement?
    A shared contract test suite every implementation must pass. It turns the unwritten expectations — determinism, no side effects, edge-case handling, latency ceiling — into executable rules, and it is what makes third-party implementations trustworthy without reading their code.
  • When would you deliberately not expose this as an extension point?
    When variation is speculative. Two team-owned variants are cheaper as a conditional; a published interface is a permanent commitment with versioning, governance, and support costs. Keep the seam internal until external variation is actually demanded.

Think of an electrical socket standard. The plug shape (interface) can't change once appliances exist, so it's specified conservatively; the socket has a breaker (timeout/fallback) because appliances misbehave; the meter records what drew power (observability); and there's a switch to cut a bad device without rewiring the house (kill switch).

saying these in an interview costs you the question

  • Treating the strategy interface as internal after outside implementations exist, then breaking its signature.
  • Adding a parameter per new requirement instead of evolving a parameter object, forcing every implementor to change.
  • Allowing mutable state in strategies that are shared as singletons across concurrent requests.
  • Letting a slow or throwing implementation propagate straight into the request path with no timeout or fallback.
  • Not recording which policy produced a decision, making incidents unexplainable and results irreproducible.
  • Silent fallback to a default policy when the configured key is unknown, hiding configuration errors.
  • Building a plugin seam for two team-owned variants that a conditional would cover.

context