A Composition Root for React + Hexagonal Architecture
I work in a React app organized with hexagonal architecture (a.k.a. ports and adapters, Alistair Cockburn, 2005). Each feature is split into domain, application, infrastructure, and presentation. The domain defines ports — interfaces like DocumentRepository — and the infrastructure layer provides adapters that implement them, like createDocumentRepository(httpClient). Dependencies always point inward; the domain never knows what database or HTTP client is behind a port.
That arrangement leaves one question open: where, and when, does a port get connected to an adapter? That’s the question this article is about. The answer turns out to have a well-known name in the dependency-injection literature — Mark Seemann’s Composition Root — and adopting it gave me a clean test seam, a one-place repository swap, and a single readable manifest of every implementation choice the app makes.
What Seemann calls this
The pattern was named by Mark Seemann in Dependency Injection in .NET (Manning, 2011) and developed further with Steven van Deursen in Dependency Injection Principles, Practices, and Patterns (Manning, 2019). His definition, from the original write-up:
A Composition Root is a (preferably) unique location in an application where modules are
composed together.MARK SEEMANN
Composition Root (2011)
Two further principles from the same source matter for what follows:
- “The Composition Root should be placed as close as possible to the application’s entry point.”
- “Only application components need a Composition Root. Libraries and frameworks don’t.”
Translated into ports-and-adapters language: the composition root is the one place that decides which adapter plugs into which port. Everything inside the hexagon — use cases, domain logic, presentation — only ever sees the ports; they never name an adapter. If ports are the appliances’ plugs, the composition root is the wall socket: one place that chooses what’s actually on the other side of the wire.
The problem this solves
A use case shouldn’t know whether the adapter behind its port is a real REST client, a GraphQL client, or a mock. But something has to be decided. There are two options for when that decision happens:
- Import-time wiring — the use case is built the moment a module is imported, frozen to a concrete adapter inside an
import. The “composition” is scattered across every file that performs the import. - Runtime wiring — every adapter is instantiated once at startup, in a single function, and carried through the React tree in a small container. Use cases are built on demand, asking the container for the right port.
The Composition Root is the second option. Everything below is plumbing for that one decision.
The four moving parts
1. Module — a named box of ports’ adapters
export abstract class Module {
readonly name: string;
protected constructor(name: string) {
this.name = name;
}
}
2. Application — a registry of modules, looked up by module type
export type ModuleConstructor<T extends Module> = {
readonly id: string;
new (...args: never[]): T;
};
export class Application {
readonly name: string;
readonly modules: readonly Module[];
constructor(name: string, modules: Module[]) {
this.name = name;
this.modules = [...modules];
}
getModule<T extends Module>(module: ModuleConstructor<T>): T {
const found = this.modules.find(registered => registered.name === module.id);
if (!found) {
throw new Error(`Module "${module.id}" is not registered in application "${this.name}".`);
}
return found as T;
}
}
3. A feature module — holds the adapters that satisfy that feature’s ports
export type DocumentsModuleRepositories = {
documents: DocumentRepository;
};
export type DocumentsModuleDependencies = {
repositories: DocumentsModuleRepositories;
};
export class DocumentsModule extends Module {
static readonly id = 'documents';
private readonly dependencies: DocumentsModuleDependencies;
constructor(dependencies: DocumentsModuleDependencies) {
super(DocumentsModule.id);
this.dependencies = dependencies;
}
get repositories() {
return this.dependencies.repositories;
}
}
4. The composition root itself — the one place where each port gets an adapter
// composition.ts — Seemann's "preferably unique location"
export const createApplication = (): Application =>
new Application('gestor-documental', [
new DocumentsModule({
repositories: {
// PORT (DocumentRepository) <- ADAPTER (createDocumentRepository) -- decided HERE
documents: createDocumentRepository(httpClient),
},
}),
]);
And a React context to carry the assembled container down the tree — Seemann’s “as close as possible to the entry point” means main.tsx:
const ApplicationContext = createContext<Application | null>(null);
export const useApplication = (): Application => {
const application = useContext(ApplicationContext);
if (!application) throw new Error('useApplication must be used within an <ApplicationProvider>.');
return application;
};
export const ApplicationProvider = ({ application, children }: ApplicationProviderProps) => (
<ApplicationContext.Provider value={application}>{children}</ApplicationContext.Provider>
);
// main.tsx — the entry point. Composition happens once, here.
const application = createApplication();
createRoot(document.getElementById('root')!).render(
<ApplicationProvider application={application}>
<App />
</ApplicationProvider>
);
How a request flows at runtime
The easiest way to follow the flow is to separate wiring time from request time:
| Moment | What happens | What it means |
|---|---|---|
| Startup | createApplication() runs in main.tsx. | The composition root runs once, close to the app entry point. |
| Startup | new DocumentsModule({ repositories }) receives concrete repository adapters. | The real adapters are chosen here, not inside a hook. |
| Startup | <ApplicationProvider application={app}> puts the wired app into React context. | React can carry the application container without importing infrastructure elsewhere. |
| Request | useGetDocumentsOverviewQuery() retrieves DocumentsModule, builds the use case, and passes it to React Query. | The hook uses an already-wired module; React Query manages async state but does not choose the adapter. |
In the real composition.ts, the important part is this small slice:
export const createApplication = (): Application =>
new Application('gestor-documental', [
new DocumentsModule({
repositories: {
documents: createDocumentRepository(httpClient),
},
}),
]);
That is the adapter decision. The DocumentsModule receives concrete infrastructure adapters, but the rest of the app will only ask the module for its ports.
Then the query hook uses the already-wired module:
export const useGetDocumentsOverviewQuery = () => {
const application = useApplication();
const documents = application.getModule(DocumentsModule);
const useCase = getDocumentsOverview({ repository: documents.repositories.documents });
return useCustomQuery({ useCase, params: undefined });
};
The important detail is the timing. The adapter is chosen at startup, not inside the hook. By the time a component calls useGetDocumentsOverviewQuery, the hook is only retrieving an already-wired module and building the use case from ports. React Query runs the async work, but it does not decide which infrastructure adapter the app uses.
The hook imports application (use-case factory) and domain (the port types and module) — never infrastructure. The hexagon stays sealed; the adapter only ever appears in composition.ts.
Advantage 1: a clean test seam (swap the adapter, not the use case)
This is the everyday win. The hook does not create or import the repository itself. It reads the repository from the Application provided by React context.
So the test gives the hook a different Application. renderHookInApp is just the test helper that renders the hook inside that provider. In production, the provider receives the real application. In this test, it receives a test application built with a mock repository.
The use case is still the real use case. Only the repository adapter is swapped.
const repository = createMockDocumentRepository({
getOverview: async () => [
{ categoryId: 'FISCAL', subcategories: [{ subcategoryId: 'IMPUESTOS', documentCount: 3 }] },
],
});
const { result } = renderHookInApp(
() => useGetDocumentsOverviewQuery(),
createDocumentsTestApplication(repository) // same port, fake repository
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.data).toHaveLength(1);
Compare the import-time version, where the hook imports a use-case factory and a repository singleton and wires them at use-site:
// presentation/queries/use-get-documents-overview.ts — OLD WAY
import { documentUseCases } from '@documents/application';
import { documentRepositories } from '@documents/infrastructure';
export const useGetDocumentsOverviewQuery = () => {
const getOverview = documentUseCases.getDocumentsOverview(documentRepositories);
return useCustomQuery({ useCase: getOverview, params: undefined });
};
You can still mock at the hexagon’s edge — the real use case runs against a fake repository — but the only seam is the module loader, so you reach for vi.mock:
const { getOverview } = vi.hoisted(() => ({
getOverview: vi.fn().mockResolvedValue([
{ categoryId: 'FISCAL', subcategories: [{ subcategoryId: 'IMPUESTOS', documentCount: 3 }] },
]),
}));
vi.mock('@documents/infrastructure', () => ({
documentRepositories: { getOverview },
}));
That works — the real getDocumentsOverview use case runs against the fake repo. The honest question is what it costs:
No composition root (vi.mock) | Composition root (inject) | |
|---|---|---|
| Injection seam | module loader (static) | function argument (dynamic) |
| What you fake | the repository singleton | a plain adapter |
| Hoisting gotchas | yes (vi.hoisted to close over vars) | none |
| Scope of the fake | whole test file | per test |
| Per-test variation | mockImplementationOnce + mockReset | fresh container, nothing to reset |
| Coupling to the test runner | Vitest module mocking is the mechanism | plain function calls — runner-agnostic |
| Real use-case logic runs | yes (only the repo is fake) | yes (only the adapter is fake) |
Note that last row: both approaches keep the use case real, because both fake at the repository layer — the getDocumentsOverview factory runs in either case.
So the genuine differences are the first six rows. They’re not huge per test, but they compound:
Every file repeats the hoisted vars and the module-mock dance, per-test variation needs a mockReset in beforeEach, and the whole thing is welded to Vitest. With the composition root the test is plain data — pass a fresh Application with a fresh fake; no module mocking, no hoisting, no resets, and the same test would work under any runner.
Advantage 2: swap an adapter in one place
The composition root is where the port-to-adapter mapping lives. Want to move documents from REST to GraphQL? One line — write a second adapter that satisfies the same port, change the wiring:
new DocumentsModule({
repositories: {
- documents: createDocumentRepository(httpClient),
+ documents: createGraphQLDocumentRepository(graphqlClient),
},
}),
Every hook, every use case, every component is untouched — because none of them named an adapter; they only ever saw the DocumentRepository port. And because each feature has its own module, different features can use different adapters for similar concerns, all declared side by side:
new Application('app', [
new DocumentsModule({
repositories: {
documents: createGraphQLDocumentRepository(gqlClient),
},
}),
new BillingModule({ repositories: { repository: createRestBillingRepository(httpClient) } }),
]);
Without a composition root, swapping a port’s adapter means editing every site that named it — and keeping them consistent by hand. The composition root collapses that to a single edit at the entry point, exactly as Seemann’s principle says.
Advantage 3: legal layering and multiple environments
Layering. Presentation never imports infrastructure; an adapter only arrives through the composition root → context. (Worth being precise: canonical clean architecture treats UI and infrastructure as sibling adapters, so a direct import isn’t strictly a Dependency-Rule violation — it’s a coupling smell. The hard “no” came from our project’s own architecture rules. The composition root satisfies that rule by construction.)
Environments. Standalone build, microfrontend build, fully mocked build — pick a builder, change nothing else. This is the original motivation in larger apps that ship the same features into different shells.
The trade-offs
It is not free:
- Ceremony. A
Modulesubclass per feature, a builder, a provider — boilerplate that buys nothing until you have a test seam, a second adapter, or another deployment environment. - Runtime lookup, not compile-time.
getModule(DocumentsModule)resolves through the module’s runtimeidand throws at runtime if wrong; a plainimportfails at compile time. You trade a static guarantee for flexibility. - Conceptual overhead for anyone new to the codebase.
Bottom line
The Composition Root earns its keep when you value:
- Fast, clean tests at the hexagon’s edge — fake the adapter, keep the use case real; and/or
- Cheap adapter swaps — one edit at the composition root, including different adapters per feature
- Multiple deployment targets — choose a builder.
The mental model that stuck with me: the composition root is the wall socket. Ports are the plugs on your appliances. Adapters are the power strips you choose between. Inside the hexagon, no one knows or cares which power plant is upstream — and that’s exactly the property Seemann’s “preferably unique location” exists to defend.
References
Cockburn, A. (2005). Hexagonal Architecture.
Seemann, M. (2011). Composition Root.
Seemann, M. (2011). Dependency Injection in .NET. Manning.
Seemann, M. & van Deursen, S. (2019). Dependency Injection Principles, Practices, and Patterns. Manning.