Back to Insights
dependency injectionJuly 25, 20268 min read

Dependency Injection Without Framework Magic

Plain constructors, factories, and one visible composition root provide dependency injection’s core benefits without hidden container behavior or framework coupling.

John Mather, Founder of Prime Axiom AI
John Mather

Founder, Prime Axiom AI

Published July 25, 2026

Visible dependency graph assembled from constructors and adapters
A composition root makes implementation choices and resource lifetimes explicit.

Dependency Injection Without Framework Magic

Dependency injection is a simple operation with an intimidating reputation: an object receives the collaborators it needs instead of constructing or locating them. Containers can automate that operation, but automation is not the idea. Starting with plain code exposes the dependency graph, clarifies lifetimes, and makes later framework choices informed rather than habitual.

This is a build-along tutorial. We will take an order-placement function that mixes policy and infrastructure, make dependencies explicit, assemble them at an application boundary, and then account for testing and production lifecycles.

Starting point: hidden construction

ts
export async function placeOrder(input: PlaceOrderInput): Promise<string> {
  const database = new PostgresOrderRepository(process.env.DATABASE_URL!);
  const payments = new AcmePayments(process.env.ACME_API_KEY!);
  const email = new SmtpEmailSender(process.env.SMTP_URL!);

  const order = Order.create(input);
  const authorization = await payments.charge(order.total, input.cardToken);
  await database.save(order.withAuthorization(authorization.id));
  await email.sendReceipt(order.customerEmail, order.id);
  return order.id;
}

This block demonstrates a dependency graph hidden inside business behavior. The function chooses vendors, reads process configuration, establishes lifetime, and performs the use case. A unit test must either hit infrastructure or intercept constructors. Changing payment providers modifies order policy code.

The first move is not a container. It is naming the capabilities the use case consumes:

ts
interface OrderRepository {
  save(order: Order): Promise<void>;
}

interface PaymentGateway {
  authorize(request: AuthorizationRequest): Promise<Authorization>;
}

interface ReceiptSender {
  send(receipt: Receipt): Promise<void>;
}

This code demonstrates client-owned contracts. They use order language rather than vendor request types. Each should document retries, idempotency, and failure categories; Interfaces as Software Contracts covers those semantics in depth.

Constructor injection for required collaborators

ts
export class PlaceOrder {
  constructor(
    private readonly orders: OrderRepository,
    private readonly payments: PaymentGateway,
    private readonly receipts: ReceiptSender,
    private readonly clock: Clock,
  ) {}

  async execute(input: PlaceOrderInput): Promise<string> {
    const order = Order.create(input, this.clock.now());
    const authorization = await this.payments.authorize({
      amount: order.total,
      paymentMethodToken: input.cardToken,
      idempotencyKey: order.id,
    });

    order.recordAuthorization(authorization.id);
    await this.orders.save(order);
    await this.receipts.send(Receipt.from(order));
    return order.id;
  }
}

The constructor demonstrates explicit required dependencies. No valid PlaceOrder exists without them. The clock is injected because current time influences domain behavior, not because every built-in function needs wrapping. Configuration values such as API keys are not passed into the use case; they configure edge adapters.

Property injection creates temporarily invalid objects, so reserve it for genuinely optional concerns. Method injection is useful when a dependency belongs to one invocation, such as a transaction-scoped writer or request identity. Constructor injection should be the default for stable requirements.

The composition root

A composition root is the small place where concrete implementations are chosen and the graph is built. In a Node HTTP service it can live in startup:

ts
export function buildApplication(config: Config): Application {
  const pool = new Pool({
    connectionString: config.databaseUrl,
    max: config.databasePoolSize,
  });

  const orders = new PostgresOrderRepository(pool);
  const paymentClient = new AcmePaymentClient({
    apiKey: config.paymentApiKey,
    timeoutMs: 2_000,
  });
  const payments = new AcmePaymentGateway(paymentClient);
  const receipts = new QueuedReceiptSender(messagePublisher);
  const placeOrder = new PlaceOrder(orders, payments, receipts, new SystemClock());

  return new Application({ placeOrder, health: new HealthChecks(pool) });
}

This block demonstrates manual wiring and lifecycle ownership. One database pool is shared rather than created per order. The low-level SDK is wrapped by an adapter. The business use case sees none of the environment parsing or vendor construction.

mermaid
flowchart TD
  Startup[Composition Root] --> UseCase[PlaceOrder]
  Startup --> Repo[PostgresOrderRepository]
  Startup --> Gateway[AcmePaymentGateway]
  Startup --> Sender[QueuedReceiptSender]
  UseCase --> Repo
  UseCase --> Gateway
  UseCase --> Sender

The diagram demonstrates that construction dependencies originate at the edge while runtime calls flow through use-case abstractions. Dependency Injection is the assembly action; Dependency Inversion is the design rule that points policy toward stable contracts.

Configuration is input, not a locator

Parse and validate environment values once:

ts
function loadConfig(env: NodeJS.ProcessEnv): Config {
  const databaseUrl = required(env, 'DATABASE_URL');
  const paymentApiKey = required(env, 'PAYMENT_API_KEY');
  const databasePoolSize = positiveInteger(env.DB_POOL_SIZE ?? '10');

  return Object.freeze({ databaseUrl, paymentApiKey, databasePoolSize });
}

This code demonstrates fail-fast configuration. A missing key stops startup with a useful message rather than failing during the first customer request. Secrets remain at the infrastructure edge. Avoid injecting Config everywhere; that turns a typed object into a service locator. Pass each adapter the values it owns.

Lifetimes are part of correctness

Manual injection forces a useful question: who creates and disposes each object?

LifetimeExamplesMain concern
Applicationconnection pool, HTTP client, metrics registrybounded resources and shutdown
Requestauthorization context, unit of workno cross-request leakage
Operationaggregate, command handler statedeterministic isolation
Background joblease, trace, transactioncancellation and retries

The table demonstrates that “singleton” and “transient” are operational decisions. A singleton mutable repository fake can leak state between tests. A transient HTTP client can exhaust sockets. A request-scoped transaction used by a background callback may already be closed.

Return a disposal function or use language lifecycle constructs:

ts
async function main(): Promise<void> {
  const config = loadConfig(process.env);
  const app = buildApplication(config);

  const shutdown = async () => {
    await app.stopAcceptingRequests();
    await app.drain();
    await app.close();
  };

  process.once('SIGTERM', shutdown);
  await app.start();
}

This demonstrates ownership all the way to graceful shutdown. The object that builds long-lived resources arranges their disposal in reverse dependency order.

Test with purposeful substitutes

ts
it('does not persist when authorization is declined', async () => {
  const orders = new RecordingOrderRepository();
  const payments = new StubPaymentGateway({
    result: { kind: 'declined', reason: 'insufficient_funds' },
  });
  const receipts = new RecordingReceiptSender();
  const useCase = new PlaceOrder(
    orders,
    payments,
    receipts,
    new FixedClock('2026-07-25T12:00:00Z'),
  );

  await expect(useCase.execute(validInput)).rejects.toMatchObject({
    code: 'PAYMENT_DECLINED',
  });
  expect(orders.saved).toHaveLength(0);
  expect(receipts.sent).toHaveLength(0);
});

The test demonstrates why explicit injection matters: it controls meaningful external outcomes without patching globals. The fake repository records observable writes; the stub gateway supplies a planned response. It does not verify private call order.

Not every dependency should be mocked. Value objects and deterministic policy can be real. Run shared contract tests against recording fakes and production repositories so substitutes do not drift. Use integration tests for SQL mappings, SDK serialization, TLS, and queue configuration.

Decorators add cross-cutting behavior visibly

Retries, metrics, and caching are often reasons teams reach for framework interceptors. Plain decorators keep order explicit:

ts
class MeteredPaymentGateway implements PaymentGateway {
  constructor(
    private readonly inner: PaymentGateway,
    private readonly metrics: Metrics,
  ) {}

  async authorize(request: AuthorizationRequest): Promise<Authorization> {
    const started = performance.now();
    try {
      const result = await this.inner.authorize(request);
      this.metrics.count('payment_authorization', { outcome: result.kind });
      return result;
    } finally {
      this.metrics.observe('payment_latency_ms', performance.now() - started);
    }
  }
}

This code demonstrates composition around a contract. Startup can wrap AcmePaymentGateway with metrics and a carefully bounded retry decorator. Ordering remains reviewable. Retries must honor idempotency and deadlines; hiding them in annotations can make dangerous behavior too easy.

Smells revealed by manual wiring

A PlaceOrder constructor with twelve arguments is not proof that injection failed. It reveals that the class coordinates too much or the use case boundary is unclear. Grouping dependencies into a Dependencies bag only hides the evidence. Revisit responsibilities.

Circular dependencies are another design signal. If Pricing needs Orders and Orders needs Pricing, initialization tricks are less valuable than clarifying ownership or introducing a one-way domain event. A container may resolve cycles through lazy proxies, but runtime surprise replaces compile-time discomfort.

The service locator anti-pattern passes a registry and allows code to request arbitrary services. Dependencies vanish from signatures, tests require global setup, and runtime errors replace type checking. Global module imports can behave similarly when they carry mutable resources.

Rule of thumb: if a unit can acquire a collaborator that its constructor or method did not disclose, the graph is hidden.

When a container becomes worthwhile

A container can reduce repetitive wiring in a large application, provide scopes, validate graphs, and integrate framework components. Microsoft's .NET dependency injection guidance describes constructor injection, service lifetimes, and disposal. Angular's dependency injection guide shows hierarchical injectors suited to its component model.

Adopt automation after the lifetimes and boundaries are understood. Prefer explicit registrations. Enable startup validation. Keep container APIs out of domain and application modules. Avoid name-based magic and reflection rules that make construction hard to search. A healthy container replaces the composition root's mechanics, not the architecture.

Comparison:

ApproachVisibilityBest fitFrequent failure
Direct constructorshighestsmall and medium servicesrepetitive assembly
Explicit container registrationhighlarger graphs/scopesregistration drift
Auto-scanninglowconvention-heavy frameworkssurprising resolution
Service locatorvery lowalmost neverhidden runtime dependency

This table demonstrates a trade-off curve, not a maturity ladder. Many substantial systems remain clearer with factories divided by feature.

Production-readiness checklist

  • [ ] Validate configuration before opening network listeners.
  • [ ] Share pools and clients according to documented lifetimes.
  • [ ] Make request-scoped data impossible to retain globally.
  • [ ] Propagate cancellation and deadlines through contracts.
  • [ ] Close resources during graceful shutdown.
  • [ ] Instrument adapters and use cases with low-cardinality outcomes.
  • [ ] Validate the full graph in a startup smoke test.
  • [ ] Exercise real adapters in integration and contract suites.

The non-magical conclusion

Dependency injection needs only three things: a module that declares what it requires, an outside location that chooses implementations, and explicit ownership of lifetimes. Constructors and factories provide all three. Framework containers can later automate repetition, but they should not decide boundaries or conceal coupling.

Build one use case manually. Let awkward constructors expose responsibility problems. Let composition roots expose resource lifetimes. Let contract tests expose fake behavior that differs from production. The result is not anti-framework; it is framework-independent understanding. Pair this technique with Composition Over Inheritance, Clean Architecture Boundaries, and the implementation references in /resources.

Further reading

Apply the idea

Turn technical clarity into a working system.

Talk with Prime Axiom about architecture, AI automation, integrations, and the operational workflow behind your next build.

Continue reading

Business bounded contexts connected through explicit event contracts
Practical bounded contexts align language, invariants, data, and team ownership.
domain-driven design

July 25, 2026 · 9 min read

Domain-Driven Design: Finding Practical Business Boundaries

A field guide to discovering bounded contexts through language, invariants, workflows, data ownership, and organizational evidence—without turning DDD into modeling ceremony.

By John Mather
Read article →