skip to content

How does the Open/Closed Principle apply above the class level — to modules, services, and published APIs — and what mechanisms enforce it at that scale?

level: principalimportance: nice to knowfreq 26%

answer

  1. Scale test: how many teams must change for one requirement?
  2. DIP + ports/adapters; enforce with architecture tests, not etiquette
  3. Events: publisher closed, new consumers additive — pay with eventual consistency
  4. Additive schemas, tolerant readers, expand/contract, consumer-driven contract tests
  5. Every extension point = permanent contract + isolation + security + governance

basics

~20 s

At large scale, being "closed" means other teams and consumers can add capabilities without you changing or redeploying your code: stable published contracts, plug-in boundaries, event streams anyone can subscribe to, and backward-compatible schema evolution.

solid answer

~50 s

Above the class level, OCP becomes a statement about **who must change when a requirement changes** — and the answer should be "only the party that wants the new behavior." Mechanisms: (1) **Dependency inversion at module boundaries** — high-level policy owns the interface, adapters (database, queue, provider) plug in, which is the essence of ports-and-adapters/hexagonal and Clean Architecture; (2) **Event-driven integration** — a publisher emits a fact and new consumers subscribe without the publisher changing, the strongest team-scale OCP but at the cost of implicit control flow and eventual consistency; (3) **Backward-compatible contract evolution** — additive-only schema changes, tolerant readers, expand/contract migrations, API versioning, so consumers upgrade independently; (4) **Runtime extension** — plug-ins, webhooks, sandboxed scripts/WASM, per-tenant rules and feature flags, letting behavior change without redeployment. The counterweight is that every extension point is a permanent public contract with compatibility, security, failure-isolation, and observability obligations, so you open only the boundaries where variation is real.

go deeper

for a junior

Say that the same idea scales up: a service publishes a stable API or event, and new consumers can be added without changing the publisher.

for a middle

Name ports and adapters / dependency inversion, event subscription, and backward-compatible API versioning as the mechanisms.

for a senior

Add enforcement (architecture tests, schema registries, consumer-driven contract tests) and expand/contract migrations, plus the costs of event-driven coupling.

for a principal

Frame it as coordination cost and Conway's law — who is blocked when a requirement lands — and treat each extension point as a governed product surface with compatibility policy, failure isolation, security/sandboxing, ordering semantics, and observability.

## Reframing OCP for architecture At class level, "closed for modification" means a file isn't edited. At architecture level, the meaningful currency is **coordination cost**: when a new requirement lands, how many teams, repositories, deployments, and release trains must move together? A system is "open/closed" at scale when new capability is a *local, additive* change by one party. ### Mechanism 1 — Dependency inversion at boundaries (ports and adapters) **Dependency Inversion Principle (DIP)**: high-level policy does not depend on low-level details; both depend on an abstraction, and the abstraction is *owned by the policy side*. Applied at module scale this gives **ports and adapters** (hexagonal architecture) and Clean Architecture's dependency rule (source dependencies point inward toward policy). Why it's OCP: the core declares `PaymentPort`, `NotificationPort`, `EventStore`. Swapping Stripe for Adyen, Postgres for DynamoDB, or adding an SMS channel means writing an adapter — the domain module is untouched and its tests remain valid. Enforcement is not aspirational: module systems, package-visibility rules, dependency-direction linters, and architecture tests (ArchUnit-style rules, dependency-cruiser configs, module frameworks) can fail the build when someone imports a detail from policy code. ### Mechanism 2 — Event-driven integration A service publishes `OrderPlaced`. Fraud, loyalty, analytics, and a new tax service each subscribe. The publisher is **closed**: adding the fifth consumer requires zero change to it. This is the highest-leverage OCP at organizational scale, because it removes the publisher's team from the critical path entirely. The costs are real and must be stated: control flow becomes implicit and hard to trace; you inherit eventual consistency, ordering, duplicate delivery, and poison-message handling; the *event schema* becomes the contract, and now it is the thing that cannot break. Practices that keep it working: publish domain **facts** rather than commands, keep events additive-only, carry a schema version, use a schema registry with compatibility rules, and design consumers as **tolerant readers** that ignore unknown fields. ### Mechanism 3 — Backward-compatible contract evolution A published API or schema is the ultimate "closed" artifact: you cannot edit your consumers. So the discipline is to make change additive. - **Additive-only fields**, never repurpose or remove without a deprecation window. - **Tolerant reader**: ignore unknown fields; don't validate strictly on things you don't use. - **Expand/contract (parallel change) database migrations**: add the new column, dual-write, backfill, switch reads, then drop the old column in a later release — each step is deploy-safe alone, allowing rolling deploys and independent consumer upgrades. - **Versioning**: URI/media-type/header versions for HTTP, field-number stability in protobuf-style formats, gRPC/GraphQL deprecation rather than removal. - **Consumer-driven contract tests**: consumers publish their expectations; the provider's CI fails when a change would break a real consumer. This converts "closed for modification" from a hope into an automated gate. ### Mechanism 4 — Runtime and no-deploy extension - **Plug-ins/extensions** for shipped products (IDEs, browsers, CI systems, e-commerce platforms) — the extender genuinely cannot edit the host. - **Webhooks and sidecars** — extension across a network boundary; the host stays closed and doesn't even share a process. - **Sandboxed compute (WASM, restricted scripting)** — untrusted extension with resource and capability limits. - **Per-tenant configuration, rules engines, feature flags** — behavior varies without any deployment. Powerful and dangerous: logic that lives in config escapes code review, tests, and type checking, so it needs its own versioning, audit trail, and testing story. ## The obligations that come with opening a boundary Once published, an extension point is a product with a lifecycle: 1. **Compatibility policy**: what may change, deprecation windows, support horizons. 2. **Failure isolation**: one plug-in or slow webhook must not take down the host — timeouts, bulkheads, circuit breakers, process isolation. 3. **Security and trust**: discovery-by-scanning executes whatever is present; untrusted extensions need sandboxing, least privilege, and supply-chain controls. 4. **Ordering and conflict semantics**: when several extensions apply, priority must be explicit and documented. 5. **Observability**: attribute latency, errors, and cost to the extension responsible, or you cannot operate the system. 6. **Governance**: extension points multiply the surface you can never simplify. Every one is a decision you can't take back cheaply. ## Conway's-law framing OCP at scale is ultimately about **who is blocked**. If adding a tax jurisdiction requires a pull request into the billing team's repo and a slot in their release train, the architecture is open for modification and closed for extension — the exact inversion. Designing the seams so each team extends within its own boundary is architecture serving organizational throughput, and it is the strongest business case for the principle. ## Where not to open Inside a single service, a single team's own module, prefer concrete code and refactor when the second case appears — the coordination cost that justifies a formal extension point simply isn't there. Reserve published extension points for boundaries that are genuinely crossed by parties who cannot edit your code.

  • Event-driven integration gives the strongest publisher-side closure. What does it cost?
    Implicit control flow that is hard to trace and debug, eventual consistency, duplicate and out-of-order delivery, poison messages and dead-letter handling, and a new immutable contract — the event schema — that must evolve additively with versioning and tolerant readers. You have moved the coupling, not removed it.
  • How do you turn 'we won't break consumers' from a hope into an enforced property?
    Automate it: schema-registry compatibility checks in CI, consumer-driven contract tests that fail the provider build when a real consumer's expectation would break, API diff/linters that flag removals, and expand/contract migration steps that are each independently deploy-safe under rolling deployments.
  • When is a formal extension point the wrong answer inside one team's service?
    Almost always at first. The justification for a published extension point is coordination cost across parties who cannot edit your code. Inside one team's codebase you can just refactor when the second variant appears, so a plug-in framework there is speculative generality with extra governance overhead.

A city that grew by writing building codes and standard utility hookups rather than by having one office approve each house's plumbing. Anyone can build on a lot without the city redesigning the water system — but the hookup standard becomes essentially permanent, so it had better be narrow, well specified, and safe to connect to.

context