Dependency Injection Container
Decision
The pain point of duplicating code for wiring dependencies at the moment is not strong enough to justify the added complexity of adopting a DI container abstraction into our codebase.
Problems
Currently, adopting inversion of control (IoC) in our codebase often means that each entrypoint (e.g. Lambda handlers) needs to manually create and wire a large dependency graph.
This leads to repetitive, manual and difficult to coordinate work to inject all the dependencies:
- Adding a new dependency to a use case often requires touching multiple files across the orchestration layer.
- Wiring is easy to get subtly wrong (e.g. missing env vars, mismatched construction order, inconsistent configuration sources).
- The same shared infrastructure (clients, config, clocks, loggers, DB connections) is repeatedly instantiated across entrypoints.
- It is hard to keep wiring consistent across domains and services, which increases maintenance cost and slows down delivery.
Examples of this orchestration pain point can be seen in:
src/printProof/CreatePrintProofUploadUrlUseCase.init.ts(manual creation of AWS clients, config, and services)src/printProof/PrintProofInfra.CreatePrintProofHandler.ts(manual creation of AWS clients, repositories, and use cases)
Context
We have explored two working examples for dependency injection containers in code:
di-container-awilixdi-container-tsyringe
The goal of introducing a DI container is to reduce the manual dependency orchestration burden while still supporting our architectural goals (e.g. Hexagonal Architecture and Inversion of Control).
Options
The following packages were considered
Reasoning
The final decision is still TBD.
When choosing between the options, we will consider the following criteria:
- How well the container supports our preferred architecture (Hexagonal Architecture):
- Application core (use cases) should stay as DI-library-agnostic as feasible.
- Ports/adapters should be easy to wire without (or with minimum) spreading container-specific code through the domain.
- TypeScript ergonomics and correctness:
- Good typing for registrations/resolution
- Good developer experience when registering new dependencies
Consequences
How do we implement this change?
We will implement this change incrementally.
- Introduce a composition root per deployable unit (e.g. per use case/handler), responsible for constructing the container and registering shared dependencies.
- Register domain modules (e.g.
printProof/module.ts) from the composition root (e.g.dependencyInjection/container.ts). - Prefer explicit boundaries:
- Keep use cases wired via explicit factories so that the application core remains DI-library-agnostic.
- Use the DI container for ports and adapters wiring to avoid repetitive, manual entrypoint setup.
- Migrate existing entrypoints gradually:
- Start with one domain/module (e.g.
printProof) to establish conventions.
- Start with one domain/module (e.g.
Who will implement the change?
The entire team will implement this change incrementally.
How do we teach this change?
- Provide an example module (e.g.
printProof) as reference implementation. - Add learning journeys for DI container adoption.
- Run a short workshop.
What could go wrong?
- Introducing a DI container can hide wiring details, making runtime issues harder to debug if conventions are not clear.
- Circular dependencies can be introduced if container tokens/registrations are placed in modules that import each other.
- Depending on the approach, it can increase coupling to a specific library (especially if used directly inside the application core).
- Some DI approaches rely on runtime metadata/reflection, which can increase the risk of runtime-only failures if not consistently configured and/or performance impact due to container initialization.
What do we do if something goes wrong?
We will keep migrations small and reversible:
- Migrate entrypoints one by one.
- Keep the previous manual wiring approach working until the migration is complete.
- If the container adoption causes regressions, we can revert a given entrypoint back to manual wiring without requiring a full rollback of the entire service.
What is still unclear?
- Which DI container do we choose (Awilix vs Tsyringe)?
- Which conventions do we want to enforce for tokens/keys, module registrations, and composition roots?
- Do we allow decorator-based injection?
- Do we want integration tests to check dependencies are injected correctly?