For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

Agent Rules / Frameworks

How do DI containers hide calls from coding agents?

Rule AR-40

Bind interfaces to implementations in one container module, using typed tokens and constructor injection.

Dependency injection keeps classes independent of each other, which is a real strength. The cost to a reader appears when a service is looked up by a string, like container.get("mailer"). The call returns something, and nothing in the source says what. Give each dependency a typed token, list every binding in one module, and take dependencies as constructor parameters. A search for the interface then finds its implementation and its consumers.

Why it happens

A container maps a key to an object. If the key is a string, the type of the result is whatever the lookup is declared to return, usually any or unknown. The compiler can't connect "mailer" at the lookup to the class registered under "mailer" elsewhere, and a text search matches only the characters.

TypeScript offers three things that carry the relation without a library:

  • Interfaces and generics. A token typed Token<Mailer> makes get(token) return Mailer, so the lookup is checked against the interface.
  • **unique symbol.** The handbook says a unique symbol is only allowed on const declarations and readonly static properties, and that "each reference to a unique symbol implies a completely unique identity", with no two unique symbol types assignable to each other. Two tokens can't be confused for one another.
  • Parameter properties. The TypeScript handbook describes parameter properties: a constructor parameter marked with an access modifier or readonly becomes a class property with the same name and value. A class that lists its dependencies in the constructor states them in one visible place.

In Python the corresponding pieces are typing.Protocol (structural subtyping, "static duck-typing", per the typing docs) for the interface, and a plain constructor parameter for the injection. The typing docs describe Protocol as used "primarily with static type checkers", so a type checker can verify that an implementation satisfies it before anything runs.

Constructor injection is the form that tools follow best. The dependency is a parameter with a type, so "who calls Mailer.send" resolves through the parameter's declared interface. A property set later by the container (@Inject() mailer) or a global lookup inside a method body leaves the class without a stated dependency.

Bad and good

A checkout service sends a receipt through a mailer.

typescript// bad: string token, untyped result, lookup inside the method const container = new Map<string, unknown>(); container.set("mailer", new SmtpMailer()); export class Checkout { pay(order: Order): void { const mailer = container.get("mailer") as any; mailer.send(order.email, "Receipt"); } }

Rename SmtpMailer.send to deliver and the compiler is silent, since mailer is any. A search for "mailer" finds the registration and the lookup only if both spell it the same way.

python# bad: string key, untyped lookup container = {"mailer": SmtpMailer()} class Checkout: def pay(self, order): container["mailer"].send(order.email, "Receipt")
typescript// good: typed token, one container module, constructor injection // tokens.ts export interface Mailer { send(to: string, subject: string): void; } export class Token<T> { readonly __type?: T; constructor(readonly name: string) {} } export const MAILER = new Token<Mailer>("mailer"); // container.ts (the only file that picks implementations) import type { Mailer } from "./tokens"; import { SmtpMailer } from "./smtp-mailer"; import { Checkout } from "./checkout"; const mailer: Mailer = new SmtpMailer(); export const checkout = new Checkout(mailer); // checkout.ts import type { Mailer } from "./tokens"; export class Checkout { constructor(private readonly mailer: Mailer) {} pay(order: Order): void { this.mailer.send(order.email, "Receipt"); } }
python# good: Protocol for the interface; the container module wires it # ports.py from typing import Protocol class Mailer(Protocol): def send(self, to: str, subject: str) -> None: ... # checkout.py from ports import Mailer class Checkout: def __init__(self, mailer: Mailer) -> None: self._mailer = mailer def pay(self, order: Order) -> None: self._mailer.send(order.email, "Receipt") # container.py (the only module that picks implementations) from checkout import Checkout from smtp_mailer import SmtpMailer checkout = Checkout(SmtpMailer())

Checkout names its dependency as a typed parameter. Renaming Mailer.send is a type error in SmtpMailer and in every other implementation, and the call site in Checkout is found by a search for send. The container module is the one place that says which implementation is live, so swapping SmtpMailer for a test double is a one-line edit there. Checked with tsc 7.0.2 / pyright 1.1.414: a class that drops send gives TS2420, and calling a method the interface lacks gives TS2339. In Python, passing an object that lacks send where Mailer is expected gives reportArgumentType.

If you keep a lookup-style container, the typed token form is the middle ground: get<T>(token: Token<T>): T returns the interface type, and the token name is a symbol a search and a rename both follow. The __type field exists only so T is used in the class and affects inference.

Trade-offs and when a lookup container is acceptable

  • Wiring by hand grows. A large app's container module can run to hundreds of lines. Split it by area and keep each part a literal list (compare AR-38 for routes).
  • Lookups cost a hop. With tokens, a reader goes from the call to the interface to the container line. That is two searches, against zero when the lookup is a string.
  • Request-scoped values (the current user, a transaction) change per call. Pass them as arguments, or build the request-scoped object inside a factory function that is named in the container.
  • A framework that constructs your classes (an application framework with its own lifecycle) may require its own registration style. Keep its registration in the single container module, and keep the dependencies typed.
  • Tests substitute implementations. Constructor injection makes that a parameter, not a patch (see AR-18 for patching hazards).

Enforce it

ToolSettingWhat it catchesChecked
typescript-eslintno-explicit-any (in recommended)as any on a container lookup2026-10-30
typescript-eslintno-unsafe-assignment, no-unsafe-argument (in recommended-type-checked, need type information)an any-typed lookup result flowing into a typed variable or call2026-10-30
tsconfigstrict (includes noImplicitAny)untyped lookup results2026-10-30
pyrightreportUnknownMemberType, reportUnknownVariableType (error in strict; see AR-07)container["name"] results of unknown type2026-10-30
pyrightprotocol checks (assigning a class to a Protocol-typed parameter reports a mismatch)implementations that drift from the interface. The rule is reportArgumentType, which flags argument type incompatibilities in a call; it is error in basic, standard and strict per the pyright configuration page, and a Protocol mismatch was reported under it in the AR-15 check.2026-10-30

No rule in the pages checked requires constructor injection. That part is a convention, enforced by the AGENTS.md block and review.

Rule block for AGENTS.md / CLAUDE.md

markdown## Dependency injection - Classes receive dependencies as typed constructor parameters. Don't look dependencies up inside methods. - Interfaces live in `tokens.ts` / `ports.py`. Implementations are chosen in `container.ts` / `container.py` and nowhere else. - Don't register or fetch by a bare string. Use a typed token, or pass the object directly. - When you add a dependency, add the constructor parameter, add the interface if needed, and update the container module in the same change.

Edge cases

  • Two implementations of one interface need two bindings. Name them for their role (PRIMARY_MAILER, BACKUP_MAILER), and not by position.
  • A token that is a class is itself a typed name a search follows, which can replace a separate token object.
  • Circular dependencies show up as a constructor ordering problem in the container module. Break the cycle with an interface that one side owns.
  • Registries that scan for implementations are the plugin case from AR-09. They are dynamic by design.
  • String-token containers across modules are the pattern in CH-12. For the registry-ownership question, see AR-20.

What LLMGRAPH shows

A service fetched by string token prints 'maybe_more (open: type unknown)' on SmtpMailer.send. With constructor injection the call to Mailer.send prints certain, and SmtpMailer.send stays 'maybe_more (open: any matching shape fits)'.

References

  • TypeScript handbook, classes (parameter properties): https://www.typescriptlang.org/docs/handbook/2/classes.html
  • TypeScript handbook, symbols (unique symbol): https://www.typescriptlang.org/docs/handbook/symbols.html
  • TSConfig reference: https://www.typescriptlang.org/tsconfig/
  • typescript-eslint: https://typescript-eslint.io/rules/no-explicit-any/ · https://typescript-eslint.io/rules/no-unsafe-assignment/ · https://typescript-eslint.io/rules/no-unsafe-argument/
  • Python, typing (Protocol, generics): https://docs.python.org/3/library/typing.html

Changelog

  • 2026-10-30: draft 1.
  • 2026-10-30: docs checks resolved (rules-of-hooks, Express error handling, Node events, pyright configuration).
  • 2026-10-30: tool checks resolved (eslint 9.39.5, tsc 7.0.2, pyright 1.1.414).
  • AR-09 How should plugin registries be written for coding agents?
  • AR-15 How do I make duck typing visible to type checkers?
  • AR-20 Where should a registry live so coding agents can find it?
  • AR-39 How do I make event emitters traceable for coding agents?

Last verified

For agentsthis page as Markdown/agent-readable/ar-40.mdllms.txt