Loading learning content...
Every object, upon creation, finds itself in a particular state with particular capabilities. Some objects can function independently—they need nothing from the outside world. But most objects in real software systems are collaborative: they accomplish their work by delegating to other objects, accessing external resources, or coordinating with services.
The question that defines dependency injection is deceptively simple: How should an object receive the collaborators it needs? The answer, developed and refined over decades of software engineering practice, points decisively toward a single technique: constructor injection.
By the end of this page, you will understand why the constructor is the natural and preferred location for dependency delivery. You'll learn the mechanics of constructor injection, its semantic significance as a declaration of requirements, and how it fundamentally transforms the way objects communicate their needs to the world.
Before we discuss constructor injection, we must understand what it replaces. In naive object-oriented design, objects often acquire their own dependencies. Consider a common pattern:
class OrderService {
private repository: OrderRepository;
private emailService: EmailService;
private paymentGateway: PaymentGateway;
constructor() {
// Self-acquisition: the object creates its own collaborators
this.repository = new PostgresOrderRepository();
this.emailService = new SmtpEmailService();
this.paymentGateway = new StripePaymentGateway();
}
placeOrder(order: Order): void {
// ... implementation using dependencies
}
}
This code works. Orders get placed, emails get sent, payments get processed. But this design carries profound problems that only surface as systems evolve.
OrderService requires database access, email capabilities, and payment processing without reading its implementation.OrderService is permanently welded to PostgresOrderRepository, SmtpEmailService, and StripePaymentGateway. Switching to MySQL, SendGrid, or PayPal requires modifying OrderService itself.OrderService instance triggers real database connections, SMTP handshakes, and payment gateway initialization. There's no way to substitute test doubles.PostgresOrderRepository itself creates a connection pool? The new chain cascades, potentially initializing entire subsystems just to construct one object.Every use of new inside a class represents a binding decision—a permanent commitment to a specific implementation. In constructor injection, we move these decisions OUT of the class, making them explicit and controllable from the outside.
Constructor injection inverts the dependency relationship. Instead of objects acquiring their own collaborators, collaborators are provided to objects at construction time. The transformation is both syntactically simple and semantically profound:
class OrderService {
private readonly repository: OrderRepository;
private readonly emailService: EmailService;
private readonly paymentGateway: PaymentGateway;
constructor(
repository: OrderRepository,
emailService: EmailService,
paymentGateway: PaymentGateway
) {
this.repository = repository;
this.emailService = emailService;
this.paymentGateway = paymentGateway;
}
placeOrder(order: Order): void {
// ... implementation using injected dependencies
}
}
What changed?
new statementsreadonly, signaling immutability after construction| Aspect | Self-Acquisition | Constructor Injection |
|---|---|---|
| Dependency visibility | Hidden inside implementation | Explicit in constructor signature |
| Coupling level | Tight coupling to concrete classes | Loose coupling to abstractions |
| Testability | Requires real implementations | Easily substituted with test doubles |
| Configuration flexibility | Hardcoded at compile time | Configurable at runtime/deployment |
| Single Responsibility | Violated (creates + uses) | Respected (uses only) |
| Initialization order control | Implicit, uncontrollable | Explicit, caller-controlled |
The composition root:
If classes no longer create their dependencies, something must. This responsibility moves to a composition root—a single location in the application where the object graph is assembled:
// Composition root: typically in main.ts, bootstrap.ts, or an IoC container configuration
const repository = new PostgresOrderRepository(connectionString);
const emailService = new SmtpEmailService(smtpConfig);
const paymentGateway = new StripePaymentGateway(stripeApiKey);
const orderService = new OrderService(repository, emailService, paymentGateway);
// The orderService is now fully configured and ready to use
This separation is crucial: business logic classes focus on behavior, while the composition root focuses on assembly and configuration.
The constructor signature in constructor injection is not merely mechanical—it's semantic. It serves as a formal declaration of everything a class requires to function. This transforms the constructor into a contract between the class and its instantiators.
Reading the contract:
When you encounter this constructor signature:
constructor(
repository: OrderRepository,
emailService: EmailService,
paymentGateway: PaymentGateway,
logger: Logger
)
You immediately know:
OrderService needs persistent storage capabilitiesYou know all this without reading a single line of implementation. The constructor signature is self-documenting.
A class using constructor injection is an 'honest' class—it declares upfront what it needs. There are no hidden surprises, no secret database calls, no unexpected network requests. Everything required is stated explicitly in the constructor. This honesty is the foundation of predictable, maintainable software.
Why constructor placement matters:
Dependency injection can technically occur through other mechanisms—setter methods, property assignments, even static registries. But the constructor has unique properties that make it the preferred location:
Temporal guarantee — The constructor executes before any method. Dependencies are available from the very first moment the object exists.
Completeness guarantee — A successfully constructed object has all its dependencies. There's no partial initialization state.
Compile-time enforcement — The compiler (or runtime) ensures all constructor parameters are provided. Missing dependencies are caught immediately.
Single assignment — Constructor parameters are assigned once. Combined with readonly, this guarantees dependencies never change after construction.
No temporal coupling — Methods don't need to be called in a specific order. The object is ready to use immediately after construction.
readonly, preventing accidental modification.Constructor parameters in production systems typically reference abstractions rather than concrete types. This abstraction level is crucial for achieving the flexibility that constructor injection promises.
Interface-based dependencies:
// Abstraction (interface)
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByCustomer(customerId: string): Promise<Order[]>;
}
// Concrete implementations
class PostgresOrderRepository implements OrderRepository { /* ... */ }
class MongoOrderRepository implements OrderRepository { /* ... */ }
class InMemoryOrderRepository implements OrderRepository { /* ... */ }
// The consumer depends only on the abstraction
class OrderService {
constructor(private readonly repository: OrderRepository) {}
}
Because OrderService depends on OrderRepository (the interface), not PostgresOrderRepository (the implementation), we can substitute any implementation—for testing, for different environments, or when switching technologies.
| Dependency Type | Declaration Style | Example | Use Case |
|---|---|---|---|
| Interface | Direct interface type | repository: OrderRepository | Standard abstraction for services |
| Abstract class | Abstract class type | validator: BaseValidator | When shared implementation exists |
| Functional interface | Function type | hash: (data: string) => string | Single-method dependencies |
| Factory function | Factory type | createLogger: () => Logger | Deferred or repeated creation |
| Configuration object | Plain object type | config: DatabaseConfig | External configuration values |
| Generic abstraction | Generic interface | cache: Cache<string, Order> | Type-safe, reusable abstractions |
Primitive dependencies:
Not all dependencies are objects. Configuration values, connection strings, feature flags, and numeric parameters are also dependencies—they're just primitive dependencies:
class RateLimiter {
constructor(
private readonly windowMs: number,
private readonly maxRequests: number,
private readonly store: RateLimitStore
) {}
}
Primitive dependencies are typically configuration values that should also come from outside the class. Hardcoding them inside the class creates the same problems as hardcoding collaborators.
The power of constructor injection comes from combining it with interface-based design. A class depending on PostgresOrderRepository directly can receive it via constructor injection, but you still can't substitute a test double. True flexibility requires both injection AND abstraction.
Understanding constructor injection fully requires examining it from two perspectives: the class being injected into, and the code that instantiates that class.
The consumer perspective (inside the class):
class NotificationService {
constructor(
private readonly emailSender: EmailSender,
private readonly smsSender: SmsSender,
private readonly pushNotifier: PushNotifier
) {}
async notifyUser(userId: string, message: string): Promise<void> {
// The class uses its dependencies without knowing their concrete types
const user = await this.userRepository.findById(userId);
if (user.emailEnabled) {
await this.emailSender.send(user.email, message);
}
if (user.smsEnabled) {
await this.smsSender.send(user.phone, message);
}
if (user.pushEnabled) {
await this.pushNotifier.notify(user.deviceToken, message);
}
}
}
From inside the class, dependencies are simply available. The class doesn't know or care where they came from. It uses them according to their interfaces.
The producer perspective (composition root):
// Production composition root
function createProductionNotificationService(): NotificationService {
const emailSender = new SendGridEmailSender(process.env.SENDGRID_API_KEY);
const smsSender = new TwilioSmsSender(process.env.TWILIO_SID, process.env.TWILIO_TOKEN);
const pushNotifier = new FirebasePushNotifier(firebaseConfig);
return new NotificationService(emailSender, smsSender, pushNotifier);
}
// Test composition
function createTestNotificationService(
emailSender: FakeEmailSender,
smsSender: FakeSmsSender,
pushNotifier: FakePushNotifier
): NotificationService {
return new NotificationService(emailSender, smsSender, pushNotifier);
}
The producer controls which implementations satisfy the dependencies. In production, real services are used. In testing, fakes are substituted. The consuming class is identical in both scenarios—only the injected dependencies differ.
Real-world services often require numerous dependencies. Managing these correctly requires careful attention to ordering, naming, and organization.
Ordering conventions:
While constructor parameter order is technically arbitrary, conventions make code more readable:
class PaymentProcessor {
constructor(
// 1. Core business dependencies first
private readonly paymentGateway: PaymentGateway,
private readonly fraudChecker: FraudChecker,
private readonly transactionRepository: TransactionRepository,
// 2. Cross-cutting concerns next
private readonly logger: Logger,
private readonly metrics: MetricsCollector,
// 3. Configuration values last
private readonly maxRetries: number,
private readonly retryDelayMs: number
) {}
}
This ordering—core dependencies, then cross-cutting concerns, then configuration—creates predictable patterns that other developers can follow.
Parameter objects for complex dependencies:
When a class requires many dependencies, consider grouping related ones:
// Before: Too many parameters
class ReportGenerator {
constructor(
orderRepo: OrderRepository,
customerRepo: CustomerRepository,
productRepo: ProductRepository,
invoiceRepo: InvoiceRepository,
formatter: ReportFormatter,
exporter: ReportExporter,
logger: Logger
) {}
}
// After: Grouped related dependencies
interface RepositorySet {
orders: OrderRepository;
customers: CustomerRepository;
products: ProductRepository;
invoices: InvoiceRepository;
}
interface ReportingServices {
formatter: ReportFormatter;
exporter: ReportExporter;
}
class ReportGenerator {
constructor(
private readonly repositories: RepositorySet,
private readonly services: ReportingServices,
private readonly logger: Logger
) {}
}
Parameter objects reduce parameter count while maintaining explicit declaration. However, use them judiciously—they can hide complexity rather than reduce it.
A constructor with 7+ dependencies often indicates the class is doing too much. Before reaching for parameter objects, ask: Should this class be split into smaller, more focused classes? Constructor injection makes this smell visible—which is a feature, not a bug.
Most modern frameworks and IoC containers have first-class support for constructor injection. Understanding how they work reveals why constructor injection is considered the default choice.
TypeScript/NestJS example:
@Injectable()
class OrderService {
constructor(
private readonly orderRepository: OrderRepository,
private readonly eventPublisher: EventPublisher,
@Inject('CONFIG') private readonly config: AppConfig
) {}
}
// Registration
@Module({
providers: [
OrderService,
OrderRepository,
EventPublisher,
{ provide: 'CONFIG', useValue: appConfig }
]
})
export class OrderModule {}
NestJS uses reflection to read constructor parameters and automatically resolves dependencies when instantiating services.
C#/.NET example:
public class OrderService : IOrderService
{
private readonly IOrderRepository _repository;
private readonly IEventPublisher _publisher;
public OrderService(
IOrderRepository repository,
IEventPublisher publisher)
{
_repository = repository;
_publisher = publisher;
}
}
// Registration in Startup.cs
services.AddScoped<IOrderRepository, PostgresOrderRepository>();
services.AddScoped<IEventPublisher, RabbitMQEventPublisher>();
services.AddScoped<IOrderService, OrderService>();
Java/Spring example:
@Service
public class OrderService {
private final OrderRepository repository;
private final EventPublisher publisher;
@Autowired
public OrderService(
OrderRepository repository,
EventPublisher publisher) {
this.repository = repository;
this.publisher = publisher;
}
}
All major frameworks converge on the same pattern: constructor parameters define requirements, and the container resolves them automatically.
| Framework | Declaration Style | Resolution Mechanism |
|---|---|---|
| NestJS (TypeScript) | @Injectable() + constructor params | Reflection + module registration |
| Spring (Java) | @Service/@Component + constructor | Component scanning + @Autowired |
| .NET Core | Constructor + interface params | IServiceCollection registration |
| Angular | @Injectable() + constructor | Module providers array |
| Guice (Java) | @Inject + constructor | Module binding configuration |
| Dagger (Android) | @Inject + @Component interface | Compile-time code generation |
Constructor injection transforms how we think about class dependencies. Instead of hidden internal decisions, dependencies become visible contracts. Instead of tightly coupled implementations, we work with flexible abstractions.
What's next:
Now that we understand the mechanics of constructor injection, the next page explores the mandatory dependencies pattern—how constructor injection naturally enforces that all required dependencies are provided, eliminating null checks and partial initialization states.
You now understand constructor injection as the preferred technique for delivering dependencies—transforming constructors from mere initialization procedures into explicit declarations of class requirements. Next, we'll explore how this technique enforces mandatory dependencies.