Dependency Injection and State in Backend Services
Learn how dependency injection makes backend code easier to test, and how to separate shared services from request state without leaking data.
Dependency injection lets backend services receive databases, clocks, and clients from the application setup, which makes their requirements visible and their behavior easier to test. The guide distinguishes process-wide dependencies from request and operation state, then shows how to keep user identity out of shared mutable services. It also covers wiring choices, test boundaries, production failures, observability, and security. Use these patterns to choose clear dependency lifetimes without adding a container where simple functions are enough.
Dependency Injection and State in Backend Services
Introduction
A backend handler often needs a database, a logger, a clock, and a way to call another service. If it constructs these dependencies itself, tests must recreate the same environment and production changes become awkward. Dependency injection (DI) gives the handler its collaborators from the outside.
That does not mean every value belongs in a global container. A database pool may be shared for the lifetime of the process. An authenticated user belongs to one request. Confusing those lifetimes can turn a tidy-looking singleton into a data leak.
This article shows where to build the dependency graph, how to separate shared services from request state, and when DI helps or adds needless wiring. It also covers testing, production failure modes, observability, and security boundaries.
Without injection, a handler factory may create its own database adapter:
function createOrderHandler() {
const orders = new PostgresOrderRepository(
new PostgresDatabase(config.databaseUrl),
);
return (orderId: string) => orders.getOrder(orderId);
}
Pass the repository in instead, and a test can supply a fake while startup supplies the real adapter:
function createOrderHandler(orders: OrderRepository) {
return (orderId: string) => orders.getOrder(orderId);
}
Build the object graph at the application edge
The application startup code, often called the composition root, is where concrete implementations meet interfaces. Keep that wiring in one place so business code does not reach into a service locator at runtime.
const database = new PostgresDatabase(config.databaseUrl);
const orderRepository = new PostgresOrderRepository(database);
const clock = { now: () => new Date() };
const orderService = createOrderService({ orders: orderRepository, clock });
const app = createHttpApp({ orderService });
The diagram shows the direction: startup builds long-lived dependencies, then each request carries its own context into the handler.
graph TD
Config[Validated configuration] --> Root[Composition root]
Root --> Pool[Shared database pool]
Root --> Repo[Order repository]
Root --> Service[Order service]
Pool --> Repo
Repo --> Service
Service --> Handler[HTTP handler]
Request[Incoming request] --> Context[Request context]
Context --> Handler
Handler --> Response[HTTP response]
A dependency container can help with a large object graph, but a handful of explicit factory calls are often easier to follow. If constructors need an entire container instead of specific dependencies, the code has stopped showing what it actually uses.
Separate shared services from request state
A useful default is to classify values by lifetime:
| Lifetime | Examples | Typical owner |
|---|---|---|
| Process or application | Database pool, immutable configuration, metrics client | Composition root |
| Request | Request ID, authenticated principal, locale, cancellation signal | HTTP middleware or request context |
| Operation | Transaction, unit of work, temporary batch state | Handler or service method |
An application-scoped service can be reused only when concurrent calls are safe. A connection pool is built for concurrent use. A mutable “current user” field on a shared service is not.
interface RequestContext {
requestId: string;
userId: string | null;
signal: AbortSignal;
}
interface OrderService {
getOrder(orderId: string, context: RequestContext): Promise<Order | null>;
}
async function handleGetOrder(
request: Request,
context: RequestContext,
orders: OrderService,
): Promise<Response> {
const order = await orders.getOrder(readOrderId(request), context);
return order ? Response.json(order) : new Response(null, { status: 404 });
}
Request context is passed down only to code that needs it. Avoid putting the whole HTTP request into domain services; passing a user ID, transaction, or cancellation signal is clearer and easier to test.
When DI helps, and when it gets in the way
Use injection when a component needs infrastructure, when tests need to replace a boundary, or when configuration selects among implementations. It is especially useful around databases, clocks, outbound clients, message publishers, and file storage.
For a tiny script with one implementation and no meaningful test boundary, direct construction may be simpler. A DI framework can add more concepts than the application has dependencies. Start with plain parameters or constructors; add a container when manual wiring becomes repetitive, not merely because the framework supports one.
Production failures and ways to prevent them
A shared object keeps per-user data
A singleton service stores currentUserId before a call. Two overlapping requests can overwrite that field, so request B may run with request A’s identity. Keep request values in local variables or pass an immutable request context through the call chain.
A new pool is created for every request
Constructing a database client inside a handler creates connection churn and can exhaust the database’s connection limit. Create the pool during startup, set a maximum size and acquisition timeout, and close it during graceful shutdown.
A dependency is created before configuration is validated
The service starts with a missing URL or invalid timeout and fails on the first live request. Parse and validate configuration at startup. Fail the process with a clear message before accepting traffic.
A test double behaves unlike production
A fake repository may return data immediately and never reproduce transaction or uniqueness behavior. Keep unit fakes small, then cover database semantics with integration tests against a real engine or an isolated test instance. API testing approaches can help choose the right boundary.
Trade-offs in common wiring choices
| Approach | Good fit | Cost |
|---|---|---|
| Explicit constructor or function parameters | Small and medium applications; dependencies are easy to see | Wiring can become repetitive in a large graph |
| DI container | Large graphs with many environment-specific bindings | Adds container rules and can hide dependencies if resolved everywhere |
| Service locator | Rare, controlled integration points where construction is genuinely dynamic | Call sites hide requirements and runtime lookup errors are harder to trace |
| Global mutable state | Almost never for request data | Tests interfere with each other; concurrent requests can corrupt state |
Test behavior by replacing the boundary
A unit test can inject a fake repository and a fixed clock without booting a database:
const service = createOrderService({
orders: {
async findById(id) {
return id === "ord-7"
? { id, expiresAt: new Date("2027-01-01T00:00:00Z") }
: null;
},
},
clock: { now: () => new Date("2026-10-01T00:00:00Z") },
});
const result = await service.getOrder("ord-7");
if (!result) throw new Error("Expected an unexpired order");
This test checks the service rule and does not claim that SQL queries work. Keep that distinction: unit tests cover decisions; integration tests cover the repository against its database. The app’s wiring can have a small startup smoke test that verifies required dependencies are present.
Observability and safe dependency boundaries
Inject a logger, metrics client, or tracer where code needs to record behavior. Include the request ID in structured logs, and measure database pool wait time, dependency call duration, and failure counts. Avoid logging access tokens, full request bodies, or personal fields just because the request context is convenient to pass around.
For outbound clients, record the destination service name and operation, but do not attach secrets or unbounded payloads to spans. Keep instrumentation at the infrastructure adapter or service boundary so business decisions stay readable. A request context can carry a trace span or cancellation signal, but the application should define which layers may access it.
Security notes
Application state often contains identity and authorization data. Treat request context as trusted only after authentication middleware has built it, and keep authorization checks close to the operation they protect. Never use a mutable global principal or infer access rights from a client-supplied user ID alone.
Configuration is another injected dependency. Load secrets from the deployment’s secret mechanism, restrict access to the process that needs them, and avoid copying them into logs or diagnostic endpoints. Prefer narrow interfaces so a component cannot access unrelated secrets or infrastructure by asking for a general-purpose container.
Common mistakes
- Injecting the whole framework container into every class. This hides dependencies and makes tests depend on container setup.
- Making every dependency replaceable, even when there is no realistic alternative or test boundary. Interfaces are useful when they clarify a seam.
- Sharing mutable request data through singletons. Shared services should hold stable configuration or concurrency-safe clients.
- Passing the entire request object through the domain. Extract the values the use case needs.
- Creating a fresh database pool or HTTP client for each operation. Reuse managed clients with explicit limits and shutdown behavior.
- Mocking every layer in an integration test. Use real adapters where the test is meant to verify their behavior.
Quick Recap Checklist
- Build concrete dependencies in one composition root.
- Pass the smallest useful interface to each service.
- Reuse process-scoped clients only when they are safe for concurrent calls.
- Keep user identity and other request data out of shared mutable fields.
- Validate configuration before the server accepts traffic.
- Test business rules with fakes and infrastructure behavior with integration tests.
- Keep secrets and sensitive request data out of logs and traces.
Interview Questions
Further Reading
- Separation of concerns and module boundaries explains how to keep dependencies pointed toward stable application rules.
- Backend configuration, environments, and dependencies covers configuration ownership and managing dependencies across environments.
- API unit, integration, and contract testing compares test scopes that help verify injected components and their real adapters.
- Martin Fowler on Inversion of Control and Dependency Injection explains the pattern and its trade-offs.
Conclusion
Dependency injection works best when it makes ownership visible: startup creates shared infrastructure, services receive only what they use, and each request carries its own identity and cancellation state. Keep the setup plain until wiring becomes a real burden. Most importantly, make lifetimes explicit before sharing an object across concurrent requests.
Category
Related Posts
Backend Configuration, Environments, and Dependencies
Learn how backend services load configuration, separate development from production, validate settings, and manage dependencies without leaking secrets.
Background Jobs, Scheduling, and Worker Pools
Design background jobs and worker pools with bounded concurrency, safe retries, scheduling, and production checks that keep slow work out of request paths.
Choosing a Backend Programming Language
Compare Python, JavaScript, Java, Go, and C# for backend work, then choose a first language based on your goals, project needs, and learning path.