Hexagonal + InversifyJS DI Pattern
This monorepo uses Hexagonal architecture (Ports & Adapters) wired with InversifyJS. Every package's role is encoded in its name prefix, and dependency wiring follows a strict, repeatable recipe.
When to Use This Skill
Load this skill when the task involves:
- Creating a new package under
packages/
- Adding or modifying a port (interface + token) in a
core-* package
- Implementing a use case inside
core-*
- Implementing a driven (secondary) or driving (primary) adapter
- Editing an app composition root (
apps/*/src/services/container.ts, apps/*/src/bootstrap-*.ts, apps/*/src/main.ts)
- Any question about
@injectable, @inject, Container, bindings, or scopes in this repo
Architecture Map
| Prefix | Role | May depend on |
|---|
core-* | Domain. Use cases + port definitions (input-ports/, output-ports/) | types, core-utils |
*-driving-* | Primary adapter. Drives the core inward (e.g. HTTP/MCP/RPC entrypoints) | relevant core-*, types, helper-* |
*-driven-* | Secondary adapter. Driven by the core outward (DBs, external services, loggers) | relevant core-*, types, helper-* |
helper-* | Shared utilities/mixins used by adapters | types |
types | Pure type-only contracts. No DI, no runtime code. | nothing |
Rule of thumb: adapters depend on core-* (where the port lives), never directly on types when a port exists. Core depends only on types and core-utils.
Core Conventions
1. DI library
- InversifyJS +
reflect-metadata
- Import
reflect-metadata once per runtime entrypoint (already handled in existing apps)
2. Dual identifier trick (ports)
A port is an interface and a Symbol sharing the same name. TypeScript merges the declarations, so consumers write @inject(FooPort) private foo: FooPort.
ts
1// packages/core-<name>/src/output-ports/foo.ts
2import type { SomeContract } from "@mcp-browser-kit/types";
3
4export interface FooOutputPort extends SomeContract {}
5export const FooOutputPort = Symbol("FooOutputPort");
Re-export from the package root (packages/core-<name>/src/index.ts).
Use Symbol(...) by default. Use Symbol.for(...) only when the token must be resolvable across independent realms/bundles.
3. Default scope: Singleton
Every core container factory creates the container with:
ts
1const container = new Container({ defaultScope: "Singleton" });
Opt into transient explicitly per binding with .inTransientScope() when needed.
4. Core container factories
Each core-* package exports a createCore<Name>Container() that:
- Creates the container
- Binds input ports → use cases (driving side is owned by core)
- Binds internal core collaborators via
.toSelf()
It must not bind output ports — those are the composition root's responsibility.
5. Adapter setupContainer convention
Every adapter class exposes a static method that registers itself into a container provided by the caller. This is the project-wide extension point.
ts
1static setupContainer(
2 container: Container,
3 serviceIdentifier: interfaces.ServiceIdentifier<FooOutputPort> = FooOutputPort,
4) {
5 container.bind<FooOutputPort>(serviceIdentifier).to(FooDrivenAdapter);
6}
Callers pass the container they own; they may override the token when a single class implements multiple ports.
Recipes
Recipe A — Define a new output port
- Create
packages/core-<name>/src/output-ports/<port-kebab>.ts:
ts
1export interface FooOutputPort {
2 doFoo(input: string): Promise<void>;
3}
4export const FooOutputPort = Symbol("FooOutputPort");
- Re-export from
packages/core-<name>/src/index.ts.
- Inject into a use case with
@inject(FooOutputPort).
Reference: packages/core-server/src/output-ports/logger-factory.ts
- Define the input port (same dual-identifier pattern) in
packages/core-<name>/src/input-ports/.
- Implement the use case in
packages/core-<name>/src/core/:
ts
1import { inject, injectable } from "inversify";
2
3@injectable()
4export class DoFooUseCase implements FooInputPort {
5 constructor(
6 @inject(BarOutputPort) private readonly bar: BarOutputPort,
7 ) {}
8
9 async execute(input: string) {
10 await this.bar.doSomething(input);
11 }
12}
- Bind the use case to the input-port token inside
createCore<Name>Container():
ts
1container.bind<FooInputPort>(FooInputPort).to(DoFooUseCase);
Reference: packages/core-server/src/utils/create-core-server-container.ts and packages/core-server/src/core/.
Recipe C — Implement a driven adapter (secondary)
Package name: <context>-driven-<capability> (e.g. driven-logger-factory).
ts
1import { Container, inject, injectable, type interfaces } from "inversify";
2import { FooOutputPort } from "@mcp-browser-kit/core-<name>";
3
4@injectable()
5export class FooDrivenAdapter implements FooOutputPort {
6 async doFoo(input: string) { /* external I/O */ }
7
8 static setupContainer(
9 container: Container,
10 serviceIdentifier: interfaces.ServiceIdentifier<FooOutputPort> = FooOutputPort,
11 ) {
12 container.bind<FooOutputPort>(serviceIdentifier).to(FooDrivenAdapter);
13 }
14}
Do not import from sibling adapter packages. Depend only on the relevant core-* package (for the port) and types/helper-* as needed.
Reference: packages/driven-logger-factory/src/services/consola.ts (see setupContainer near line 287).
Recipe D — Implement a driving adapter (primary)
Package name: <context>-driving-<capability> (e.g. server-driving-mcp-server).
ts
1import { Container, inject, injectable } from "inversify";
2import { FooInputPort } from "@mcp-browser-kit/core-<name>";
3
4@injectable()
5export class BazDrivingAdapter {
6 constructor(
7 @inject(FooInputPort) private readonly foo: FooInputPort,
8 ) {}
9
10 async start() { /* accept external requests and dispatch to this.foo */ }
11
12 static setupContainer(container: Container) {
13 container.bind(BazDrivingAdapter).toSelf();
14 }
15}
Driving adapters consume input-port symbols. They register themselves via .toSelf() so the app can container.get(BazDrivingAdapter) at the entrypoint.
Reference: packages/server-driving-mcp-server/src/services/server-driving-mcp-server.ts (setupContainer near line 103).
Recipe E — Wire an app composition root
Ordering is important: core factory first, then driven adapters, then driving adapters, then resolve in main.ts.
ts
1// apps/<app>/src/services/container.ts
2import { createCoreXxxContainer, FooOutputPort } from "@mcp-browser-kit/core-<name>";
3import { FooDrivenAdapter } from "@mcp-browser-kit/<context>-driven-<capability>";
4import { BazDrivingAdapter } from "@mcp-browser-kit/<context>-driving-<capability>";
5
6export const container = createCoreXxxContainer();
7
8FooDrivenAdapter.setupContainer(container, FooOutputPort);
9BazDrivingAdapter.setupContainer(container);
ts
1// apps/<app>/src/main.ts
2import "reflect-metadata";
3import { container } from "./services/container";
4import { BazDrivingAdapter } from "@mcp-browser-kit/<context>-driving-<capability>";
5
6async function main() {
7 await container.get(BazDrivingAdapter).start();
8}
9
10main();
Reference: apps/server/src/services/container.ts and apps/server/src/main.ts.
Recipe F — Per-runtime sub-containers (browser extensions)
MV2/MV3 isolate runtimes (background/service worker vs. content script vs. popup). Each runtime is its own process and cannot share an Inversify container instance. Create a dedicated composition root per runtime:
bootstrap-<ext>-sw.ts / bootstrap-<ext>-bg.ts — the worker/background context
bootstrap-<ext>-tab.ts — the content-script context
Each bootstrap calls createCore<Name>Container() and wires the adapters appropriate for that runtime (e.g. browser-side logger in tabs, error-stream logger in server).
Reference: apps/m3/src/bootstrap-mbk-sw.ts and apps/m3/src/bootstrap-mbk-tab.ts.
Decision Guide
Which package prefix?
- Defines domain behavior or a port? →
core-*
- External entrypoint that calls into the core? →
*-driving-*
- Implementation the core calls outward to? →
*-driven-*
- Shared mixin/util consumed by multiple adapters? →
helper-*
- Pure type contract with no runtime? → add to
types
Which logger adapter?
- Node/server process where stdout is reserved for protocol traffic →
DrivenLoggerFactoryConsolaError (writes to stderr)
- Browser/extension runtime →
DrivenLoggerFactoryConsolaBrowser
Symbol vs Symbol.for?
- Default:
Symbol("Name") — unique per module instance, fine for normal use
Symbol.for("Name") — only when the token must match across independent bundles/realms
Gotchas
- Adapter → core, not adapter → types. If a port exists, import its symbol from the owning
core-* package.
- No shared containers across extension runtimes. SW and each tab get their own.
- Multi-token aliasing: binding one class to two tokens with
.to(Class) twice produces two singletons. If you need a single instance resolvable under multiple tokens, bind once, then alias with .toService(otherToken) (or bind .toConstantValue(instance)).
- Injecting
Container itself is possible (useful for lazy resolution inside routers/factories) but tightly couples the consumer to Inversify. Avoid unless lazy resolution is a hard requirement.
ext-e2e-test-app and ext-e2e intentionally do not use DI. Don't retrofit them.
types is runtime-free. Never add @injectable classes or Symbols there.
Checklists
New port
New driven adapter
New driving adapter
New app wiring
Reference File Index
Real examples in this repo. Open these when implementing a recipe — they are the source of truth.
Port definitions
packages/core-server/src/output-ports/logger-factory.ts — minimal output port
packages/core-server/src/output-ports/extension-channel-provider.ts
packages/core-server/src/input-ports/server-tool-calls.ts
packages/core-extension/src/output-ports/browser-driver.ts
packages/core-extension/src/output-ports/server-channel-provider.ts
packages/core-extension/src/input-ports/ — additional input ports
Core container factories
packages/core-server/src/utils/create-core-server-container.ts
packages/core-extension/src/utils/create-core-extension-container.ts
Use cases
packages/core-server/src/core/server-tool-calls.ts — @injectable use case with @inject
Driven adapter examples
packages/driven-logger-factory/src/services/consola.ts — setupContainer at ~line 287
packages/server-driven-trpc-channel-provider/src/services/server-driven-trpc-channel-provider.ts — setupContainer at ~line 248 (also demonstrates transient scope and Container-as-dependency)
packages/extension-driven-browser-driver/
packages/extension-driven-server-channel-provider/
Driving adapter examples
packages/server-driving-mcp-server/src/services/server-driving-mcp-server.ts — setupContainer at ~line 103
packages/extension-driving-trpc-controller/
App composition roots
apps/server/src/services/container.ts + apps/server/src/main.ts — server
apps/m3/src/bootstrap-mbk-sw.ts + apps/m3/src/bootstrap-mbk-tab.ts — MV3 extension
apps/m2/src/bootstrap-mbk-bg.ts + apps/m2/src/bootstrap-mbk-tab.ts — MV2 extension
Helpers / shared
packages/helper-base-extension-channel-provider/
packages/helper-extension-keep-alive/
packages/core-utils/ — shared library code (not a hexagon; no ports)
packages/types/ — pure type contracts, no DI