SecurityPARADIGM-SPECIFICLANGUAGE-SPECIFICCONTESTED

Capability Passing

Instead of handing a module a service container and hoping, hand it the specific things it may do: CanSendEmail, CanChargePayment. Powerful, honest, and more ergonomic cost than most codebases will accept.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

What changes if a module can only perform effects it was explicitly handed, rather than any effect it can reach?

The requirement

"When an order ships, charge the saved card and email the customer a receipt." The shipping module needs to do exactly two things to the outside world, and today it is constructed with a Services object that can do sixty.

The obvious build

Inject the service container. Everything is available, nothing has to be threaded through five layers, and adding a dependency is one line. It is also what the framework wants, which is not nothing.

Why it breaks

The container is a service locator with better manners: a module's real dependencies are invisible at its boundary and discoverable only by reading every line of it (Service Locator).

How it breaks as requirements change
  • The container is a service locator with better manners: a module's real dependencies are invisible at its boundary and discoverable only by reading every line of it (Service Locator).
  • Effects appear where nobody expects them. A "calculate shipping cost" function that can reach the mailer will eventually send an email from inside a calculation, and the test suite will start sending mail.
  • The blast radius of a bug is the container. A loop with an off-by-one in shipping can charge cards, because charging cards was reachable.
  • When the requirement becomes "run this logic in a dry-run mode for the migration", there is no seam: dry-run means "do everything except the effects", and the effects are not a thing you can enumerate (Side Effects).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The language has no effect system, so "can this function send email" is not a property anything checks unless the design makes it one.
  • The team already uses a DI container that autowires constructors, which means asking for more access costs nothing today.
  • Anything proposed here has to survive contact with an engineer under deadline pressure at 5pm, or it is not a design, it is an aspiration.
Invariants
  • A module can perform exactly the effects it was passed — no ambient reach, no global, no container lookup (Hidden Global State).
  • The set of effects a module can perform is readable from its signature without reading its body.
  • Granting a new capability is a visible edit in a file whose whole job is granting capabilities.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The composition root owns the entire grant graph: who may do what, in one file, readable top to bottom (Wiring and the Composition Root).
  • Each capability is a tiny module owning one effect and its failure semantics — CanChargePayment owns what happens when the provider times out, not its callers.
  • The business module owns sequencing and rules, and is deliberately incapable of owning anything else.
Boundaries
  • The boundary is the parameter list. Everything a module can do to the world crosses it explicitly, which makes the boundary auditable in a way that a package boundary is not.
  • Capabilities are defined by the consumer's need — CanSendReceipt rather than Mailer — so the grant says what it is for and not merely what it wires to (Interface Segregation, Critically).
  • The line stops at pure computation. A function that only transforms data needs no capabilities, and noticing how much of the system that covers is the pleasant surprise of this design (Functional Core, Imperative Shell).

Two constructors, two very different modules

Read only the constructors below and ask what each module can do. In the first, the honest answer is "anything in the application"; you would have to read every line to narrow it, and the answer changes whenever someone adds a service.

In the second, the answer is in the signature and it is two things. Notice what that enables that is not about security at all: a dry run is new ShipOrder(noopCharge, noopMail), and a test needs no framework.

Ambient reach versus granted capability
1// ambient: what can this module do? Read all of it and find out.
2class ShipOrder {
3 constructor(private services: Services) {}
4 async run(o: Order) {
5 await this.services.payments.charge(o.card, o.total)
6 await this.services.mail.send(receiptFor(o))
7 }
8}
9
10// granted: what can this module do? Two things.
11type CanChargePayment = (card: CardRef, amount: Money) => Promise<ChargeId>
12type CanSendReceipt = (to: Email, r: Receipt) => Promise<void>
13
14class ShipOrder {
15 constructor(
16 private charge: CanChargePayment,
17 private sendReceipt: CanSendReceipt,
18 ) {}
19 async run(o: Order) {
20 const id = await this.charge(o.card, o.total)
21 await this.sendReceipt(o.email, receiptFor(o, id))
22 }
23}
24
25// main.ts — the grant list, and the only place it can change
26new ShipOrder(stripeCharge(client), sesReceipt(mailer))

The capability is a function type, not an interface with one method — that is deliberate, because it removes any temptation to add a second method later. Attenuation goes one step further: stripeCharge(client) could close over a per-order idempotency key, so the module cannot charge twice even if its logic is wrong (Idempotency by Design).

The bag, and why it is the failure that gets you

Every team that adopts this design meets the same pull request within about three months. Threading four capabilities through three layers is tedious, someone bundles them into a type for convenience, and the reviewer approves it because it is obviously tidier.

It is tidier. It also converts the design back into the thing it replaced, and it does so gradually enough that no single commit looks like the mistake.

smellA `Capabilities` / `Ctx` / `Deps` object passed everywhereThe capability bag

looks like One type holding every capability in the system, taken as the first parameter of most functions — often with a comment saying it keeps signatures short.

suggests Ambient authority has been reintroduced under a new name. Reach is invisible again, the blast radius is the whole bag, and the composition root no longer tells you what any module can do.

fix Split by consumer, not by convenience: each module takes the capabilities it uses. Where the threading is genuinely painful, that pain is usually pointing at a layer that should not exist — a pass-through layer with no decisions in it is a finding, not a constraint (Over-Decomposition).

when this is fine A genuinely small, genuinely stable bundle that always travels together and is not about authority at all — a request context carrying a correlation id, a clock and a logger is a reasonable thing to pass as one value, and splitting it buys nothing because none of those grant power (Time as a Dependency).

When this is worth it, and when it is not

This design has a narrow band where it is clearly right and a wide region where it is expensive theatre. Being able to say which one you are in is more useful than being able to implement it.

The deciding question is not how security-sensitive the system is. It is how bad a *wrong effect* would be — an email sent twice, a card charged in a test, a row deleted by a migration that was supposed to be dry.

How far should capability discipline go here?

What would a mistaken effect from the wrong module actually cost, and how often do new effects appear?

Full capability passing, attenuated

when Money movement, irreversible external actions, multi-tenant systems where an effect on the wrong tenant is the nightmare scenario.

cost Permanent ergonomic tax, a large composition root, and an onboarding cost for every engineer who has not seen the style. Justified because a mistaken charge is not a bug you fix by rerunning.

Capabilities for dangerous effects only

when Most real systems: charge, send, delete, provision. Reads and pure logic stay ordinary.

cost Two idioms in one codebase, so the rule for which is which has to be written down. This is the recommendation for almost everyone, and it gets most of the value.

Narrow ports, no capability discipline

when The dangerous effects are few and already isolated behind adapters, and the team is small.

cost Reach is still ambient within a module; you get auditability at module granularity but not at effect granularity (Least Privilege as a Design Decision).

Container, and be deliberate about it

when CRUD-shaped applications, internal tools, anything where a mistaken effect is embarrassing rather than expensive.

cost Invisible reach, and tests that need a framework to assert what happened. A legitimate choice, and it should be a choice rather than a default.

Capabilities everywhere including reads

when Essentially never, outside research systems and languages with effect types that make it free.

cost You pay the full tax for the half of the system where the failure mode is a wrong number (Speculative Generality).

How to build it

Most important first.

  • Model each outbound effect as a one-method type with a domain name: CanChargePayment, CanSendReceipt, CanIssueRefund. The name is half the value — it makes the grant list read like a permission list.
  • Pass capabilities explicitly through construction, not through a context object, because a context object is a container with a different name and reintroduces everything this design removed (Constructor Injection).
  • Keep the grant graph in one composition root. When someone asks "what can the shipping module do?", the answer must be a file, not an investigation.
  • Attenuate rather than duplicate: CanChargePayment for this order can be a closure over the order, so the module cannot charge a different one. That is the idea's real power and also where the ergonomics get expensive.
  • Apply it where consequences are irreversible — money, mail, deletion, external calls — and leave reads alone. The whole-codebase version is what makes people abandon it (Over-Decomposition).
  • Be honest in review that this is a minority practice. It is well-founded — the object-capability literature is decades old — and it is not what most teams do, and a design argument that hides that is a weaker argument (What an Abstraction Actually Is).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Adding an effect to a module: a new capability type, an adapter, one grant in the composition root, and a change to the constructor. Expensive on purpose — every new power a module gains is a visible decision.
  • Changing an effect's implementation — a different mail provider, a different payment gateway — is one adapter and no business code at all. This is where the design is unambiguously cheaper than the container.
  • Adding a dry-run or a sandbox mode is nearly free: grant no-op capabilities at the root. Under a container it is a project, because "the effects" is not a set anything can name (Feature Flags and What They Cost).
  • The cost that surprises teams: inserting a new layer between two existing ones means threading every capability the lower layer needs through the new one. That is genuinely annoying, it happens regularly, and it is why this design is rare.
What the recommended approach costs
  • Ergonomics. This is the honest headline: passing capabilities through intermediate layers is tedious, the tedium is permanent, and no amount of tooling fully removes it in a language without an effect system.
  • It fights the framework. Most DI containers, most web frameworks and most tutorials assume ambient access, so the design is upstream of the ecosystem and every new hire arrives expecting something else.
  • It buys protection against accident and against confused-deputy mistakes; against an attacker with code execution in the process, it buys very little, because the adapters are in the same address space.

What can go wrong

Failure modes
  • A Capabilities bag appears, holding all of them, and gets passed everywhere "to reduce boilerplate". The design is now a container with extra steps and no remaining benefit — this is the most common way it dies.
  • Capabilities proliferate to one per method until the composition root is a thousand lines nobody reads, at which point its auditability — the entire point — is gone.
  • Middle layers accumulate pass-through parameters they never use, and the resulting churn makes engineers route around the design rather than through it.
  • It is adopted for reads as well as effects, which doubles the machinery for the half of the system where the mistake it prevents is a wrong answer rather than a wrong action.
Dependencies, and their direction
  • Business modules depend on capability types they own; adapters depend on those types to satisfy them. Nothing in the domain depends on Stripe, SMTP or the container (Dependency Inversion).
  • The composition root depends on every adapter, and is the only thing that does. It becomes a large file, and that is the intended trade: one large honest file instead of hidden reach everywhere.
  • Threading capabilities through intermediate layers creates a dependency from those layers on effects they do not use — the central ergonomic cost, and the reason people reach for a context object.
Misreads
  • "This is just dependency injection." DI is about who constructs what. Capability passing is about what a module is *able to do*, and a DI container that autowires anything on request has DI and no capabilities (Dependency Injection).
  • "So the container is an anti-pattern." No. For a CRUD application with a small team it is a reasonable, well-understood tool, and this design would be over-engineering. The claim is narrower: a container makes reach invisible, and where reach is dangerous that invisibility has a price.
  • "We can get the benefit with naming conventions." A convention tells you what a module *should* reach. The design here is about what it *can* reach, and the gap between those two is the entire subject.
  • "Few codebases do this, so it is impractical." Few do it wholesale. Many do it for exactly the dangerous handful — the payment call, the mailer, the delete — and that partial adoption gets most of the value for a small fraction of the cost.
Smells this explains
  • god-object
  • long-parameter-list

Testing it, and how it ages

What to test, and at which boundary
  • Tests become trivial in a specific way: a capability is a one-method fake, so asserting "this path charged exactly once and sent exactly one receipt" needs no mocking framework (Test Doubles, Precisely).
  • Test that a pure path performs no effects by constructing it with capabilities that throw. The test is meaningful because the capability list is complete — under a container the same test proves nothing.
  • Assert the composition root itself: a test that fails when a module gains a capability it did not have. That is the audit, automated (What to Automate Out of Review).
  • Do not write tests that mirror the grant graph module by module; they duplicate the wiring and break on every legitimate refactor (Mocking).
How this design ages
  • The design ages well in a domain where the set of dangerous effects is stable and small — payments, messaging, provisioning — and badly where every feature adds a new external call, because each one adds a type and a grant.
  • It tends to collapse toward a bag under team growth unless the composition root is treated as a reviewed artefact with an owner (Code Ownership).
  • If the system later splits into services, the capability list is the natural seam: a module whose capabilities are three network effects is a service waiting to happen (Finding Seams).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • PARADIGM-SPECIFICNatural in functional and object-capability styles where effects are already values; awkward in framework-driven OO where the container is the idiom, and close to impossible in codebases that use module-level singletons, because ambient access is the language everyone already speaks there.
  • LANGUAGE-SPECIFICIn a language with an effect system or algebraic effects the compiler enforces the capability list for free; in Rust the borrow checker makes attenuated capabilities pleasant; in TypeScript or Java it is a convention plus constructor discipline, and in a dynamic language with import-time side effects the guarantee is mostly aspirational.
  • CONTESTEDThe strongest opposing view, and it is a serious one: object-capability discipline has been advocated for forty years and has not been adopted by mainstream codebases, which is evidence about its cost rather than about the profession's taste. Ambient authority plus a container is easier to learn, easier to refactor and vastly better supported by tooling; the accidents capability passing prevents are real but rare, and teams that adopt it wholesale often spend more on threading parameters than the prevented incidents would have cost. The defensible position is partial adoption at the dangerous effects, and anyone teaching the full version owes the reader this paragraph.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — effect systems, algebraic effects and object-capability languages move this from a convention the team maintains to a property the compiler checks, and the reason it stays a convention in mainstream languages is a language-design story rather than a design-discipline one.