# 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.

```ts tab
// 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 tab
# bad: string key, untyped lookup
container = {"mailer": SmtpMailer()}

class Checkout:
    def pay(self, order):
        container["mailer"].send(order.email, "Receipt")
```

```ts tab
// 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 tab
# 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
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| typescript-eslint | `no-explicit-any` (in `recommended`) | `as any` on a container lookup | 2026-10-30 |
| typescript-eslint | `no-unsafe-assignment`, `no-unsafe-argument` (in `recommended-type-checked`, need type information) | an `any`-typed lookup result flowing into a typed variable or call | 2026-10-30 |
| tsconfig | `strict` (includes `noImplicitAny`) | untyped lookup results | 2026-10-30 |
| pyright | `reportUnknownMemberType`, `reportUnknownVariableType` (error in strict; see AR-07) | `container["name"]` results of unknown type | 2026-10-30 |
| pyright | protocol 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).

## Related

- [AR-09: How should plugin registries be written for coding agents?](https://beta.llmgraph.in/agent-readable/ar-09.md)
- [AR-15: How do I make duck typing visible to type checkers?](https://beta.llmgraph.in/agent-readable/ar-15.md)
- [AR-20: Where should a registry live so coding agents can find it?](https://beta.llmgraph.in/agent-readable/ar-20.md)
- [AR-39: How do I make event emitters traceable for coding agents?](https://beta.llmgraph.in/agent-readable/ar-39.md)

Last verified 2026-10-30
