Un composition root para arquitectura hexagonal en React
- Cómo lo llama Seemann
- El problema que resuelve el composition root
- Las cuatro piezas del patrón
- 1. Module: una caja con nombre para los adaptadores de un dominio
- 2. Application: un registro de módulos, accesible por tipo de módulo
- 3. Un módulo por funcionalidad: contiene los adaptadores que satisfacen los puertos de esa funcionalidad
- 4. El composition root en sí: el único lugar donde cada puerto recibe un adaptador
- Cómo fluye una petición en tiempo de ejecución
- Ventaja 1: un test seam limpio (cambia el adaptador, no el caso de uso)
- Ventaja 2: cambiar un adaptador en un solo lugar
- Ventaja 3: capas limpias y múltiples entornos
- Los costes del patrón
- Cuándo usar un composition root
- Referencias
Trabajo en una aplicación React organizada con arquitectura hexagonal (lo que Alistair Cockburn llamó ports and adapters en 2005). Cada funcionalidad se divide en domain, application, infrastructure y presentation. El dominio define puertos —interfaces como DocumentRepository— y la capa de infraestructura proporciona adaptadores que los implementan, como createDocumentRepository(httpClient). Las dependencias siempre apuntan hacia dentro; el dominio nunca sabe qué base de datos o cliente HTTP hay detrás de un puerto.
Esa organización por capas plantea una pregunta: ¿dónde y cuándo se conecta un puerto a su adaptador? De eso trata este artículo. La respuesta tiene un nombre bien conocido en la literatura de inyección de dependencias —el composition root de Mark Seemann— y adoptarlo me dio un test seam limpio, un único lugar para cambiar implementaciones y un manifiesto legible de todas las decisiones de infraestructura que toma la aplicación.
Cómo lo llama Seemann
El patrón lo nombró Mark Seemann en Dependency Injection in .NET (Manning, 2011) y lo desarrolló junto a Steven van Deursen en Dependency Injection Principles, Practices, and Patterns (Manning, 2019). Su definición original:
A Composition Root is a (preferably) unique location in an application where modules are composed together.
MARK SEEMANN
Composition Root (2011)
Dos principios adicionales de la misma fuente definen cómo aplicarlo:
- «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.»
Traducido al lenguaje de ports and adapters: el composition root es el único lugar que decide qué adaptador conecta con qué puerto. Todo el código del núcleo de la arquitectura —casos de uso, lógica de dominio, presentación— solo trabaja con puertos; ninguna pieza del núcleo conoce ni referencia un adaptador concreto. Si los puertos son los enchufes de los electrodomésticos, el composition root es el enchufe de la pared: un único punto que decide qué hay al otro lado del cable.
El problema que resuelve el composition root
Un caso de uso no debería saber si el adaptador detrás de su puerto es un cliente REST real, uno GraphQL o un mock. Pero algo tiene que decidirlo y para ello existen dos opciones:
- Inyección en tiempo de importación: el caso de uso se construye en el momento en que se importa el módulo, vinculado a un adaptador concreto dentro de un
import. La composición queda dispersa en cada fichero que realiza esa importación. - Inyección en tiempo de ejecución: cada adaptador se instancia una vez al arrancar, en una única función, y se transporta por el árbol de React en un pequeño contenedor. Los casos de uso se construyen bajo demanda, pidiendo al contenedor el puerto correspondiente.
La segunda opción es, precisamente, el composition root. Todo lo que viene a continuación son los mecanismos que hacen posible esa elección.
Las cuatro piezas del patrón
1. Module: una caja con nombre para los adaptadores de un dominio
export abstract class Module {
readonly name: string;
protected constructor(name: string) {
this.name = name;
}
}
Module es deliberadamente ligero: un contenedor con nombre y sin lógica de negocio. Su trabajo es dar a cada funcionalidad una identidad estable que Application pueda buscar por clase.
2. Application: un registro de módulos, accesible por tipo de módulo
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;
}
}
getModule hace una sola cosa: encontrar el módulo correcto por su id estático y devolverlo con el tipo esperado. El throw es intencionado: los registros incorrectos deben fallar ruidosamente al arrancar, no en silencio cuando un componente hace su primera petición.
3. Un módulo por funcionalidad: contiene los adaptadores que satisfacen los puertos de esa funcionalidad
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;
}
}
El módulo posee sus dependencias detrás de una interfaz tipada. Quien lo consulte pedirá documents.repositories.documents —un DocumentRepository— sin saber ni importarle si está respaldado por REST, GraphQL o un fake en memoria.
4. El composition root en sí: el único lugar donde cada puerto recibe un adaptador
// composition.ts — la "ubicación preferiblemente única" de Seemann
export const createApplication = (): Application =>
new Application('gestor-documental', [
new DocumentsModule({
repositories: {
// PUERTO (DocumentRepository) <- ADAPTADOR (createDocumentRepository) -- se decide AQUÍ
documents: createDocumentRepository(httpClient),
},
}),
]);
Y un contexto de React para transportar el contenedor ensamblado por el árbol. El «lo más cerca posible del punto de entrada» de Seemann significa 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 — el punto de entrada. La composición ocurre una vez, aquí.
const application = createApplication();
createRoot(document.getElementById('root')!).render(
<ApplicationProvider application={application}>
<App />
</ApplicationProvider>
);
Cómo fluye una petición en tiempo de ejecución
La forma más fácil de seguir el flujo es separar el momento de composición del momento de petición:
| Momento | Qué ocurre | Qué significa |
|---|---|---|
| Arranque | createApplication() se ejecuta en main.tsx. | El composition root corre una vez, cerca del punto de entrada. |
| Arranque | new DocumentsModule({ repositories }) recibe los adaptadores reales. | Los adaptadores reales se eligen aquí, no dentro de un hook. |
| Arranque | <ApplicationProvider application={app}> mete el contenedor en el contexto de React. | React transporta el contenedor sin que la infraestructura se importe en otro lugar. |
| Petición | useGetDocumentsOverviewQuery() obtiene DocumentsModule, construye el caso de uso y lo pasa a React Query. | El hook usa un módulo ya conectado; React Query gestiona el estado asíncrono pero no elige el adaptador. |
En el composition.ts real, la parte importante es este fragmento:
export const createApplication = (): Application =>
new Application('gestor-documental', [
new DocumentsModule({
repositories: {
documents: createDocumentRepository(httpClient),
},
}),
]);
Esa es la decisión del adaptador. El DocumentsModule recibe adaptadores de infraestructura concretos, pero el resto de la aplicación solo le pedirá sus puertos.
El hook de consulta usa el módulo ya conectado:
export const useGetDocumentsOverviewQuery = () => {
const application = useApplication();
const documents = application.getModule(DocumentsModule);
const useCase = getDocumentsOverview({ repository: documents.repositories.documents });
return useCustomQuery({ useCase, params: undefined });
};
El detalle importante es el momento. El adaptador se elige al arrancar, no dentro del hook. Cuando un componente llama a useGetDocumentsOverviewQuery, el hook solo está recuperando un módulo ya conectado y construyendo el caso de uso a partir de puertos. React Query ejecuta el trabajo asíncrono, pero no decide qué adaptador de infraestructura usa la aplicación.
El hook importa application (la fábrica de casos de uso) y domain (los tipos de puerto y el módulo), nunca infrastructure. La arquitectura permanece sellada; el adaptador solo aparece en composition.ts.
Ventaja 1: un test seam limpio (cambia el adaptador, no el caso de uso)
Este es el beneficio más tangible en el trabajo cotidiano. Como el adaptador llega a través del composition root, un test construye una segunda Application con un adaptador mock para el mismo puerto, sin necesidad de vi.mock.
const repository = createMockDocumentRepository({
getOverview: async () => [
{ categoryId: 'FISCAL', subcategories: [{ subcategoryId: 'IMPUESTOS', documentCount: 3 }] },
],
});
const { result } = renderHookInApp(
() => useGetDocumentsOverviewQuery(),
createDocumentsTestApplication(repository) // un segundo composition root, con un adaptador falso
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.data).toHaveLength(1);
Compara con la versión que inyecta en tiempo de importación, donde el hook importa una fábrica de casos de uso:
// presentation/queries/use-get-documents-overview.ts — FORMA ANTIGUA
import { documentUseCases } from '@documents/application';
import { documentRepositories } from '@documents/infrastructure';
export const useGetDocumentsOverviewQuery = () => {
const getOverview = documentUseCases.getDocumentsOverview(documentRepositories);
return useCustomQuery({ useCase: getOverview, params: undefined });
};
Con ese enfoque sigues pudiendo hacer mock en la frontera entre capas —el caso de uso real corre contra un repositorio falso— pero el único seam es el cargador de módulos, así que recurres a vi.mock:
const { getOverview } = vi.hoisted(() => ({
getOverview: vi.fn().mockResolvedValue([
{ categoryId: 'FISCAL', subcategories: [{ subcategoryId: 'IMPUESTOS', documentCount: 3 }] },
]),
}));
vi.mock('@documents/infrastructure', () => ({
documentRepositories: { getOverview },
}));
Eso funciona: el getDocumentsOverview real opera contra el repositorio falso. La pregunta clave es qué cuesta:
Sin composition root (vi.mock) | Con composition root (inyección) | |
|---|---|---|
| Seam de inyección | cargador de módulo (estático) | argumento de función (dinámico) |
| Qué falsificas | el repositorio singleton | un adaptador plano |
| Problemas con hoisting | sí (vi.hoisted para capturar vars) | ninguno |
| Alcance del fake | todo el fichero de test | por test |
| Variación por test | mockImplementationOnce + mockReset | contenedor nuevo, nada que resetear |
| Acoplamiento al test runner | el module mocking de Vitest es el mecanismo | llamadas a funciones normales, agnóstico al runner |
| La lógica del caso de uso corre de verdad | sí (solo el repositorio es falso) | sí (solo el adaptador es falso) |
Esa última fila es clave: los dos enfoques mantienen el caso de uso real, porque los dos falsifican en la capa del repositorio.
Las diferencias reales están en las primeras seis filas. No son enormes por test, pero se acumulan: cada fichero repite las variables en hoisting y el baile del module mock, la variación por test necesita un mockReset en beforeEach, y todo está soldado a Vitest. Con el composition root el test son datos puros: pasa una Application nueva con un fake nuevo; sin module mocking, sin hoisting, sin resets, y el mismo test funcionaría con cualquier runner.
Ventaja 2: cambiar un adaptador en un solo lugar
El composition root es donde vive el mapa puerto-adaptador. ¿Quieres mover documentos de REST a GraphQL? Escribe un segundo adaptador que satisfaga el mismo puerto y actualiza la conexión en el composition root:
new DocumentsModule({
repositories: {
- documents: createDocumentRepository(httpClient),
+ documents: createGraphQLDocumentRepository(graphqlClient),
},
}),
Cada hook, cada caso de uso, cada componente permanece intacto, porque ninguno de ellos nombra un adaptador; solo han visto el puerto DocumentRepository. Y como cada funcionalidad tiene su propio módulo, distintas funcionalidades pueden usar distintos adaptadores para preocupaciones similares, todos declarados uno al lado del otro:
new Application('app', [
new DocumentsModule({
repositories: {
documents: createGraphQLDocumentRepository(gqlClient),
},
}),
new BillingModule({ repositories: { repository: createRestBillingRepository(httpClient) } }),
]);
Sin un composition root, cambiar el adaptador de un puerto implica editar cada punto que lo nombra y mantenerlos consistentes a mano. El composition root lo colapsa a una única edición en el punto de entrada, exactamente como dice el principio de Seemann.
Ventaja 3: capas limpias y múltiples entornos
Capas. La capa de presentation nunca importa directamente de la infraestructura; un adaptador solo llega a través del composition root → contexto. Una precisión: la arquitectura limpia canónica trata la UI y la infraestructura como adaptadores hermanos, así que una importación directa no viola estrictamente la regla de dependencias; es, en todo caso, un problema de acoplamiento. La prohibición vino de las reglas de arquitectura propias del proyecto. El composition root cumple esa regla por construcción.
Entornos. Un entorno independiente, uno configurado como microfrontend y otro completamente mockeado: eliges un constructor y no cambias nada más. Esta fue la motivación original en aplicaciones más grandes que publican las mismas funcionalidades en distintas shells.
Los costes del patrón
Adoptar este patrón tiene costes reales:
- Ceremony. El patrón exige crear una subclase de
Modulepor cada funcionalidad, más un constructor y un proveedor específicos. Es código que no justifica su existencia de forma inmediata; su utilidad solo se hace evidente cuando llega el momento de testear en el límite de la arquitectura, cambiar de adaptador o gestionar múltiples entornos. - Resolución en runtime, no en compilación.
getModule(DocumentsModule)resuelve por elidde runtime del módulo y lanza en runtime si algo está mal; unimportnormal falla en tiempo de compilación. Cambias una garantía estática por flexibilidad. - Sobrecarga conceptual para cualquiera que llegue nuevo al proyecto.
Cuándo usar un composition root
El composition root es una solución con complejidad real. Antes de adoptarlo, conviene valorar si la necesitas:
- Tests rápidos y limpios en el límite de la arquitectura: puedes sustituir el adaptador por un fake sin tocar el caso de uso ni recurrir a module mocking.
- Cambios de adaptador baratos: una edición en el composition root es suficiente, incluyendo adaptadores distintos por funcionalidad.
- Múltiples entornos de despliegue: cada entorno elige su propio constructor.
La imagen que mejor me ayuda a entenderlo es la del enchufe de la pared. El composition root es ese enchufe; los puertos son las clavijas de los electrodomésticos, y los adaptadores son las regletas que eliges entre medias. Dentro del núcleo de la arquitectura, nadie sabe ni le importa qué central eléctrica hay corriente arriba. Esa es exactamente la propiedad que la «ubicación preferiblemente única» de Seemann existe para defender.
Referencias
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.