Composition Root and Failure Boundaries
Dependency Injection with a Composition Root is a pattern where objects do not create their own dependencies directly; instead, the dependency graph is assembled at the application's startup point and the resulting objects are passed to wherever they are needed. A service declares "what it needs" through its constructor or function parameters, while the outer assembly layer decides "what to actually wire in."
Practice memo · 36 min read · Medium
Dependency Injection with a Composition Root is a pattern where objects do not create their own dependencies directly; instead, the dependency graph is assembled at the application's startup point and the required objects are passed in. The key is to avoid placing creation responsibilities like new EmailClient(...), new Clock(...), or new Logger(...) inside business services. A service declares what it needs through its constructor or function parameters, while the outer assembly layer decides what to actually wire in.
The intent of this pattern is to separate creation policy from execution policy. If a payment receipt delivery service creates an SMTP client, template renderer, current time, and repository internally, testing becomes difficult and swapping out production components becomes equally painful. By contrast, if the service accepts only abstract dependencies such as EmailSender, ReceiptRenderer, and Clock, you can inject real implementations in production and fake implementations in tests.
Martin Fowler describes Dependency Injection (DI) as a style in which a component receives its dependencies from the outside rather than looking them up or creating them itself, contrasting it with the Service Locator pattern.1 Fowler's central distinction is "where does the dependency come from?": with DI, dependencies are passed in from outside, whereas with Service Locator, the object actively looks them up through a global registry or locator. The term "Composition Root" itself was not coined by Fowler but by Mark Seemann; it refers to the starting boundary where the object graph is assembled.2 That said, "exactly one, globally" is not the rule. A web server, a CLI tool, a worker, a migration tool, and a test host can each have their own Composition Root. More precisely, the principle is one Composition Root per execution entry point, with the constraint that assembly responsibility must not be scattered within a given execution unit.
The official .NET documentation also explains that services can be registered with transient, scoped, or singleton lifetimes in dependency injection, and that the appropriate lifetime must be chosen for each service.3 In addition, .NET DI resolves dependencies through constructor injection by inspecting a public constructor. In other words, DI is not simply about "using more interfaces"; it is a pattern that requires designing together the location of dependency creation, service lifetime, testability, and execution boundaries.
That said, being able to choose a service lifetime is a convenience and a risk at the same time. Choosing among singleton, scoped, and transient may look like a simple configuration decision, but it is actually an architectural judgment about an object's lifespan and the scope over which state is shared.
In particular, incorrect lifetime configuration can produce a Captive Dependency. For example, if a singleton service holds on to a scoped dependency, state that should be isolated per request becomes trapped inside a longer-lived object. This can cause per-user state to bleed across requests, resources that should have been disposed to linger, or production failures that never showed up in tests.
Put another way, the fact that a DI container lets you choose lifetimes means there are that many more places to choose incorrectly. This is usually not a syntax problem but an experience problem.
Personal note: the reason we go through all this is ultimately that business rules must stay sound regardless of everything else. Whether SMTP goes down, the clock shifts, a config file gets corrupted, or a fake is injected in a test, the core domain logic must do its job consistently. The moment you decide to center your design on business logic, a whole host of techniques follow.
DI, Composition Root, Result types, creation boundaries, lifetime policy, and the prohibition on Service Locator are all byproducts of that decision.
The goal is not to make objects look elegant; it is to keep business logic away from the mud of the outside world. It feels odd to say this while writing pattern flash cards, but all of these techniques are derived from the single aim of keeping business logic pristine.
Core formula to remember:
Dependency Injection = instead of creating what you need internally, you receive it from outside
Composition Root = the object graph is assembled at the application's entry point
Constructor Boundary = required dependencies are made explicit in the constructor
Lifetime Policy = singleton / scoped / transient service lifetimes are determined at the composition layer
No Service Locator = runtime objects do not query a global registry directly
Predictable Failure = predictable failures such as validation are more clearly expressed as a Result type rather than as exceptions.1. The problem scenario
Suppose we are building a service that sends a receipt email to a customer when an order payment is completed.
The required responsibilities are as follows.
Renders the order information as receipt HTML.
Sends the email.
Inserts the current timestamp into the receipt.
Returns a success result.In a bad design, the service creates its dependencies directly. A senior colleague I used to work with called this "Korean-style dependency" (he was someone who liked attaching the label "Korean programming style" to every bad convention he encountered).
I personally think of this as a delivery-driven dependency: the kind of hardwired dependency that appears in small contract projects where the first goal is simply to make the demo work.
ReceiptEmailService
-> new SystemClock()
-> new HtmlReceiptRenderer()
-> new SmtpEmailSender()When structured this way, it becomes difficult to suppress SMTP sending in tests, hard to fix the current time, and hard to swap out the renderer. This card covers a single domain: assembling a receipt email sending service at the Composition Root.
Core Expressions
The common example is ReceiptEmailService.
ReceiptEmailService = orchestration
ReceiptRenderer = order → HTML conversion
EmailSender = email I/O
Clock = current time dependency
Composition Root = assembles actual implementation objectsA single validation policy is applied uniformly across the entire card. Predictable failures such as input validation are returned as a Result rather than thrown as exceptions, and that validation is placed at the creation boundary (factory) where OrderReceipt is constructed, not inside the service's execution logic. As a result, ReceiptEmailService receives only already-valid OrderReceipt instances and focuses solely on execution. Only unrecoverable programming mistakes, such as wiring errors (null dependencies), are blocked with exceptions in the constructor.
C#
using System;
using System.Threading;
using System.Threading.Tasks;
public enum ReceiptError
{
EmptyOrderId,
EmptyCustomerEmail,
NegativeTotal,
}
public readonly struct Result<TValue, TError>
{
private readonly TValue value;
private readonly TError error;
private readonly bool isOk;
private readonly bool isInitialized;
private Result(
bool isOk,
TValue value,
TError error)
{
this.isOk = isOk;
this.value = value;
this.error = error;
this.isInitialized = true;
}
public bool IsOk
{
get
{
EnsureInitialized();
return this.isOk;
}
}
public static Result<TValue, TError> Ok(TValue value)
{
return new Result<TValue, TError>(true, value, default!);
}
public static Result<TValue, TError> Fail(TError error)
{
return new Result<TValue, TError>(false, default!, error);
}
public bool TryGetValue(out TValue value)
{
EnsureInitialized();
value = this.value;
return this.isOk;
}
public TResult Match<TResult>(
Func<TValue, TResult> onOk,
Func<TError, TResult> onFail)
{
EnsureInitialized();
if (this.isOk)
{
return onOk(this.value);
}
return onFail(this.error);
}
private void EnsureInitialized()
{
if (!this.isInitialized)
{
throw new InvalidOperationException(
"Result has not been initialized. It must be created with Ok or Fail.");
}
}
}
public sealed record OrderReceipt
{
public string OrderId { get; }
public string CustomerEmail { get; }
public long TotalCents { get; }
private OrderReceipt(
string orderId,
string customerEmail,
long totalCents)
{
this.OrderId = orderId;
this.CustomerEmail = customerEmail;
this.TotalCents = totalCents;
}
public static Result<OrderReceipt, ReceiptError> TryCreate(
string orderId,
string customerEmail,
long totalCents)
{
if (string.IsNullOrWhiteSpace(orderId))
{
return Result<OrderReceipt, ReceiptError>.Fail(ReceiptError.EmptyOrderId);
}
if (string.IsNullOrWhiteSpace(customerEmail))
{
return Result<OrderReceipt, ReceiptError>.Fail(ReceiptError.EmptyCustomerEmail);
}
if (totalCents < 0)
{
return Result<OrderReceipt, ReceiptError>.Fail(ReceiptError.NegativeTotal);
}
OrderReceipt receipt = new OrderReceipt(
orderId,
customerEmail,
totalCents);
return Result<OrderReceipt, ReceiptError>.Ok(receipt);
}
}
public sealed record SendReceiptResult(
string OrderId,
string CustomerEmail,
DateTimeOffset SentAt);
public interface ReceiptRenderer
{
string RenderHtml(
OrderReceipt receipt,
DateTimeOffset issuedAt);
}
public interface EmailSender
{
ValueTask SendAsync(
string toEmail,
string subject,
string htmlBody,
CancellationToken cancellationToken);
}
public interface Clock
{
DateTimeOffset GetUtcNow();
}
public sealed class ReceiptEmailService
{
private readonly ReceiptRenderer renderer;
private readonly EmailSender emailSender;
private readonly Clock clock;
public ReceiptEmailService(
ReceiptRenderer renderer,
EmailSender emailSender,
Clock clock)
{
this.renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
this.emailSender = emailSender ?? throw new ArgumentNullException(nameof(emailSender));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public async ValueTask<SendReceiptResult> SendAsync(
OrderReceipt receipt,
CancellationToken cancellationToken)
{
DateTimeOffset issuedAt = this.clock.GetUtcNow();
string htmlBody = this.renderer.RenderHtml(receipt, issuedAt);
await this.emailSender.SendAsync(
receipt.CustomerEmail,
$"Receipt for order {receipt.OrderId}",
htmlBody,
cancellationToken);
return new SendReceiptResult(
receipt.OrderId,
receipt.CustomerEmail,
issuedAt);
}
}
public sealed class SystemClock : Clock
{
public DateTimeOffset GetUtcNow()
{
return DateTimeOffset.UtcNow;
}
}
public sealed class HtmlReceiptRenderer : ReceiptRenderer
{
public string RenderHtml(
OrderReceipt receipt,
DateTimeOffset issuedAt)
{
return $"<h1>Order {receipt.OrderId}</h1><p>Total: {receipt.TotalCents}</p><p>{issuedAt:O}</p>";
}
}(The example builds HTML with string interpolation for simplicity, but in production, any value derived from user input must be HTML-escaped or processed through a template engine's escape feature.)
OrderReceipt exposes only a private constructor and a TryCreate factory, so an invalid state cannot be created in the first place. Validation failures are returned as Result<OrderReceipt, ReceiptError> and handled at the call boundary. A null dependency, by contrast, is a dependency assembly error and is therefore blocked in the constructor with an ArgumentNullException. These two are fundamentally different kinds of failures.
One pitfall is that Result is a readonly struct, meaning it can also be initialized via default(Result<...>). This state is a value-type default initialization that bypasses Ok/Fail, violating the Result construction contract (structs cannot prevent a parameterless default constructor). In that case, the internal isOk is false and error becomes the type's default value, so if Match is called on it, the code either silently returns a spurious failure that never occurred (or throws a NullReferenceException if error is a reference type) or takes the wrong branch. The isInitialized flag is therefore used to detect this: using an uninitialized Result immediately surfaces an InvalidOperationException. Because this is an unrecoverable programming error, the exception must be exposed. (These small techniques and hard-won experiences are exactly what drive salary growth.) In C#, when SMTP fails, the system exception (Exception) is bubbled up to the caller as-is, and letting it propagate without catching it is the correct way to delegate responsibility.
In practice, Result types are not widely used in real-world codebases, but if you do use them, you do not need to roll your own Result implementation like this; you can use a library such as OneOf or FluentResults, or your team's standard Result type. The minimal implementation here exists solely to illustrate the boundary concept.
TypeScript
type ReceiptError =
| "EmptyOrderId"
| "EmptyCustomerEmail"
| "InvalidTotal";
type Result<TValue, TError> =
| Readonly<{ ok: true; value: TValue }>
| Readonly<{ ok: false; error: TError }>;
type OrderReceipt = Readonly<{
orderId: string;
customerEmail: string;
totalCents: number;
}>;
type SendReceiptResult = Readonly<{
orderId: string;
customerEmail: string;
sentAt: Date;
}>;
type ReceiptRenderer = Readonly<{
renderHtml: (
receipt: OrderReceipt,
issuedAt: Date,
) => string;
}>;
type EmailSender = Readonly<{
sendAsync: (
toEmail: string,
subject: string,
htmlBody: string,
signal?: AbortSignal,
) => Promise<void>;
}>;
type Clock = Readonly<{
getUtcNow: () => Date;
}>;
export function tryCreateOrderReceipt(
orderId: string,
customerEmail: string,
totalCents: number,
): Result<OrderReceipt, ReceiptError>
{
if (orderId.trim().length === 0) {
return { ok: false, error: "EmptyOrderId" };
}
if (customerEmail.trim().length === 0) {
return { ok: false, error: "EmptyCustomerEmail" };
}
if (!Number.isInteger(totalCents) || totalCents < 0) {
return { ok: false, error: "InvalidTotal" };
}
return {
ok: true,
value: { orderId, customerEmail, totalCents },
};
}
export class ReceiptEmailService
{
readonly #renderer: ReceiptRenderer;
readonly #emailSender: EmailSender;
readonly #clock: Clock;
public constructor(
renderer: ReceiptRenderer,
emailSender: EmailSender,
clock: Clock,
)
{
if (renderer === undefined || renderer === null) {
throw new TypeError("renderer is required.");
}
if (emailSender === undefined || emailSender === null) {
throw new TypeError("emailSender is required.");
}
if (clock === undefined || clock === null) {
throw new TypeError("clock is required.");
}
this.#renderer = renderer;
this.#emailSender = emailSender;
this.#clock = clock;
}
public async sendAsync(
receipt: OrderReceipt,
signal?: AbortSignal,
): Promise<SendReceiptResult>
{
const issuedAt = this.#clock.getUtcNow();
const htmlBody = this.#renderer.renderHtml(receipt, issuedAt);
await this.#emailSender.sendAsync(
receipt.customerEmail,
`Receipt for order ${receipt.orderId}`,
htmlBody,
signal,
);
return {
orderId: receipt.orderId,
customerEmail: receipt.customerEmail,
sentAt: issuedAt,
};
}
}
export const systemClock: Clock = {
getUtcNow: () => new Date(),
};
export const htmlReceiptRenderer: ReceiptRenderer = {
renderHtml: (receipt, issuedAt) => {
return `<h1>Order ${receipt.orderId}</h1><p>Total: ${receipt.totalCents}</p><p>${issuedAt.toISOString()}</p>`;
},
};Because TypeScript uses structural typing, you could construct an OrderReceipt literal directly, but to guarantee validity, the team should establish a convention that all construction must go through tryCreateOrderReceipt. If you want a stricter enforcement, wrap it in a branded type or a class with a private constructor. Here, a simple structural type is used to illustrate the concept of a creation boundary.
The service performs only execution, not validation. The reason is that ReceiptEmailService's responsibility is not "deciding whether the input qualifies as a receipt" but "rendering an already-valid receipt as HTML and sending it by email." Placing validation inside the service blurs the creation boundary. Every time the same OrderReceipt is used in a different use case, the validation code gets duplicated, and the problem of validating in some code paths while skipping it in others arises. By contrast, keeping tryCreateOrderReceipt as the sole creation boundary lets the service execute on the premise that an OrderReceipt is already valid. In short, validation belongs at the boundary where the object is created, and the service receives a valid object and executes the use case. Beginner programmers tend to add validation everywhere, but too much validation actually causes harm. Programming ultimately comes down to how you define logical boundaries, and while it varies by team convention, it is important to note that there are generally accepted best practices.
Python
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import Enum, auto
from typing import Generic, Protocol, TypeVar
TValue = TypeVar("TValue")
TError = TypeVar("TError")
class ReceiptError(Enum):
EMPTY_ORDER_ID = auto()
EMPTY_CUSTOMER_EMAIL = auto()
NEGATIVE_TOTAL = auto()
@dataclass(frozen=True)
class Ok(Generic[TValue]):
value: TValue
@dataclass(frozen=True)
class Err(Generic[TError]):
error: TError
@dataclass(frozen=True)
class OrderReceipt:
order_id: str
customer_email: str
total_cents: int
@dataclass(frozen=True)
class SendReceiptResult:
order_id: str
customer_email: str
sent_at: datetime
class ReceiptRenderer(Protocol):
def render_html(
self,
receipt: OrderReceipt,
issued_at: datetime,
) -> str:
...
class EmailSender(Protocol):
async def send_async(
self,
to_email: str,
subject: str,
html_body: str,
) -> None:
...
class Clock(Protocol):
def get_utc_now(self) -> datetime:
...
def try_create_order_receipt(
order_id: str,
customer_email: str,
total_cents: int,
) -> Ok[OrderReceipt] | Err[ReceiptError]:
if order_id.strip() == "":
return Err(ReceiptError.EMPTY_ORDER_ID)
if customer_email.strip() == "":
return Err(ReceiptError.EMPTY_CUSTOMER_EMAIL)
if total_cents < 0:
return Err(ReceiptError.NEGATIVE_TOTAL)
return Ok(
OrderReceipt(
order_id=order_id,
customer_email=customer_email,
total_cents=total_cents,
)
)
class ReceiptEmailService:
def __init__(
self,
renderer: ReceiptRenderer,
email_sender: EmailSender,
clock: Clock,
) -> None:
self._renderer = renderer
self._email_sender = email_sender
self._clock = clock
async def send_async(
self,
receipt: OrderReceipt,
) -> SendReceiptResult:
issued_at = self._clock.get_utc_now()
html_body = self._renderer.render_html(receipt, issued_at)
await self._email_sender.send_async(
receipt.customer_email,
f"Receipt for order {receipt.order_id}",
html_body,
)
return SendReceiptResult(
order_id=receipt.order_id,
customer_email=receipt.customer_email,
sent_at=issued_at,
)
class SystemClock:
def get_utc_now(self) -> datetime:
return datetime.now(UTC)
class HtmlReceiptRenderer:
def render_html(
self,
receipt: OrderReceipt,
issued_at: datetime,
) -> str:
return (
f"<h1>Order {receipt.order_id}</h1>"
f"<p>Total: {receipt.total_cents}</p>"
f"<p>{issued_at.isoformat()}</p>"
)Validation is returned by try_create_order_receipt as Ok/Err. At the call boundary, the result is branched on using match or isinstance. In the Python example as well, the real EmailSender may throw exceptions on network failures, so in tests you should create not only a success fake but also a failure fake to verify that the upper boundary handles the exception correctly.
Rust
use async_trait::async_trait;
use time::OffsetDateTime;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReceiptError {
EmptyOrderId,
EmptyCustomerEmail,
NegativeTotal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrderReceipt {
order_id: String,
customer_email: String,
total_cents: i64,
}
impl OrderReceipt {
pub fn try_new(
order_id: String,
customer_email: String,
total_cents: i64,
) -> Result<Self, ReceiptError> {
if order_id.trim().is_empty() {
return Err(ReceiptError::EmptyOrderId);
}
if customer_email.trim().is_empty() {
return Err(ReceiptError::EmptyCustomerEmail);
}
if total_cents < 0 {
return Err(ReceiptError::NegativeTotal);
}
Ok(Self {
order_id,
customer_email,
total_cents,
})
}
pub fn order_id(&self) -> &str {
&self.order_id
}
pub fn customer_email(&self) -> &str {
&self.customer_email
}
pub fn total_cents(&self) -> i64 {
self.total_cents
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SendReceiptResult {
order_id: String,
customer_email: String,
sent_at: OffsetDateTime,
}
impl SendReceiptResult {
pub fn order_id(&self) -> &str {
&self.order_id
}
pub fn customer_email(&self) -> &str {
&self.customer_email
}
pub fn sent_at(&self) -> OffsetDateTime {
self.sent_at
}
}
pub trait ReceiptRenderer: Send + Sync {
fn render_html(
&self,
receipt: &OrderReceipt,
issued_at: OffsetDateTime,
) -> String;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EmailSendError {
Timeout,
DnsFailure,
AuthenticationFailed,
TransportFailed,
}
#[async_trait]
pub trait EmailSender: Send + Sync {
async fn send_async(
&self,
to_email: &str,
subject: &str,
html_body: &str,
) -> Result<(), EmailSendError>;
}
pub trait Clock: Send + Sync {
fn get_utc_now(&self) -> OffsetDateTime;
}
pub struct ReceiptEmailService<TRenderer, TEmailSender, TClock>
where
TRenderer: ReceiptRenderer,
TEmailSender: EmailSender,
TClock: Clock,
{
renderer: TRenderer,
email_sender: TEmailSender,
clock: TClock,
}
impl<TRenderer, TEmailSender, TClock>
ReceiptEmailService<TRenderer, TEmailSender, TClock>
where
TRenderer: ReceiptRenderer,
TEmailSender: EmailSender,
TClock: Clock,
{
pub fn new(
renderer: TRenderer,
email_sender: TEmailSender,
clock: TClock,
) -> Self {
Self {
renderer,
email_sender,
clock,
}
}
pub async fn send_async(
&self,
receipt: &OrderReceipt,
) -> Result<SendReceiptResult, EmailSendError> {
let issued_at = self.clock.get_utc_now();
let html_body = self.renderer.render_html(receipt, issued_at);
let subject = format!("Receipt for order {}", receipt.order_id());
self.email_sender
.send_async(
receipt.customer_email(),
&subject,
&html_body,
)
.await?;
Ok(SendReceiptResult {
order_id: receipt.order_id().to_owned(),
customer_email: receipt.customer_email().to_owned(),
sent_at: issued_at,
})
}
}
pub struct SystemClock;
impl Clock for SystemClock {
fn get_utc_now(&self) -> OffsetDateTime {
OffsetDateTime::now_utc()
}
}
pub struct HtmlReceiptRenderer;
impl ReceiptRenderer for HtmlReceiptRenderer {
fn render_html(
&self,
receipt: &OrderReceipt,
issued_at: OffsetDateTime,
) -> String {
format!(
"<h1>Order {}</h1><p>Total: {}</p><p>{}</p>",
receipt.order_id(),
receipt.total_cents(),
issued_at
)
}
}In Rust, instead of panicking with assert!, try_new returns Result<Self, ReceiptError>. This ensures that external input, such as an empty order_id, does not cause the service to panic, and forces the caller to handle Err explicitly.
The example above uses generic type parameters to demonstrate static composition, but if you need to swap implementations at runtime, you can opt for a design where the field type is a trait object such as Box<dyn EmailSender + Send + Sync> or Arc<dyn EmailSender + Send + Sync>.
If there is any chance the injected trait will be shared via Arc or passed to a task via tokio::spawn in a multi-threaded async runtime, the trait bound must include Send + Sync. Without this bound, the compiler cannot verify that the value is safe to move across threads, causing compile errors in actual concurrent code. If you only target single-threaded execution, options like #[async_trait(?Send)] exist, but for server code it is generally safer to specify Send + Sync explicitly.
Email send failures are surfaced in the type system. EmailSender::send_async returns Result<(), EmailSendError>, and the service propagates it with ?, returning Result<SendReceiptResult, EmailSendError>. In C#, TypeScript, and Python, these I/O failures are conveyed to the upper boundary as exceptions or rejected promises, but in Rust, making them explicit in the type system aligns better with the Rust philosophy of failure handling.
It is also significant that the validation failure ReceiptError and the send failure EmailSendError are kept as separate types. One represents a domain input validation failure, while the other represents an external I/O failure. Because the recovery site and policy differ between the two, they should not be mixed into the same error type.
3. The Call Site
Within a single execution unit, the Composition Root is the central assembly point where real implementations are created. Here, using TypeScript as the reference, the object graph is built at application startup and validation Result values are handled at the boundary.
type AppServices = Readonly<{
receiptEmailService: ReceiptEmailService;
}>;
class SmtpEmailSender implements EmailSender
{
readonly #host: string;
public constructor(host: string)
{
if (host.trim().length === 0) {
throw new RangeError("`host` cannot be empty.");
}
this.#host = host;
}
public async sendAsync(
toEmail: string,
subject: string,
htmlBody: string,
_signal?: AbortSignal,
): Promise<void>
{
console.info("smtp.send", {
host: this.#host,
toEmail,
subject,
bodyLength: htmlBody.length,
});
}
}
export function buildAppServices(): AppServices
{
const emailSender = new SmtpEmailSender("smtp.example.com");
const receiptEmailService = new ReceiptEmailService(
htmlReceiptRenderer,
emailSender,
systemClock,
);
return {
receiptEmailService,
};
}
export async function handlePaymentCompletedAsync(
input: Readonly<{
orderId: string;
customerEmail: string;
totalCents: number;
}>,
services: AppServices,
signal?: AbortSignal,
): Promise<Result<SendReceiptResult, ReceiptError>>
{
const receiptResult = tryCreateOrderReceipt(
input.orderId,
input.customerEmail,
input.totalCents,
);
if (!receiptResult.ok) {
return receiptResult;
}
const sentAt = await services.receiptEmailService.sendAsync(
receiptResult.value,
signal,
);
return {
ok: true,
value: sentAt,
};
}Separation of responsibilities:
buildAppServices = Composition Root, assembles actual implementations and lifetimes
tryCreateOrderReceipt = input validation, returns predictable failures as Result
ReceiptEmailService = receipt sending use case orchestration, only handles execution
ReceiptRenderer = pure HTML conversion
EmailSender = external email I/O
Clock = time dependency
handlePaymentCompletedAsync = at event/API boundary, handles validation Result and invokes the use caseTwo things matter here. First, ReceiptEmailService does not create SmtpEmailSender directly (assembly is completed at the Composition Root).
Second, validation failures are not thrown as exceptions but returned as a Result, so the boundary function handles them first with a guard clause.
The Result from this function represents only input validation failures. I/O failures such as SMTP errors propagate to the upper boundary as rejected promises, where retry, backoff, and notification policies are applied at event handler or worker boundaries. In other words, the failure model at this call site splits validation failures into Result's Err and I/O failures into rejections.
4. Reading Order
What dependencies does this object need?
→ Are those dependencies exposed through the constructor or function arguments?
→ Does the object directly call new, environment variables, or a global registry internally?
→ Is the actual implementation selection gathered in the Composition Root?
→ Are predictable validation failures expressed as Result rather than exceptions?
→ Is that validation gathered at the creation boundary (factory)?
→ Is the lifetime singleton, scoped, or transient?
→ Can a fake implementation be injected in tests?
→ Are I/O dependencies and pure transformation dependencies separated?When reading DI code, you should look first for "where does the responsibility of creation live" rather than "is there an interface present."
5. Boundaries and Misconceptions
Dependency Injection is not a pattern that attaches an interface to every object. Value objects that are unlikely to change, DTOs, and small calculation functions do not need DI. DI is needed where the implementation may differ between test and production environments, such as time, files, networks, databases, message queues, external APIs, randomness, and configuration.
Put another way, not every new is bad. Creating value objects, DTOs, and pure calculation objects internally is generally not a problem. What is problematic is creating dependencies that carry environment and lifetime policies, such as SMTP, databases, files, time, networks, and configuration, directly inside a use case. The point is not to ban new, but to push the creation of implementations that connect to the outside world out of the use case.
You must separate predictable domain-level failures from system failures. An empty OrderId is an input validation failure, and it should be modeled as a Result returned from the creation boundary rather than as an exception. SMTP timeouts, DNS failures, and authentication failures are I/O failures in email delivery, and they are candidates for retry logic or upper-level failure boundary handling. Wiring mistakes such as injecting a null dependency are unrecoverable programming errors, so the constructor should block them immediately by throwing an exception. DI is not a failure-handling mechanism itself; it is a pattern that separates the boundaries where failures occur, making testing and policy handling possible.
A DI container is optional. That is the textbook answer, of course, and Some projects or frameworks effectively require a DI container. In languages like C#/.NET, where containers are deeply embedded in the standard ecosystem, container-based DI is natural. In TypeScript, Python, and Rust, however, manual wiring is often simpler and more explicit. Fowler himself, when explaining DI, focuses on the practice of providing dependencies from the outside rather than on the container itself.
The Service Locator pattern looks like DI but the direction of responsibility differs. When ReceiptEmailService calls GlobalContainer.get("emailSender"), the dependency is not visible in the constructor. Testing becomes harder, and you cannot tell from the code signature alone what dependencies an object uses. The moment dependencies are hidden, the value of the Composition Root diminishes.
Common mistakes in practice:
- Directly calling `new SmtpEmailSender()` inside the service
- The service directly queries a global container/service locator
- Predictable validation failures are handled as exceptions/panics and leak into the execution layer
- A singleton object holds request-scoped state (Captive Dependency)
- A heavyweight client that should be transient is created anew on every request
- Tests use real SMTP/DB/clock instead of test doubles
- Too many interfaces cause even plain value objects to be unnecessarily abstracted
- The Composition Root is scattered across multiple locations, making the object graph impossible to trace
- Configuration reading and use case execution are mixed in the same class6. Problematic Examples
class BadReceiptEmailService
{
public async sendAsync(receipt: OrderReceipt): Promise<SendReceiptResult>
{
const renderer = htmlReceiptRenderer;
const clock = systemClock;
const emailSender = new SmtpEmailSender("smtp.example.com");
const issuedAt = clock.getUtcNow();
const htmlBody = renderer.renderHtml(receipt, issuedAt);
await emailSender.sendAsync(
receipt.customerEmail,
`Receipt for order ${receipt.orderId}`,
htmlBody,
);
return {
orderId: receipt.orderId,
customerEmail: receipt.customerEmail,
sentAt: issuedAt,
};
}
}Why it is bad:
The service directly instantiates the real SMTP implementation internally.
You cannot inject a fake `EmailSender` in tests.
It is difficult to fix the current time.
The SMTP host configuration is hardcoded inside the use case rather than in the Composition Root.
You cannot control the service creation cost or the SMTP client lifetime.
Dependencies are not exposed in the constructor signature, making code review difficult.An even worse form is the global locator.
class LocatorBasedReceiptEmailService
{
public async sendAsync(receipt: OrderReceipt): Promise<SendReceiptResult>
{
const emailSender = globalContainer.resolve<EmailSender>("emailSender");
const clock = globalContainer.resolve<Clock>("clock");
const renderer = globalContainer.resolve<ReceiptRenderer>("renderer");
const issuedAt = clock.getUtcNow();
const htmlBody = renderer.renderHtml(receipt, issuedAt);
await emailSender.sendAsync(receipt.customerEmail, "Receipt", htmlBody);
return {
orderId: receipt.orderId,
customerEmail: receipt.customerEmail,
sentAt: issuedAt,
};
}
}This code looks like DI, but the object goes out and fetches its own dependencies. Because you cannot determine the required dependencies just by looking at the constructor, it is closer to the Service Locator pattern.
7. Scaling to Production
Real-world scaling involves a table of fake dependencies for testing and lifetime policies. The value of DI shows up earliest in tests. You should be able to verify the receipt-sending use case without connecting to a real SMTP server.
There is one thing to be careful about here, though. Fakes are used to simplify reality, but an overly optimistic Fake can actually hide production failures. You therefore need both a Fake that records the success path and a Fake that injects the failure path.
Start by verifying the success path. Here, RecordingEmailSender is not an object that mimics a real SMTP server. It is a test double that records which recipient, subject, and body the service used when requesting an email send.
import assert from "node:assert/strict";
import test from "node:test";
class RecordingEmailSender implements EmailSender
{
readonly #sentMessages: Array<Readonly<{
toEmail: string;
subject: string;
htmlBody: string;
}>> = [];
public async sendAsync(
toEmail: string,
subject: string,
htmlBody: string,
): Promise<void>
{
this.#sentMessages.push({
toEmail,
subject,
htmlBody,
});
}
public getSentMessages(): readonly Readonly<{
toEmail: string;
subject: string;
htmlBody: string;
}>[]
{
return this.#sentMessages;
}
}
const fixedClock: Clock = {
getUtcNow: () => new Date("2026-07-08T00:00:00.000Z"),
};
const testRenderer: ReceiptRenderer = {
renderHtml: (receipt, issuedAt) => {
return `order=${receipt.orderId}; total=${receipt.totalCents}; issuedAt=${issuedAt.toISOString()}`;
},
};
test("`tryCreateOrderReceipt` returns an empty `orderId` as `Err`", () => {
const result = tryCreateOrderReceipt("", "[email protected]", 12_000);
assert.equal(result.ok, false);
if (result.ok) {
return;
}
assert.equal(result.error, "EmptyOrderId");
});
test("`ReceiptEmailService` delegates the email sending intent to `EmailSender`", async () => {
const emailSender = new RecordingEmailSender();
const service = new ReceiptEmailService(
testRenderer,
emailSender,
fixedClock,
);
const receiptResult = tryCreateOrderReceipt(
"ord-1",
"[email protected]",
12_000,
);
assert.equal(receiptResult.ok, true);
if (!receiptResult.ok) {
return;
}
const result = await service.sendAsync(receiptResult.value);
assert.equal(result.sentAt.toISOString(), "2026-07-08T00:00:00.000Z");
assert.deepEqual(emailSender.getSentMessages(), [
{
toEmail: "[email protected]",
subject: "Receipt for order ord-1",
htmlBody: "order=ord-1; total=12000; issuedAt=2026-07-08T00:00:00.000Z",
},
]);
});This test does not verify whether a real email was actually sent. That is the concern of the SMTP client or an integration test. What it verifies here is whether ReceiptEmailService produced the correct email-sending intent and passed it to the EmailSender dependency.
Stopping here, however, leaves the test overly optimistic. If you only write this much, the test zealots will start lecturing you about writing bad tests, and they have a point: tests should generally cover two cases. Write a success case and a failure case. Programming is a lot like tossing meat to hyenas. Programmers as a species have an endless need to prove they are more skilled than everyone else, and they will always bare their critical fangs at you, so stay alert.
Real-world SMTP fails at any moment: timeouts occur, DNS resolution breaks, authentication servers go down, networks drop. If you rely only on a happy-path fake, the entire failure-recovery scenario, including whether the upper layer should retry, back off, open a circuit breaker, or surface the error to the user, becomes a blind spot in your tests.
That is why you also test failure paths using a fault injection fake.
In TypeScript, a send failure is expressed as a rejected promise. The responsibility of this use case is not to swallow the failure but to propagate it to the upper boundary.
class FailingEmailSender implements EmailSender
{
readonly #error: Error;
public constructor(error: Error)
{
this.#error = error;
}
public async sendAsync(
_toEmail: string,
_subject: string,
_htmlBody: string,
): Promise<void>
{
throw this.#error;
}
}
test("`ReceiptEmailService` propagates email delivery failures up to the failure boundary", async () => {
const emailSender = new FailingEmailSender(
new Error("smtp transport failed"),
);
const service = new ReceiptEmailService(
testRenderer,
emailSender,
fixedClock,
);
const receiptResult = tryCreateOrderReceipt(
"ord-1",
"[email protected]",
12_000,
);
assert.equal(receiptResult.ok, true);
if (!receiptResult.ok) {
return;
}
await assert.rejects(
() => service.sendAsync(receiptResult.value),
/smtp transport failed/,
);
});In Rust, failures are visible in the type. Because EmailSender returns Result<(), EmailSendError>, a failing fake simply returns Err. Then you assert that the service passes that Err back as-is.
struct FailingEmailSender;
#[async_trait]
impl EmailSender for FailingEmailSender {
async fn send_async(
&self,
_to_email: &str,
_subject: &str,
_html_body: &str,
) -> Result<(), EmailSendError> {
Err(EmailSendError::Timeout)
}
}
#[tokio::test]
async fn send_failure_is_propagated_as_err() {
let receipt = OrderReceipt::try_new(
"ord-1".to_owned(),
"[email protected]".to_owned(),
12_000,
)
.expect("valid receipt");
let service = ReceiptEmailService::new(
HtmlReceiptRenderer,
FailingEmailSender,
SystemClock,
);
let result = service.send_async(&receipt).await;
assert_eq!(result, Err(EmailSendError::Timeout));
}One boundary must be made explicit.
Recovery policies such as retry, backoff, circuit breaking, alerting, and dead letter queues are not the responsibility of ReceiptEmailService. This use case is the layer responsible for rendering the receipt and executing the email-sending intent. If it starts swallowing failures and taking on retry policies, the use case ends up owning operational concerns as well.
Recovery policies typically belong to the boundary that calls this use case.
API handler
event handler
background worker
message consumer
schedulerThat layer catches the failure and decides whether to retry, back off, open a circuit breaker, notify an administrator, or route the message to a dead letter queue. What DI does here is make that failure injectable, which is what makes failure-path testing possible in the first place.
In other words, you need both a success fake and a fault injection fake.
RecordingEmailSender = records what the service was trying to do on the success path.
FailingEmailSender = verifies that I/O failures propagate to the upper boundary.In production, declare a service lifetime table explicitly.
ReceiptEmailService = transient or scoped, per request/use case unit
HtmlReceiptRenderer = singleton, stateless pure transformation
SystemClock = singleton, stateless
SmtpEmailSender = singleton or pooled, depends on connection management policy
Configuration = singleton, validated immutable config at boot time
RequestContext = scoped, per request correlation/user informationMicrosoft's DI lifetime documentation also distinguishes transient, scoped, and singleton lifetimes and explains that each service must be assigned an appropriate lifetime.4 Getting this wrong causes real incidents. For example, storing request context in a singleton can mix user data across requests, and creating a heavy HTTP client as transient on every call can degrade connection management.
A simple DI review checklist looks like this.
Are there no `new` external I/O clients inside the use case class?
Can you identify all required dependencies just by looking at the constructor?
Is the Composition Root consolidated into one place per execution entry point?
Are predictable validation failures returned as a Result type rather than thrown as exceptions?
Is that validation logic gathered at the creation boundary, leaving the execution layer to only execute?
Does a singleton hold no mutable request state?
Do tests run with a fake clock, fake sender, and fake repository?
Are I/O failure paths tested not only with a success fake but also with a fault-injecting fake?
Is the failure recovery policy located at the call boundary rather than inside the use case?
Does the Service Locator call not leak into the application core?DI is often described as a pattern that makes testing easier, but more precisely it is a pattern that makes failures injectable. Tests that only verify the success path are usually pretty lies. In production, what matters is not the success path but where the failure path flows.
8. C# / TypeScript / Python / Rust Comparison Notes
Language | Idiomatic Style | Caveats |
|---|---|---|
C# | constructor injection, interfaces, built-in DI container, lifetime registration | Do not resolve directly from the container inside the application core |
TypeScript | manual wiring, object interfaces, composition functions | Before introducing a decorator or container, verify whether manual assembly is sufficient |
Python | Protocol, constructor injection, simple factory functions | Relying on monkey patching or global singletons hides dependencies |
Rust | traits, generic injection, trait objects, explicit ownership | Because of lifetime and ownership constraints, composition boundaries must be designed more explicitly |
C# has a DI container well integrated into its standard ecosystem, and constructor injection along with service lifetime registration feel natural. Since there is a standard TimeProvider.GetUtcNow(), using it instead of a custom Clock is a valid option5. Honestly, avoiding a DI container makes it hard to earn a living on the WPF side. TypeScript's structural typing and object literals make manual DI concise at small scale. Python expresses required behavior through Protocol and passing it explicitly to constructors, which reads cleanly. Rust injects dependencies via trait generics or trait objects, though async trait and ownership design come along for the ride.
As always, there is no need to forcibly transplant one language's style onto another. You do not need to awkwardly mimic C#-style container annotations in TypeScript, nor push Rust-style generic injection too aggressively in C#. There is one shared principle.
Execution objects should not create their own dependencies; the composition layer should create them and pass them in.9. Further Considerations
Is this dependency a pure computation, an external I/O, or an environmental dependency such as time, random numbers, or config?
When it comes to objects this object may directly call
newon, are they value objects or external resources?When predictable validation failures are modeled as Result types, how does the branching code at the call boundary become simpler?
When the Composition Root is scattered across multiple places, how does that make deployment configuration and test assembly more difficult?
Does an object registered as a singleton hold per-request state?
If you cannot inject a fake clock, fake sender, or fake repository in tests, which boundary is wrong?
Does using a DI container reduce the amount of code, or does it hide the flow of dependencies?
10. Summary
Composition Root Dependency Injection is a pattern that concentrates object graph assembly at the application startup boundary.
Services should expose the dependencies they need through constructor parameters or function arguments, rather than creating them directly.
The essence of DI is not a proliferation of interfaces, but the separation of creation responsibility, service lifetime, and test replaceability.
Predictable validation failures should be returned as Result types rather than exceptions, gathered at the creation boundary so that the execution layer only executes.
Service Locator hides dependencies, so you must ensure it does not leak into the application core.
Choosing the wrong singleton/scoped/transient lifetime leads to state leakage, performance degradation, and test instability.
In TypeScript, Python, and Rust, manual assembly without a container is often clearer.
DI is not syntax for making objects look pretty; it is an operational discipline for controlling creation responsibility and failure boundaries in one place.
Rules to remember:
Business objects should neither locate nor create their dependencies; they should only receive pre-assembled dependencies and execute.Footnotes
- Martin Fowler, "Inversion of Control Containers and the Dependency Injection Pattern" (2004-01-23): https://martinfowler.com/articles/injection.html ↩
- Mark Seemann, "Composition Root" (2011-07-28): https://blog.ploeh.dk/2011/07/28/CompositionRoot/ ↩
- Microsoft Learn, "Dependency injection - .NET": https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection ↩
- Microsoft Learn, "Service lifetimes (dependency injection) - .NET": https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection#service-lifetimes ↩
- Microsoft Learn, "TimeProvider Class": https://learn.microsoft.com/en-us/dotnet/api/system.timeprovider ↩