skip to content

Besides swapping strategy implementations, what other mechanisms let you extend a system's behavior without modifying existing code — and how do the Decorator pattern and plug-in extension points each achieve the Open/Closed Principle?

level: middleimportance: should knowfreq 52%

answer

  1. Strategy replaces; Decorator surrounds; Plug-in injects the unknown
  2. Decorator = same interface + delegate; order encodes semantics
  3. Extension point = interface + discovery (service loader, DI list, hooks)
  4. Observer/events: add a listener, never edit the publisher
  5. Extension point = permanent contract: narrow it, version it, isolate failures

basics

~20 s

A Decorator wraps an existing object behind the same interface and adds behavior around it (caching, retry, logging) without touching the wrapped class. A plug-in extension point lets the core discover and call implementations it has never heard of, registered at startup or install time.

solid answer

~50 s

Strategy swaps *which* implementation runs; **Decorator** adds behavior *around* an implementation. A decorator implements the same interface as the thing it wraps, holds a reference to it, and delegates — adding caching, retries, metrics, authorization checks, or logging before/after. Because the wrapped class never learns it is wrapped, both the core and the concrete implementation stay closed; decorators compose (`Metrics(Retry(Cache(RealClient)))`), and ordering is a deliberate design choice. **Plug-in extension points** invert discovery: the core declares an interface plus a registration mechanism (service loader, DI container collecting every bean of a type, manifest scanning, an `on(event, handler)` hook, a webhook) and calls whatever it finds. This is OCP at its strongest, because the core can be a compiled, separately shipped artifact that third parties genuinely cannot edit. Related mechanisms: higher-order functions/callbacks (lightweight Strategy), template method (a fixed algorithm with overridable steps), the observer/event bus (add a listener, don't edit the publisher), the visitor pattern for adding operations, and data-driven rules that move variation out of code entirely.

code

typescript · 17 lines
typescript
interface Fetcher { get(url: string): Promise<Body> }

// Decorator: same interface, wraps a delegate, adds behavior. Core untouched.
class RetryingFetcher implements Fetcher {
  constructor(private inner: Fetcher, private attempts = 3) {}
  async get(url: string) {
    let last: unknown
    for (let i = 0; i < this.attempts; i++) {
      try { return await this.inner.get(url) } catch (e) { last = e }
    }
    throw last
  }
}

// Composition order is a design decision, not an accident:
// metrics measures total time incl. retries; a cache hit here skips retries.
const fetcher = new MetricsFetcher(new RetryingFetcher(new CachingFetcher(new HttpFetcher())))

go deeper

for a junior

Define Decorator (same interface, wraps and delegates, adds behavior) and give one example like logging or caching. Knowing plug-ins exist is enough.

for a middle

Contrast Strategy/Decorator/plug-in by where the new behavior sits, name real discovery mechanisms (service loader, DI list injection, event subscription), and note that decorator ordering changes semantics.

for a senior

Add the design obligations of an extension point — narrow interface, documented contract, failure isolation, ordering, versioning — and the debugging/identity costs of deep decorator stacks. Mention Visitor and the expression problem.

for a principal

Treat extension points as public product surface with a compatibility policy and a trust/security model (sandboxing untrusted plug-ins, out-of-process extension), and weigh code-based extension against configuration/rules that avoid deployment entirely.

## The mechanism family, defined All of these share one shape: **a fixed piece of code calls through a named contract, and new behavior is supplied by something that satisfies that contract.** They differ in *where* the new behavior sits relative to the old. ### 1. Strategy — replace the behavior An interface with one operation; the client holds one implementation and calls it. New requirement → new implementation. Variation *instead of* the original. ### 2. Decorator — surround the behavior A **decorator** implements the same interface as the object it wraps and keeps a reference to it (the *delegate*). Its methods do something extra and call through: ``` class CachingRepo implements Repo { constructor(inner: Repo, cache: Cache) {} find(id) { return cache.get(id) ?? cache.put(id, inner.find(id)) } } ``` The wrapped class is untouched and unaware. Key properties: - **Composable**: wrap a wrapper. `Logging(Retry(Caching(Http)))`. - **Order matters and encodes semantics**: retry *outside* cache retries and then caches; retry *inside* cache means a cache hit skips retries entirely. Metrics outside retry measures total latency including retries; inside, per-attempt. - **Interface-preserving**: unlike inheritance, you can add and remove layers at wiring time, and you avoid a combinatorial subclass explosion (`CachingRetryingLoggingRepo`). - **Costs**: deep stacks make stack traces and debugging harder; identity/equality and type-checks (`instanceof`) break because the object is no longer the concrete type; a fat interface makes decorators tedious to write (every method must be forwarded). - **Relatives**: proxies (same shape, purpose is access control/laziness/remoting), middleware chains in web frameworks, aspect-oriented interceptors, and function composition — all decorators by another name. ### 3. Plug-in / extension point — inject unknown behavior into the core The core publishes: an interface (**the extension point**), and a **discovery mechanism** so it can find implementations it has no compile-time knowledge of. Common discovery mechanisms: - **Service loader / provider registry** (a manifest file listing implementations, scanned at startup). - **Dependency injection collection**: the container injects `List<Validator>` — every registered implementation. Adding a validator is adding one class. - **Explicit registration API**: `registry.register(new XlsxRenderer())` in a bootstrap file. - **Event/hook subscription**: `on('order.placed', handler)` — the publisher never changes when a new subscriber appears. - **Out-of-process**: webhooks, sidecars, WASM modules, sandboxed scripts. These extend a system you cannot even recompile. This is the strongest form of OCP because the core may literally be immutable to the extender — a vendor library, a compiled binary, a SaaS product. That constraint is what makes designing the extension point a *product* decision. ### 4. Other members of the family - **Higher-order function / callback**: pass behavior as a parameter (`sort(list, comparator)`). Same OCP effect as Strategy with less ceremony; the language-neutral minimum viable extension point. - **Template Method**: a base class fixes the algorithm's skeleton and leaves overridable steps. Open along the steps, closed on the sequence — but inheritance-based, so it couples subclasses to the base's internals. - **Observer / event bus**: adding a consumer never edits the producer. Extremely open, but control flow becomes implicit and hard to trace. - **Visitor**: flips the expression problem — makes adding *operations* over a fixed type set cheap, at the cost of making new types expensive. - **Data-driven / configuration**: rules in tables, templates, feature flags, DSLs. Extension without any code change or deployment; the cost is that logic escapes type checking, tests, and code review. ## Choosing between them | You want to… | Use | |---|---| | Vary the core algorithm | Strategy / callback | | Add cross-cutting behavior around many implementations | Decorator | | Let others (or other teams) add capabilities to a shipped core | Plug-in extension point | | React to something without the source knowing | Observer / events | | Fix the sequence, vary the steps | Template Method | | Add operations over a stable type set | Visitor | | Change behavior without deploying | Configuration/rules | ## Designing a good extension point 1. **Narrow interface.** Every method is a promise to every future implementer; a five-method extension point is five compatibility obligations. 2. **Explicit contract.** Document threading, idempotency, exception behavior, ordering guarantees, timeouts. Unspecified contracts are how plug-ins destabilize hosts. 3. **Failure isolation.** One bad plug-in must not take the host down: catch, time out, bulkhead, and consider process isolation for untrusted code. 4. **Ordering.** If multiple implementations apply, define priority explicitly rather than depending on registration or classpath order. 5. **Versioning.** Once published, the interface is closed to breaking change — add methods with defaults, or version the interface. 6. **Security.** Discovery-by-scanning means "anything on the path executes." For untrusted extensions, sandbox and constrain capabilities. ## The honest counterweight Every extension point is a permanent public contract with a maintenance cost, and each layer of decoration adds indirection a debugger must walk. Add them where variation has been demonstrated, and prefer the cheapest form that works — a function parameter before an interface, an interface before a discovery framework.

  • Why prefer a Decorator over subclassing the concrete class to add caching?
    Subclassing binds you to one concrete implementation and to its internals, and combinations explode (caching × retry × logging = many subclasses). A decorator targets the *interface*, so one caching decorator works for every implementation, layers compose at wiring time, and the wrapped class stays closed.
  • What breaks when you stack many decorators?
    Debuggability (deep, repetitive stack traces), object identity and type tests (`instanceof`/reflection see the wrapper, not the concrete type), equality/serialization, and reasoning about semantics when ordering is implicit. Fat interfaces also make each decorator boilerplate-heavy.
  • What must a published plug-in interface guarantee that an internal interface need not?
    Backward compatibility and an explicit contract: threading and reentrancy, timeout and failure behavior, ordering among multiple plug-ins, exception semantics, and a versioning/deprecation policy — because you cannot recompile third-party implementations when you change your mind.

Strategy is swapping the lens on a camera. Decorator is screwing a filter onto whatever lens is mounted — the lens doesn't know, and filters stack (order changes the picture). A plug-in extension point is the hot shoe: the camera maker publishes a socket and an electrical protocol, then a company they've never met ships a flash that works on a camera nobody can open.

context