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?
answer
- Strategy replaces; Decorator surrounds; Plug-in injects the unknown
- Decorator = same interface + delegate; order encodes semantics
- Extension point = interface + discovery (service loader, DI list, hooks)
- Observer/events: add a listener, never edit the publisher
- Extension point = permanent contract: narrow it, version it, isolate failures
basics
~20 sA 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 sStrategy 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 linesinterface 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
Define Decorator (same interface, wraps and delegates, adds behavior) and give one example like logging or caching. Knowing plug-ins exist is enough.
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.
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.
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.