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.

published: reading time: 10 min read author: GeekWorkBench
Quick Summary

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

1. What problem does dependency injection solve?
It separates the code that uses a dependency from the code that chooses and constructs its implementation. That makes wiring explicit and lets tests replace infrastructure at a useful boundary. DI does not automatically improve design; a service that depends on a giant container still has hidden requirements.
2. Why is request state unsafe in a singleton service?
A singleton may serve several requests at once. If it stores a mutable current user or request ID, concurrent calls can overwrite each other's values. Keep request state local to the call or pass an immutable request context as an argument.
3. When would you use a DI container instead of manual wiring?
Use a container when a large object graph makes explicit setup repetitive or when bindings vary by environment. Keep resolution near the composition root. If application code asks the container for services during normal execution, it becomes a service locator and conceals dependencies.
4. How should an application handle a database transaction dependency?
Give the operation a transaction or unit-of-work scope whose lifetime matches that operation. Do not keep a transaction on an application singleton, and do not reuse one transaction across unrelated requests. Commit or roll back at a clear boundary and release the connection in either case.

Further Reading

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.

#backend #configuration #deployment

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.

#backend #background-jobs #worker-pools

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.

#backend #programming-languages #software-engineering