Home / Articles / Framework-Agnostic Frontend DI with InversifyJS and a Composition Root

This article is published in English.

Framework-Agnostic Frontend DI with InversifyJS and a Composition Root

How to pick a TypeScript DI container, package each domain as a ContainerModule, wire everything in one Composition Root and bridge it to React, Vue and Angular.

3230 words

Large frontend codebases rarely fail because of a single bad component; they fail because business rules quietly become entangled with React hooks, HTTP clients and storage APIs until nothing can be tested or reused on its own. Dependency injection is the low-level mechanism that keeps those rules separate, but only if the wiring is designed deliberately. This guide walks through choosing a container for a multi-domain TypeScript monorepo, defining type-safe tokens, sealing each domain behind a single module, assembling the graph in a Composition Root, and exposing it to React, Vue and Angular through bridges of a dozen lines each.

Why isolation has to be decided before the domain is modeled

Classic Domain-Driven Design advice says implementation details should be postponed: model entities and use cases first, pick tools later. In practice, a team starting a large architectural shift benefits from settling one technical question up front, namely how module isolation will actually be enforced. Without an agreed mechanism, the first few domains get wired ad hoc and the conventions never recover.

The principle underneath is the Dependency Inversion Principle: high-level policy should depend on abstractions, and concrete details should depend on those same abstractions. Dependency Injection is the practical technique that delivers it. Classes ask for abstract tokens rather than concrete implementations, and something outside them decides what those tokens resolve to. In DDD and Clean Architecture this is not optional polish; the domain must not know which UI framework, HTTP client or persistence layer it runs with.

Most frontend teams reach first for React Context (or provide/inject in Vue). It looks sufficient: declare a value near the root, read it anywhere below. The limitations appear quickly. There is no lifecycle management, no notion of factories or scopes, and every consumer of the value is now tied to the React runtime, so the logic cannot run or be tested without it. The path from that starting point to a proper Composition Root is what the rest of this guide covers. Writing a bespoke container is rarely worth it, so the first job is to evaluate the existing IoC options honestly.

Criteria for a frontend DI container

Backend frameworks such as NestJS, Spring and .NET ship with DI as a solved, standard concern. Frontend code has extra constraints: runtime cost, bundle budgets and the need to work across several UI frameworks. Five criteria keep the evaluation objective:

  1. Framework agnosticism. The container must run in plain TypeScript with no runtime dependency on React, Vue or Angular, and work in the browser, in Node.js for SSR, and in React Native.
  2. Modularity. A domain should be able to ship a preconfigured module with its bindings sealed inside, so the application only loads it. This is what prevents a 500-line main.ts that registers every service by hand.
  3. Lifecycle management. Singletons, transient instances and scoped resolution must be first-class.
  4. Testability. Swapping a dependency for a mock or fake should take one line, without rebuilding the container.
  5. Bundle impact. Production overhead should be small, both raw and minified plus gzipped.

The candidates

InversifyJS

InversifyJS has long been the default choice for large TypeScript projects and is the most complete IoC container in the ecosystem. It is framework-independent and declares dependencies with decorators. Its ContainerModule groups bindings into logical units, and it supports inSingletonScope, inTransientScope and inRequestScope, child containers, loading and unloading modules at runtime, and activation hooks. The price is weight: it was the heaviest option measured, and describing current versions as lightweight would be misleading.

TSyringe

TSyringe, maintained by Microsoft, trades breadth for convenience. It resolves constructor dependencies automatically from TypeScript metadata, offers a rich decorator set, and covers the usual lifetimes (Singleton, Transient, ResolutionScoped, ContainerScoped). For a medium-sized application that is an attractive, low-ceremony setup. Two things count against it in a multi-domain architecture: there is no first-class module concept, so composition has to be built by hand with registrar functions or createChildContainer(), and it depends on the reflect-metadata polyfill, which adds noticeable weight.

Awilix

Awilix avoids decorators entirely. It uses the ES6 Proxy API to match dependencies to constructor parameter names or to keys of a factory's argument object. That makes it a natural fit for teams that do not want decorators, and it was the smallest standalone container evaluated. It supports SINGLETON, SCOPED and TRANSIENT lifetimes; modules are organized with custom registration functions.

One frontend-specific trap deserves attention. In CLASSIC injection mode Awilix reads constructor argument names as strings, and minifiers rename those arguments, so resolution breaks in production. Only the default PROXY mode survives aggressive mangling.

Angular's built-in injector

Angular has one of the most capable hierarchical DI systems in frontend development, with token-based injection, scoped providers and lazy loading built in. If it could be used without the Angular runtime, it would be a serious contender for the domain layer. It cannot, which is the problem.

React Context

Context is not really a DI container; it is a way to avoid prop drilling. A provider sits near the root and components read it with useContext(). Any change to the provided value re-renders every consumer, and there is no lifecycle, no factory binding and no scope isolation. Its only real advantage is that it costs nothing extra in the bundle.

Vue provide and inject

Vue's mechanism passes values down the component tree and shares Context's architectural limits: it is not a practical foundation for a graph of domain services. It does have one ergonomic edge, since dependencies can be registered globally through a plugin instead of wrapping the tree in nested providers. Angular's modern inject() function relies on a comparable idea of an active injection context.

Plain constructor injection

The zero-library approach passes every dependency through constructors explicitly. It is fully type-safe and has no runtime overhead. The downside is scale: once an application has roughly 10 to 15 domain services, wiring the graph by hand at the entry point turns into a large, brittle file that everyone is afraid to touch.

What the comparison shows

Bundle sizes were compared by bundling an entry file that imports each package's public API with esbuild --bundle --minify --platform=browser and compressing the result with gzip -9, which isolates the container's own overhead from application code. Four conclusions followed:

  1. Framework DI belongs to the UI, not the domain. Putting useContext() or Angular's @Injectable() into domain libraries welds business rules to one framework and fails the first criterion.
  2. Manual injection works for small projects only. In a large enterprise monorepo the Composition Root degenerates into thousands of lines of hand-written wiring.
  3. InversifyJS won on modularity. It is the only candidate where a module (ContainerModule) is a first-class value that a domain library can build, encapsulate and export as one unit.
  4. Build configuration matters. InversifyJS and TSyringe need experimentalDecorators: true, emitDecoratorMetadata: true and target: ES2022 in tsconfig.json. InversifyJS v8 no longer needs a separate reflect-metadata polyfill. With esbuild- or SWC-based tooling (Vite, Next.js, Bun), confirm that the transform step supports legacy decorator metadata, usually through a plugin, because esbuild does not emit decorator metadata on its own.

Four rules that keep the container from becoming a service locator

A container by itself does not isolate anything; used carelessly, it becomes a global bag of services that any file can reach into. The architecture therefore rests on four conventions:

  • Abstract DI tokens built with Symbol.for and a $ property.
  • Each domain library exports only its container module, never its concrete classes.
  • A single Composition Root assembles the application graph.
  • A thin bridge per UI framework exposes the container to components.

Type-safe tokens with Symbol.for and $

A container needs runtime identifiers to map abstractions to implementations. TypeScript interfaces disappear at compile time, so they cannot serve that role directly. The pattern used here pairs every interface in a @my-app/*-contracts package with a constant of the same name whose $ property holds a Symbol.

// libs/auth/contracts/src/lib/interfaces/auth.facade.ts
import type { ServiceIdentifier } from 'inversify';

export interface AuthFacade {
  getAccessToken(): Promise<string | null>;
  login(): Promise<void>;
  logout(): Promise<void>;
}
export const AuthFacade = {
  $: Symbol.for('AuthFacade') as ServiceIdentifier<AuthFacade>,
};

// libs/auth/contracts/src/lib/interfaces/pin.facade.ts
export interface PinFacade {
  setupPin(pin: string): Promise<void>;
  verifyPin(sub: string, pin: string): Promise<PinVerifyResult>;
  changePin(sub: string, currentPin: string, newPin: string): Promise<PinVerifyResult>;
}
export const PinFacade = {
  $: Symbol.for('PinFacade') as ServiceIdentifier<PinFacade>,
};

Three decisions are packed into that snippet:

  1. One name for type and value. TypeScript keeps types and values in separate namespaces, so AuthFacade can be both the interface and the token holder. The compiler picks the right meaning from context, and nobody has to invent names such as AuthFacadeToken.
  2. Symbol.for instead of Symbol(). Each call to Symbol() returns a brand-new value, whereas Symbol.for() looks the key up in the runtime's global symbol registry. If a contracts package ends up duplicated in two bundles, as can happen in a monorepo or with Module Federation, both copies still produce the same token and lookups keep working instead of failing mysteriously. The flip side is that registry keys are global strings, so they should be unique across the whole application.
  3. The ServiceIdentifier<AuthFacade> cast. Typing $ as Inversify's ServiceIdentifier<T> ties the token to its interface at compile time, so resolution sites infer the right type without explicit generics.

The next snippet shows both sides: a class receiving the facade through constructor injection, and a direct container.get call whose return type is inferred from the token.

import { inject, injectable } from 'inversify';
import { AuthFacade } from '@my-app/auth-contracts';

@injectable()
export class LoginPageComponent {
  // Strongly-typed injection via token
  constructor(@inject(AuthFacade.$) private readonly auth: AuthFacade) {}
}

// Resolution site automatically infers return type as AuthFacade
const facade = container.get(AuthFacade.$); // AuthFacade

The consumer only ever references the contract. It has no idea what lives inside @my-app/auth-core, which is exactly the point.

One domain, one ContainerModule

Each domain's core library exposes a single thing: its DI module. Use cases, entities, ports and repositories stay private. Inside the module, internal use cases are bound to themselves, while the public facade is bound to its contract token.

// libs/auth/core/src/lib/auth-container.module.ts
import { ContainerModule, type ContainerModuleLoadOptions } from 'inversify';
import { AuthFacade } from '@my-app/auth-contracts';
import { CoreAuthFacade } from './facades/core-auth.facade';
import { LoginUseCase } from './use-cases/login.use-case';
import { LogoutUseCase } from './use-cases/logout.use-case';
import { GetAccessTokenUseCase } from './use-cases/get-access-token.use-case';

export const authContainerModule = new ContainerModule((options: ContainerModuleLoadOptions) => {
  // Private use cases (registered to self within domain)
  options.bind(LoginUseCase).toSelf().inSingletonScope();
  options.bind(GetAccessTokenUseCase).toSelf().inSingletonScope();
  options.bind(LogoutUseCase).toSelf().inSingletonScope();
  // Public contract implementation bound to token
  options.bind(AuthFacade.$).to(CoreAuthFacade).inSingletonScope();
});

// libs/auth/core/src/index.ts - ONLY the ContainerModule is re-exported!
export * from './lib/auth-container.module';

Encapsulation is enforced twice:

  1. Public API. The library's index.ts re-exports only authContainerModule. Classes like LoginUseCase or CoreAuthFacade are simply unreachable from other packages.
  2. Lint rules. Nx's @nx/enforce-module-boundaries ESLint rule checks project tags, so, for example, a project tagged type:core is not allowed to import another domain's type:core project. See the Nx module boundaries documentation for how tags and constraints are declared.

The export boundary stops accidental imports; the lint rule stops deliberate shortcuts through deep paths. Together they make the architecture something CI verifies rather than something reviewers have to remember.

The Composition Root

The Composition Root is the one place where the dependency graph is created, typically apps/my-app/src/composition-root.ts or a function called from main.ts. One rule governs it:

The Composition Root is the only place in the codebase allowed to import concrete implementations, infrastructure adapters and domain core modules.

The function below first binds global infrastructure (an Axios-based HTTP client and a logger built from a factory so it can receive configured transports), then loads the domain modules.

// apps/my-app/src/composition-root.ts
import { Container } from 'inversify';
import { HttpClientPort, LoggerPort } from '@my-app/shared-contracts';
import { AxiosHttpClient, BrowserLogger, ConsoleLogTransport, FileLogTransport } from '@my-app/shared-infrastructure';

import { authContainerModule } from '@my-app/auth-core';
import { paymentsContainerModule } from '@my-app/payments-core';
export const initAppContainer = (): Container => {
  const container = new Container();

  // 1. Bind global infrastructure adapters
  container.bind(HttpClientPort.$).to(AxiosHttpClient).inSingletonScope();
  container.bind(LoggerPort.$).toDynamicValue(() => new BrowserLogger({
    transports: [
      new ConsoleLogTransport(),
      new FileLogTransport(),
    ],
  })).inSingletonScope();

  // 2. Load domain modules
  container.load(authContainerModule);
  container.load(paymentsContainerModule);

  return container;
};

For completeness, the BrowserLogger adapter is an ordinary class implementing LoggerPort and fanning each message out to its transports. Note that it carries no decorators; because it is constructed inside toDynamicValue, the container never needs to inspect its constructor.

// libs/shared/infrastructure/src/lib/logging/browser-logger.ts
import type { LoggerPort } from '@my-app/shared-contracts';
import type { LogTransport } from './interfaces/log-transport.interface';

export interface BrowserLoggerOptions {
  transports: LogTransport[];
}

export class BrowserLogger implements LoggerPort {
  constructor(private readonly options: BrowserLoggerOptions) {}

  info(message: string, ...args: unknown[]): void {
    this.options.transports.forEach(t => t.write('INFO', message, args));
  }
}

Several applications from one domain core

Because infrastructure is chosen in the Composition Root, different apps can reuse the same domain packages with different adapters and a different selection of modules. A React Native app might bind AsyncStorage-backed storage, while a web admin app uses IndexedDB and loads an audit domain instead of payments.

// apps/mobile/src/composition-root.ts — React Native target
container.bind(StoragePort.$).to(AsyncStorageAdapter).inSingletonScope();
container.bind(LoggerPort.$).to(RnLogger).inSingletonScope();
container.load(authContainerModule);
container.load(paymentsContainerModule);

// apps/admin/src/composition-root.ts - Web Admin target
container.bind(StoragePort.$).to(IndexedDbAdapter).inSingletonScope();
container.bind(LoggerPort.$).to(BrowserLogger).inSingletonScope();
container.load(authContainerModule);
container.load(auditContainerModule); // Payments domain excluded entirely

@my-app/auth-core is byte-for-byte the same in the mobile, admin and desktop builds. It never learns whether storage is AsyncStorage or IndexedDB.

Letting one domain use another

Suppose payments-core needs an access token that auth-core manages. Importing @my-app/auth-core is forbidden by the boundary rules. Instead, the payments use case depends on the auth domain's contract token, which lives in the contracts package and is allowed.

// libs/payments/core/src/lib/use-cases/create-payment.use-case.ts
import { inject, injectable } from 'inversify';

// Import from contracts, NOT core — permitted by boundary rules
import { AuthFacade } from '@my-app/auth-contracts';
import { PaymentsGatewayPort } from '../ports/payments-gateway.port';

@injectable()
export class CreatePaymentUseCase {
  constructor(
    @inject(AuthFacade.$) private readonly auth: AuthFacade,
    @inject(PaymentsGatewayPort.$) private readonly gateway: PaymentsGatewayPort,
  ) {}

  async execute(amount: number): Promise<void> {
    const token = await this.auth.getAccessToken();
    return this.gateway.processPayment(amount, token);
  }
}

paymentsContainerModule does not bind AuthFacade.$; it only consumes it. The binding is satisfied when the Composition Root loads both modules into the same container. The practical consequence: if an app loads payments without auth, resolution fails at runtime, so it is worth a smoke test per application target that resolves every public token once.

Bridging the container to UI frameworks

Since the container knows nothing about any UI library, each framework gets a small adapter of roughly 10 to 15 lines.

React

The container is created once at startup and never replaced, so the context value never changes. That sidesteps the re-render cascade normally associated with Context: consumers read a stable reference. useInjection memoizes the lookup per container and token and throws a clear error if the provider is missing.

// libs/shared/react-di/src/lib/di-context.tsx
import React, { createContext, useContext, useMemo, type ReactNode } from 'react';
import type { Container, ServiceIdentifier } from 'inversify';

export interface DIProviderProps {
  container: Container;
  children: ReactNode;
}

const DIContext = createContext<Container | null>(null);

export const DIProvider = ({ container, children }: DIProviderProps) => (
  <DIContext.Provider value={container}>
    {children}
  </DIContext.Provider>
);

export const useInjection = <T,>(token: ServiceIdentifier<T>): T => {
  const container = useContext(DIContext);

  return useMemo(() => {
    if (!container) {
      throw new Error('useInjection must be used within a DIProvider');
    }

    return container.get(token);
  }, [container, token]);
};

Vue

Vue's plugin system and a typed InjectionKey make the adapter even shorter. The plugin provides the container at the app level, and a composable resolves tokens from it.

// libs/shared/vue-di/src/lib/di-plugin.ts
import type { App, InjectionKey } from 'vue';
import { inject } from 'vue';
import type { Container, ServiceIdentifier } from 'inversify';

export const DI_CONTAINER: InjectionKey<Container> = Symbol('DI_CONTAINER');

export const diPlugin = {
  install(app: App, { container }: { container: Container }) {
    app.provide(DI_CONTAINER, container);
  },
};

export const useInjection = <T>(token: ServiceIdentifier<T>): T => {
  const container = inject(DI_CONTAINER);

  if (!container) {
    throw new Error('diPlugin is not installed');
  }

  return container.get(token);
};

Angular

Angular's own hierarchical injector can delegate to the domain container through factory providers. Handing the container over via an InjectionToken, and creating a fresh container on each bootstrap, keeps concurrent server-rendered requests from sharing state.

// apps/angular-app/src/app/domain.providers.ts
import { InjectionToken, type Provider } from '@angular/core';
import type { Container } from 'inversify';
import { AuthFacade } from '@my-app/auth-contracts';
import { initAppContainer } from './composition-root';

export const DI_CONTAINER = new InjectionToken<Container>('DI_CONTAINER');
export const AUTH_FACADE = new InjectionToken<AuthFacade>('AUTH_FACADE');

export const provideDomainContainer = (container: Container): Provider[] => [
  {
    provide: DI_CONTAINER,
    useValue: container,
  },
  {
    provide: AUTH_FACADE,
    useFactory: (c: Container) => c.get(AuthFacade.$),
    deps: [DI_CONTAINER],
  },
];

// main.ts - fresh container instance created per bootstrap
bootstrapApplication(AppComponent, {
  providers: [...provideDomainContainer(initAppContainer())],
});

Each additional facade Angular components need gets its own InjectionToken and factory provider, so the list grows with the public API; generating it from a small map is an option once it gets long.

Costs and when to skip this

The setup adds about 21 KB gzipped and requires emitDecoratorMetadata in the build. For an enterprise monorepo with ten or more domains, that cost is quickly repaid. For an app with two or three screens, the isolation is overhead you do not need; plain constructor injection or even module-level singletons will serve better.

Key takeaways

  • Keep framework DI (Context, provide/inject, Angular providers) at the UI edge; the domain core should depend only on contract tokens.
  • Choose a container for its module story first: a domain that exports one sealed ContainerModule is what keeps the entry point small.
  • Use Symbol.for tokens typed as ServiceIdentifier<T> so duplicated packages and type inference both keep working.
  • Enforce boundaries with exports and lint rules, not conventions alone.
  • Let exactly one file, the Composition Root, touch concrete implementations; multi-platform builds then become a matter of swapping bindings.
  • Remember that injection is only the tool. The goal is inversion: if you can replace React with Vue, SQLite with IndexedDB or Axios with Fetch without editing the domain layer, the strategy has done its job.