Loading learning content...
An Inversion of Control (IoC) container is the engine that powers dependency injection at scale. While you can hand-wire dependencies manually in small applications, real-world systems with hundreds or thousands of classes demand automated, systematic management of object creation, lifecycle control, and dependency resolution.
The container serves as a central registry that knows how to construct every object in your system, including all their dependencies, their dependencies' dependencies, and so on—forming complete object graphs with zero manual plumbing from application code.
But containers don't just magically know these things. They must be configured—told which implementations fulfill which abstractions, how long objects should live, and when special construction logic applies.
By the end of this page, you will master the fundamental approaches to configuring IoC containers: imperative (code-based), declarative (XML/config), attribute-based, and convention-based. You'll understand the philosophy behind each approach, their strengths and weaknesses, and how to choose appropriately for your context.
At its heart, container configuration answers three fundamental questions:
Every configuration approach addresses these questions differently, with varying trade-offs between flexibility, explicitness, and convenience.
| Approach | Configuration Source | Type Safety | Discoverability | Flexibility |
|---|---|---|---|---|
| Imperative (Code) | Source code methods | Compile-time | IDE support | Maximum |
| Declarative (XML/JSON) | External config files | None | Schema validation | Runtime flexibility |
| Attribute-based | Decorated classes | Compile-time | Attribute discovery | Moderate |
| Convention-based | Naming/namespace patterns | Compile-time | Automatic | Low (by design) |
Understanding these approaches isn't merely academic—it directly impacts your system's maintainability, debuggability, and team velocity. The wrong choice can create configuration nightmares that haunt projects for years.
Imperative configuration means writing explicit code that tells the container exactly what to do. This is the most common approach in modern frameworks and provides maximum control.
The Philosophy:
Your dependency configuration is code—first-class code that compiles, gets refactored by your IDE, and benefits from type checking. There are no magic strings, no external files to keep synchronized, and no runtime surprises from typos.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
// Modern TypeScript DI Container Configuration// Using InversifyJS as example (concepts transfer to any container) import { Container } from 'inversify';import { TYPES } from './types'; // Interfaces (abstractions)interface ILogger { log(message: string): void;} interface IDatabase { query<T>(sql: string): Promise<T[]>;} interface IUserRepository { findById(id: string): Promise<User | null>; save(user: User): Promise<void>;} interface IUserService { getUser(id: string): Promise<User>; createUser(data: CreateUserDTO): Promise<User>;} // Concrete implementationsclass ConsoleLogger implements ILogger { log(message: string): void { console.log(`[${new Date().toISOString()}] ${message}`); }} class PostgresDatabase implements IDatabase { constructor(private connectionString: string) {} async query<T>(sql: string): Promise<T[]> { // PostgreSQL query implementation return []; }} class UserRepository implements IUserRepository { constructor( private database: IDatabase, private logger: ILogger ) {} async findById(id: string): Promise<User | null> { this.logger.log(`Finding user: ${id}`); const results = await this.database.query<User>( `SELECT * FROM users WHERE id = $1` ); return results[0] ?? null; } async save(user: User): Promise<void> { this.logger.log(`Saving user: ${user.id}`); await this.database.query( `INSERT INTO users (id, name, email) VALUES ($1, $2, $3)` ); }} class UserService implements IUserService { constructor( private userRepository: IUserRepository, private logger: ILogger ) {} async getUser(id: string): Promise<User> { this.logger.log(`Getting user: ${id}`); const user = await this.userRepository.findById(id); if (!user) throw new Error(`User not found: ${id}`); return user; } async createUser(data: CreateUserDTO): Promise<User> { this.logger.log(`Creating user: ${data.email}`); const user = new User(generateId(), data.name, data.email); await this.userRepository.save(user); return user; }} // Container configuration - THE KEY PARTfunction configureContainer(): Container { const container = new Container(); // Register abstractions to implementations container.bind<ILogger>(TYPES.ILogger) .to(ConsoleLogger) .inSingletonScope(); // One instance for entire application container.bind<IDatabase>(TYPES.IDatabase) .toDynamicValue(() => new PostgresDatabase( process.env.DATABASE_URL! )) .inSingletonScope(); container.bind<IUserRepository>(TYPES.IUserRepository) .to(UserRepository) .inTransientScope(); // New instance per resolution container.bind<IUserService>(TYPES.IUserService) .to(UserService) .inRequestScope(); // One instance per request return container;} // Usage in composition rootconst container = configureContainer();const userService = container.get<IUserService>(TYPES.IUserService);Place all container configuration in a single location—the Composition Root—typically at application startup. This creates a single point of truth for your object graph, making the system easier to understand and modify. Never configure the container from multiple scattered locations.
As applications grow, placing hundreds of registrations in a single method becomes unwieldy. The solution is modular configuration—organizing registrations into cohesive, feature-focused modules.
The Architecture of Configuration Modules:
Think of your application as a collection of vertical slices or bounded contexts. Each slice has its own services, repositories, and handlers. Configuration modules mirror this structure, grouping related registrations together.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
// Modular container configuration pattern// Each module handles its own feature's dependencies // Base module interfaceinterface IContainerModule { configure(container: Container): void;} // Infrastructure module - cross-cutting concernsclass InfrastructureModule implements IContainerModule { constructor(private config: AppConfiguration) {} configure(container: Container): void { // Logging container.bind<ILogger>(TYPES.ILogger) .to(this.config.environment === 'production' ? CloudwatchLogger : ConsoleLogger) .inSingletonScope(); // Database container.bind<IDatabase>(TYPES.IDatabase) .toDynamicValue(() => new PostgresDatabase(this.config.databaseUrl)) .inSingletonScope(); // Caching container.bind<ICache>(TYPES.ICache) .to(this.config.redisUrl ? RedisCache : InMemoryCache) .inSingletonScope(); // HTTP Client container.bind<IHttpClient>(TYPES.IHttpClient) .to(AxiosHttpClient) .inSingletonScope(); }} // User management moduleclass UserModule implements IContainerModule { configure(container: Container): void { // Repositories container.bind<IUserRepository>(TYPES.IUserRepository) .to(UserRepository) .inRequestScope(); container.bind<IUserProfileRepository>(TYPES.IUserProfileRepository) .to(UserProfileRepository) .inRequestScope(); // Services container.bind<IUserService>(TYPES.IUserService) .to(UserService) .inRequestScope(); container.bind<IAuthenticationService>(TYPES.IAuthenticationService) .to(JwtAuthenticationService) .inSingletonScope(); container.bind<IPasswordHasher>(TYPES.IPasswordHasher) .to(BcryptPasswordHasher) .inSingletonScope(); // Event handlers container.bind<IEventHandler<UserCreated>>(TYPES.IEventHandler) .to(SendWelcomeEmailHandler) .inTransientScope(); container.bind<IEventHandler<UserCreated>>(TYPES.IEventHandler) .to(CreateUserProfileHandler) .inTransientScope(); }} // Payment moduleclass PaymentModule implements IContainerModule { constructor(private config: PaymentConfiguration) {} configure(container: Container): void { // Payment provider strategy const paymentProvider = this.createPaymentProvider(); container.bind<IPaymentProvider>(TYPES.IPaymentProvider) .toConstantValue(paymentProvider); container.bind<IPaymentService>(TYPES.IPaymentService) .to(PaymentService) .inRequestScope(); container.bind<IRefundService>(TYPES.IRefundService) .to(RefundService) .inRequestScope(); container.bind<IInvoiceGenerator>(TYPES.IInvoiceGenerator) .to(PdfInvoiceGenerator) .inTransientScope(); } private createPaymentProvider(): IPaymentProvider { switch (this.config.provider) { case 'stripe': return new StripePaymentProvider(this.config.stripeKey); case 'paypal': return new PayPalPaymentProvider(this.config.paypalConfig); case 'mock': return new MockPaymentProvider(); default: throw new Error(`Unknown provider: ${this.config.provider}`); } }} // Notification moduleclass NotificationModule implements IContainerModule { configure(container: Container): void { container.bind<IEmailSender>(TYPES.IEmailSender) .to(SendGridEmailSender) .inSingletonScope(); container.bind<ISmsSender>(TYPES.ISmsSender) .to(TwilioSmsSender) .inSingletonScope(); container.bind<IPushNotificationSender>(TYPES.IPushNotificationSender) .to(FirebasePushSender) .inSingletonScope(); container.bind<INotificationService>(TYPES.INotificationService) .to(MultiChannelNotificationService) .inRequestScope(); }} // Composition Root - assembles all modulesclass ApplicationContainer { private container: Container; constructor(private config: AppConfiguration) { this.container = new Container(); } configure(): Container { const modules: IContainerModule[] = [ new InfrastructureModule(this.config), new UserModule(), new PaymentModule(this.config.payment), new NotificationModule(), // Add new feature modules here ]; // Apply all modules modules.forEach(module => module.configure(this.container)); // Verify configuration (fail fast on missing dependencies) this.verifyConfiguration(); return this.container; } private verifyConfiguration(): void { // Attempt to resolve key services to catch configuration errors early const criticalServices = [ TYPES.ILogger, TYPES.IDatabase, TYPES.IUserService, TYPES.IPaymentService, ]; for (const serviceType of criticalServices) { try { this.container.get(serviceType); } catch (error) { throw new ConfigurationError( `Failed to resolve ${serviceType.toString()}: ${error}` ); } } }} // Application startupconst config = loadConfiguration();const appContainer = new ApplicationContainer(config);const container = appContainer.configure();Configuration modules should align with your domain's bounded contexts or feature areas. A PaymentModule contains everything payment-related; a UserModule contains authentication, profiles, and user management. This organization makes it obvious where to add new registrations and simplifies reasoning about the system.
Real applications require more than simple type-to-type mappings. You often need:
new invocations123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
// Advanced registration patterns // 1. CONDITIONAL REGISTRATION// Select implementation based on environment or configuration class EnvironmentAwareRegistrar { constructor( private container: Container, private environment: 'development' | 'staging' | 'production' ) {} registerLogger(): void { switch (this.environment) { case 'development': this.container.bind<ILogger>(TYPES.ILogger) .to(PrettyConsoleLogger) .inSingletonScope(); break; case 'staging': this.container.bind<ILogger>(TYPES.ILogger) .to(JsonConsoleLogger) // Structured logging for log aggregation .inSingletonScope(); break; case 'production': this.container.bind<ILogger>(TYPES.ILogger) .to(CloudWatchLogger) .inSingletonScope(); break; } } registerCache(): void { if (this.environment === 'development') { // In-memory cache for fast local development this.container.bind<ICache>(TYPES.ICache) .to(InMemoryCache) .inSingletonScope(); } else { // Redis for shared state across instances this.container.bind<ICache>(TYPES.ICache) .toDynamicValue(ctx => { const config = ctx.container.get<AppConfig>(TYPES.AppConfig); return new RedisCache(config.redisUrl); }) .inSingletonScope(); } }} // 2. FACTORY REGISTRATION// Complex object construction with dependencies container.bind<IPaymentGateway>(TYPES.IPaymentGateway) .toDynamicValue(ctx => { const config = ctx.container.get<PaymentConfig>(TYPES.PaymentConfig); const logger = ctx.container.get<ILogger>(TYPES.ILogger); const metrics = ctx.container.get<IMetrics>(TYPES.IMetrics); // Complex initialization logic const gateway = PaymentGatewayFactory.create({ provider: config.provider, apiKey: config.apiKey, webhookSecret: config.webhookSecret, timeoutMs: config.timeoutMs, }); // Wrap with observability return new ObservablePaymentGateway(gateway, logger, metrics); }) .inSingletonScope(); // 3. DECORATOR PATTERN REGISTRATION// Layer behaviors: Core -> Caching -> Logging -> Retry // Start with base implementationcontainer.bind<IUserRepository>(TYPES.BaseUserRepository) .to(PostgresUserRepository) .inRequestScope(); // Add caching layercontainer.bind<IUserRepository>(TYPES.CachedUserRepository) .toDynamicValue(ctx => { const base = ctx.container.get<IUserRepository>(TYPES.BaseUserRepository); const cache = ctx.container.get<ICache>(TYPES.ICache); return new CachingUserRepository(base, cache, { ttlSeconds: 300, keyPrefix: 'user:', }); }) .inRequestScope(); // Add logging layercontainer.bind<IUserRepository>(TYPES.LoggedUserRepository) .toDynamicValue(ctx => { const cached = ctx.container.get<IUserRepository>(TYPES.CachedUserRepository); const logger = ctx.container.get<ILogger>(TYPES.ILogger); return new LoggingUserRepository(cached, logger); }) .inRequestScope(); // The "public" binding uses the full decorator chaincontainer.bind<IUserRepository>(TYPES.IUserRepository) .toDynamicValue(ctx => ctx.container.get<IUserRepository>(TYPES.LoggedUserRepository)) .inRequestScope(); // 4. NAMED/KEYED REGISTRATIONS// Multiple implementations of the same interface container.bind<INotificationChannel>(TYPES.INotificationChannel) .to(EmailNotificationChannel) .whenTargetNamed('email'); container.bind<INotificationChannel>(TYPES.INotificationChannel) .to(SmsNotificationChannel) .whenTargetNamed('sms'); container.bind<INotificationChannel>(TYPES.INotificationChannel) .to(PushNotificationChannel) .whenTargetNamed('push'); // Injection with named dependencies@injectable()class MultiChannelNotificationService implements INotificationService { constructor( @inject(TYPES.INotificationChannel) @named('email') private emailChannel: INotificationChannel, @inject(TYPES.INotificationChannel) @named('sms') private smsChannel: INotificationChannel, @inject(TYPES.INotificationChannel) @named('push') private pushChannel: INotificationChannel, ) {} async notify(userId: string, message: Notification): Promise<void> { const user = await this.getUser(userId); const channels = this.getPreferredChannels(user); await Promise.all( channels.map(channel => channel.send(userId, message)) ); }} // 5. COLLECTION REGISTRATION// Inject all implementations of an interface container.bind<IEventHandler>(TYPES.IEventHandler) .to(LoggingEventHandler); container.bind<IEventHandler>(TYPES.IEventHandler) .to(MetricsEventHandler); container.bind<IEventHandler>(TYPES.IEventHandler) .to(AuditEventHandler); // Inject all handlers@injectable()class EventDispatcher { constructor( @multiInject(TYPES.IEventHandler) private handlers: IEventHandler[] ) {} async dispatch(event: DomainEvent): Promise<void> { await Promise.all( this.handlers.map(h => h.handle(event)) ); }}container.get() from within factory methods unless absolutely necessary. Prefer constructor injection.Imperative configuration is the default recommendation for most modern applications. It should be your starting point unless you have specific requirements that demand alternatives.
Many production systems combine approaches: code-based configuration for the core object graph with external configuration (environment variables, JSON files) for values that genuinely vary across deployments—connection strings, API keys, feature flags. The structure stays in code; the values come from configuration.
We've explored the foundational concepts of IoC container configuration, focusing on the dominant imperative (code-based) approach. Let's consolidate the key insights:
What's Next:
With the foundation of configuration approaches established, the next page dives into the three major configuration paradigms in detail: XML vs Code vs Attribute Configuration. We'll examine each approach's philosophy, implementation patterns, and scenarios where each excels.
You now understand the core challenge of container configuration and the dominant imperative approach. You can structure modular configurations, implement advanced patterns like decorators and factories, and make informed decisions about when code-based configuration is appropriate.