Loading content...
In our exploration of Dependency Injection, we've examined Constructor Injection and Setter Injection—two powerful mechanisms for decoupling components from their dependencies. But there exists a third, often overlooked form: Interface Injection.
Interface Injection inverts the traditional injection model. Instead of the consuming class deciding how to receive dependencies (through constructors or setters), the dependency itself defines an interface that clients must implement to receive it. This subtle shift in responsibility creates powerful new possibilities for framework-driven injection, plugin architectures, and late-binding dependency resolution.
Understanding Interface Injection completes your mastery of the DI landscape, enabling you to select the right injection mechanism for any architectural scenario.
By the end of this page, you will understand the fundamental mechanics of Interface Injection—how dependencies provide injector interfaces, why this approach exists, and how it differs conceptually from constructor and setter injection. You'll learn to recognize scenarios where Interface Injection provides unique architectural advantages.
Interface Injection is a form of Dependency Injection where the dependency type provides an interface that defines how it should be injected into clients. Any class wanting to receive this dependency must implement the injector interface.
This creates a contract-based injection model: the dependency doesn't just exist passively waiting to be injected—it actively defines the injection protocol.
The conceptual shift:
This reversal of control places the dependency—or more precisely, the dependency's injector interface—at the center of the injection design.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// Step 1: The dependency defines an injector interface// This interface specifies HOW the dependency should be injectedinterface LoggerInjector { injectLogger(logger: Logger): void;} // Step 2: The Logger interface (the actual dependency contract)interface Logger { log(message: string): void; error(message: string, error?: Error): void; warn(message: string): void;} // Step 3: Concrete implementation of Loggerclass ConsoleLogger implements Logger { log(message: string): void { console.log(`[LOG] ${new Date().toISOString()}: ${message}`); } error(message: string, error?: Error): void { console.error(`[ERROR] ${new Date().toISOString()}: ${message}`, error); } warn(message: string): void { console.warn(`[WARN] ${new Date().toISOString()}: ${message}`); }} // Step 4: Client implements the injector interface to receive the dependencyclass OrderService implements LoggerInjector { private logger!: Logger; // The injector interface method - this is how OrderService receives its Logger injectLogger(logger: Logger): void { if (!logger) { throw new Error("Logger dependency cannot be null"); } this.logger = logger; } processOrder(orderId: string): void { this.logger.log(`Processing order: ${orderId}`); // Order processing logic... this.logger.log(`Order ${orderId} processed successfully`); }} // Step 5: The injector (framework/container) performs injectionclass DependencyInjector { private logger: Logger; constructor() { this.logger = new ConsoleLogger(); } // The injector detects LoggerInjector implementations and injects injectInto(target: unknown): void { if (this.implementsLoggerInjector(target)) { target.injectLogger(this.logger); } } private implementsLoggerInjector(obj: unknown): obj is LoggerInjector { return obj !== null && typeof obj === 'object' && 'injectLogger' in obj && typeof (obj as LoggerInjector).injectLogger === 'function'; }} // Usageconst injector = new DependencyInjector();const orderService = new OrderService();injector.injectInto(orderService);orderService.processOrder("ORD-12345");Key observations:
LoggerInjector is the injector interface—it defines the injection method signatureOrderService implements LoggerInjector, declaring its intent to receive a LoggerDependencyInjector (framework) detects implementations and calls the injection methodThis pattern is less common than constructor or setter injection but appears frequently in frameworks that need to inject dependencies into objects they didn't create.
An injector interface is a carefully designed contract that enables dependency injection through interface implementation. Let's dissect its components and understand the design principles that make it effective.
Core Components:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
// ========================================// EXAMPLE 1: Simple Injector Interface// ========================================interface DatabaseConnectionInjector { // Single method, single dependency, clear naming injectDatabaseConnection(connection: DatabaseConnection): void;} // ========================================// EXAMPLE 2: Injector Interface with Lifecycle Awareness// ========================================interface CacheServiceInjector { // Injection method injectCacheService(cache: CacheService): void; // Optional: lifecycle callback after injection complete onCacheServiceReady?(): void;} // ========================================// EXAMPLE 3: Generic Injector Interface Pattern// ========================================interface DependencyInjector<T> { inject(dependency: T): void;} // Typed specializationsinterface ConfigInjector extends DependencyInjector<Configuration> {}interface MetricsInjector extends DependencyInjector<MetricsCollector> {} // ========================================// EXAMPLE 4: Injector Interface with Validation// ========================================interface SecurityContextInjector { // Injection with validation capability injectSecurityContext(context: SecurityContext): boolean; // Client can reject incompatible security contexts validateSecurityContext(context: SecurityContext): boolean;} // Implementation demonstrating validationclass SecurePaymentProcessor implements SecurityContextInjector { private securityContext!: SecurityContext; private readonly requiredPermissions = ['payment.process', 'payment.refund']; validateSecurityContext(context: SecurityContext): boolean { // Validate that context has required permissions return this.requiredPermissions.every( perm => context.hasPermission(perm) ); } injectSecurityContext(context: SecurityContext): boolean { if (!this.validateSecurityContext(context)) { console.error("Security context lacks required permissions"); return false; } this.securityContext = context; return true; } processPayment(amount: number): void { if (!this.securityContext) { throw new Error("Security context not injected"); } // Secure payment processing... }} // ========================================// EXAMPLE 5: Composite Injector Interface// ========================================// When a class needs multiple related dependencies injected togetherinterface ServiceBundleInjector { injectServiceBundle(bundle: { logger: Logger; metrics: MetricsCollector; tracer: DistributedTracer; }): void;} // Client implementationclass DistributedOrderProcessor implements ServiceBundleInjector { private logger!: Logger; private metrics!: MetricsCollector; private tracer!: DistributedTracer; injectServiceBundle(bundle: { logger: Logger; metrics: MetricsCollector; tracer: DistributedTracer; }): void { this.logger = bundle.logger; this.metrics = bundle.metrics; this.tracer = bundle.tracer; } async processDistributedOrder(order: Order): Promise<void> { const span = this.tracer.startSpan('processOrder'); try { this.logger.log(`Processing order ${order.id}`); this.metrics.increment('orders.processed'); // Processing logic... span.finish(); } catch (error) { this.logger.error(`Order processing failed`, error as Error); this.metrics.increment('orders.failed'); span.setError(error); throw error; } }}A common naming convention is [DependencyType]Injector or [DependencyType]Aware. For example: LoggerInjector, CacheAware, TransactionAware. The *Aware suffix is particularly common in Java/Spring ecosystems (e.g., ApplicationContextAware, BeanNameAware).
Interface Injection follows a specific protocol that orchestrates the interaction between three participants: the dependency provider, the injector interface, and the client.
Protocol Flow:
This protocol is more complex than constructor injection but provides additional flexibility for framework-driven architectures.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
// ========================================// PHASE 1: DEFINITION - Module defines dependency and injector interface// ========================================// This could be a library, framework, or infrastructure modulenamespace MessagingModule { // The actual dependency interface export interface MessageBroker { publish(topic: string, message: Message): Promise<void>; subscribe(topic: string, handler: MessageHandler): Subscription; unsubscribe(subscription: Subscription): void; } // The injector interface - defines HOW to receive the dependency export interface MessageBrokerInjector { injectMessageBroker(broker: MessageBroker): void; } // Helper function for type checking export function isMessageBrokerInjector(obj: unknown): obj is MessageBrokerInjector { return obj !== null && typeof obj === 'object' && 'injectMessageBroker' in obj && typeof (obj as MessageBrokerInjector).injectMessageBroker === 'function'; }} // ========================================// PHASE 2: DECLARATION - Client implements injector interface// ========================================class NotificationService implements MessagingModule.MessageBrokerInjector { private broker!: MessagingModule.MessageBroker; private initialized = false; // Implementing the injector interface method injectMessageBroker(broker: MessagingModule.MessageBroker): void { console.log('[NotificationService] Receiving MessageBroker dependency'); this.broker = broker; this.initialized = true; } async sendNotification(userId: string, notification: Notification): Promise<void> { this.ensureInitialized(); await this.broker.publish('notifications', { type: 'user.notification', payload: { userId, notification }, timestamp: new Date() }); } private ensureInitialized(): void { if (!this.initialized) { throw new Error( 'NotificationService not initialized. ' + 'MessageBroker must be injected before use.' ); } }} // ========================================// PHASE 3 & 4: DISCOVERY AND INJECTION - Container orchestrates// ========================================class ApplicationContainer { private components: Map<string, object> = new Map(); private messageBroker: MessagingModule.MessageBroker; constructor() { // Container manages the dependency lifecycle this.messageBroker = this.createMessageBroker(); } private createMessageBroker(): MessagingModule.MessageBroker { // Could be RabbitMQ, Kafka, Redis Pub/Sub, etc. return new RabbitMQBroker({ host: process.env.RABBITMQ_HOST, port: parseInt(process.env.RABBITMQ_PORT || '5672'), }); } // Register a component with the container register(name: string, component: object): void { this.components.set(name, component); // DISCOVERY: Check if component implements injector interface this.performInterfaceInjection(component); } // INJECTION: Automatically inject dependencies based on interfaces private performInterfaceInjection(component: object): void { // Check for MessageBroker injection if (MessagingModule.isMessageBrokerInjector(component)) { console.log('[Container] Detected MessageBrokerInjector, injecting...'); component.injectMessageBroker(this.messageBroker); } // Additional injector interface checks would follow... if (isLoggerInjector(component)) { component.injectLogger(this.getLogger()); } if (isCacheInjector(component)) { component.injectCache(this.getCache()); } } // Create and wire a component createComponent<T extends object>( componentClass: new () => T, name: string ): T { const component = new componentClass(); this.register(name, component); return component; }} // ========================================// PHASE 5: ACTIVATION - Using the fully wired system// ========================================async function main() { const container = new ApplicationContainer(); // The container creates and injects automatically const notificationService = container.createComponent( NotificationService, 'notificationService' ); // Service is ready to use - MessageBroker was injected await notificationService.sendNotification('user-123', { title: 'Welcome!', body: 'Thanks for signing up.', type: 'welcome' });}Interface Injection naturally gravitates toward container-managed architectures. The container becomes the central orchestrator that understands injection protocols and performs discovery. This makes Interface Injection particularly suitable for plugin architectures and framework extension points.
At first glance, having dependencies define their own injection mechanism seems backwards. Why should a Logger care about how it gets into a UserService? The answer reveals Interface Injection's unique strengths:
1. Framework Extension Points
When building extensible frameworks, the framework cannot know what classes users will create. But it can define injector interfaces that user classes implement to receive framework services.
2. Standardized Injection Across Diverse Clients
When many unrelated classes need the same dependency, an injector interface standardizes how they all receive it—creating consistency in large codebases.
3. Late-Binding and Runtime Discovery
Interfar Injection enables discovering injection requirements at runtime through reflection, enabling powerful container features.
4. Plugin Architectures
Plugins developed by third parties can declare their dependency needs through interfaces, without the host application needing to know implementation details.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
// ========================================// A web framework defines injector interfaces for its services// Plugins implement these interfaces to receive framework services// ======================================== namespace WebFramework { // Framework services export interface RequestContext { request: HttpRequest; response: HttpResponse; session: Session; params: Record<string, string>; } export interface ApplicationConfig { get<T>(key: string): T; has(key: string): boolean; } export interface PluginRegistry { registerRoute(route: RouteDefinition): void; registerMiddleware(middleware: Middleware): void; } // Injector interfaces defined by the framework export interface RequestContextInjector { injectRequestContext(context: RequestContext): void; } export interface ConfigInjector { injectConfig(config: ApplicationConfig): void; } export interface PluginRegistryInjector { injectPluginRegistry(registry: PluginRegistry): void; } // Discovery helpers export function needsRequestContext(obj: unknown): obj is RequestContextInjector { return typeof (obj as RequestContextInjector)?.injectRequestContext === 'function'; } export function needsConfig(obj: unknown): obj is ConfigInjector { return typeof (obj as ConfigInjector)?.injectConfig === 'function'; } export function needsRegistry(obj: unknown): obj is PluginRegistryInjector { return typeof (obj as PluginRegistryInjector)?.injectPluginRegistry === 'function'; }} // ========================================// Third-party plugin implements injector interfaces// The plugin author doesn't modify the framework - they conform to its contracts// ========================================class AuthenticationPlugin implements WebFramework.ConfigInjector, WebFramework.PluginRegistryInjector { private config!: WebFramework.ApplicationConfig; private registry!: WebFramework.PluginRegistry; // Receive configuration from framework injectConfig(config: WebFramework.ApplicationConfig): void { console.log('[AuthPlugin] Receiving framework configuration'); this.config = config; } // Receive plugin registry for registration injectPluginRegistry(registry: WebFramework.PluginRegistry): void { console.log('[AuthPlugin] Receiving plugin registry'); this.registry = registry; this.registerRoutes(); } private registerRoutes(): void { const jwtSecret = this.config.get<string>('jwt.secret'); this.registry.registerRoute({ method: 'POST', path: '/auth/login', handler: this.loginHandler.bind(this) }); this.registry.registerRoute({ method: 'POST', path: '/auth/logout', handler: this.logoutHandler.bind(this) }); this.registry.registerMiddleware( this.createAuthMiddleware(jwtSecret) ); } private loginHandler(ctx: WebFramework.RequestContext): Response { // Login implementation } private logoutHandler(ctx: WebFramework.RequestContext): Response { // Logout implementation } private createAuthMiddleware(secret: string): Middleware { // JWT validation middleware }} // ========================================// Framework's plugin loader uses interface detection// ========================================class FrameworkPluginLoader { private config: WebFramework.ApplicationConfig; private registry: WebFramework.PluginRegistry; loadPlugin(plugin: object): void { console.log('[Framework] Loading plugin, checking injection requirements...'); // Automatic discovery and injection based on interface implementation if (WebFramework.needsConfig(plugin)) { console.log('[Framework] Plugin needs config - injecting'); plugin.injectConfig(this.config); } if (WebFramework.needsRegistry(plugin)) { console.log('[Framework] Plugin needs registry - injecting'); plugin.injectPluginRegistry(this.registry); } // The framework doesn't need to know AuthenticationPlugin specifically // It just detects what interfaces it implements and injects accordingly }} // Usage: Plugin author develops independentlyconst plugin = new AuthenticationPlugin();const loader = new FrameworkPluginLoader();loader.loadPlugin(plugin); // Framework injects what the plugin declared it needsThe power of this approach:
Understanding Interface Injection's place in the DI landscape requires comparing it to Constructor and Setter Injection. Each form has distinct characteristics that suit different scenarios.
Key Differentiators:
| Characteristic | Constructor Injection | Setter Injection | Interface Injection |
|---|---|---|---|
| Control of injection method | Client decides | Client decides | Dependency decides |
| When dependency is received | At construction time | After construction | After construction |
| Dependency visibility | In constructor signature | In setter methods | In implemented interfaces |
| Required vs optional | Naturally required | Naturally optional | Framework determines |
| Immutability support | Excellent (final fields) | Poor (mutable) | Moderate (framework-controlled) |
| Framework integration | Registration-based | Registration-based | Discovery-based |
| Boilerplate | Low | Medium | Higher |
| Testing ease | Very easy | Easy | Requires setup |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
// Service interface (the dependency)interface UserRepository { findById(id: string): Promise<User | null>; save(user: User): Promise<void>;} // ========================================// CONSTRUCTOR INJECTION// Client declares dependencies in constructor// ========================================class ConstructorInjectedUserService { // Dependencies are final/readonly - set once at construction constructor( private readonly userRepository: UserRepository, private readonly logger: Logger ) {} async getUser(id: string): Promise<User | null> { this.logger.log(`Fetching user ${id}`); return this.userRepository.findById(id); }} // Usage: Dependencies passed at constructionconst serviceA = new ConstructorInjectedUserService( new PostgresUserRepository(), new ConsoleLogger()); // ========================================// SETTER INJECTION// Client provides setters for dependencies// ========================================class SetterInjectedUserService { private userRepository!: UserRepository; private logger!: Logger; // Dependencies set via setters after construction setUserRepository(repo: UserRepository): void { this.userRepository = repo; } setLogger(logger: Logger): void { this.logger = logger; } async getUser(id: string): Promise<User | null> { if (!this.userRepository || !this.logger) { throw new Error('Dependencies not injected'); } this.logger.log(`Fetching user ${id}`); return this.userRepository.findById(id); }} // Usage: Dependencies set after constructionconst serviceB = new SetterInjectedUserService();serviceB.setUserRepository(new PostgresUserRepository());serviceB.setLogger(new ConsoleLogger()); // ========================================// INTERFACE INJECTION// Dependency module defines injector interfaces, client implements them// ======================================== // Dependency module defines these interfacesinterface UserRepositoryInjector { injectUserRepository(repo: UserRepository): void;} interface LoggerInjector { injectLogger(logger: Logger): void;} // Client implements injector interfacesclass InterfaceInjectedUserService implements UserRepositoryInjector, LoggerInjector { private userRepository!: UserRepository; private logger!: Logger; // Injection methods defined by the injector interfaces injectUserRepository(repo: UserRepository): void { this.userRepository = repo; } injectLogger(logger: Logger): void { this.logger = logger; } async getUser(id: string): Promise<User | null> { this.logger.log(`Fetching user ${id}`); return this.userRepository.findById(id); }} // Usage: Container discovers and injects based on interface implementationconst serviceC = new InterfaceInjectedUserService();container.wire(serviceC); // Container calls injectUserRepository and injectLoggerInterface Injection shines when: (1) building extensible frameworks where client code is unknown, (2) implementing plugin architectures, (3) working with containers that favor discovery over registration, or (4) when dependencies need to control their own injection semantics (like lifecycle-aware injection).
A common manifestation of Interface Injection is the Aware Interface Pattern, popularized by the Spring Framework. In this pattern, injector interfaces follow a *Aware naming convention, signaling that implementing classes are "aware of" (and receive) specific framework capabilities.
Common Aware Interfaces:
ApplicationContextAware — Receives the application contextBeanNameAware — Receives the bean's name in the containerBeanFactoryAware — Receives the bean factoryResourceLoaderAware — Receives a resource loaderEnvironmentAware — Receives the application environmentThis pattern is interface injection in action: the framework defines the interfaces, and beans implement them to receive framework services.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
// ========================================// Define Aware interfaces for framework services// ========================================interface ApplicationContextAware { setApplicationContext(context: ApplicationContext): void;} interface EnvironmentAware { setEnvironment(environment: Environment): void;} interface LifecycleAware { onPostConstruct(): void; onPreDestroy(): void;} interface NameAware { setBeanName(name: string): void;} // ========================================// Framework's ApplicationContext and Environment// ========================================interface ApplicationContext { getBean<T>(name: string): T; getBeansByType<T>(type: new (...args: any[]) => T): T[]; containsBean(name: string): boolean; publishEvent(event: ApplicationEvent): void;} interface Environment { getProperty(key: string): string | undefined; getRequiredProperty(key: string): string; getActiveProfiles(): string[]; acceptsProfiles(...profiles: string[]): boolean;} // ========================================// A component implementing multiple Aware interfaces// ========================================class DatabaseMigrationService implements ApplicationContextAware, EnvironmentAware, LifecycleAware, NameAware { private context!: ApplicationContext; private environment!: Environment; private beanName!: string; // Receive the application context setApplicationContext(context: ApplicationContext): void { console.log(`[${this.beanName || 'DatabaseMigration'}] Received ApplicationContext`); this.context = context; } // Receive the environment setEnvironment(environment: Environment): void { console.log(`[${this.beanName || 'DatabaseMigration'}] Received Environment`); this.environment = environment; } // Receive our bean name setBeanName(name: string): void { console.log(`[DatabaseMigration] My bean name is: ${name}`); this.beanName = name; } // Called after all injection is complete onPostConstruct(): void { console.log(`[${this.beanName}] Post-construct: initializing`); if (this.shouldAutoMigrate()) { this.runMigrations(); } } // Called before bean destruction onPreDestroy(): void { console.log(`[${this.beanName}] Pre-destroy: cleaning up`); this.closeConnections(); } private shouldAutoMigrate(): boolean { const profile = this.environment.getActiveProfiles(); const autoMigrate = this.environment.getProperty('database.autoMigrate'); // Only auto-migrate in development return profile.includes('development') && autoMigrate === 'true'; } private runMigrations(): void { const migrationRunner = this.context.getBean<MigrationRunner>('migrationRunner'); migrationRunner.runPending(); } private closeConnections(): void { // Cleanup }} // ========================================// Framework's bean processor handles Aware injection// ========================================class BeanPostProcessor { constructor( private readonly context: ApplicationContext, private readonly environment: Environment ) {} processBean(beanName: string, bean: object): void { console.log(`[BeanProcessor] Processing bean: ${beanName}`); // Process Aware interfaces in order if (this.isNameAware(bean)) { bean.setBeanName(beanName); } if (this.isEnvironmentAware(bean)) { bean.setEnvironment(this.environment); } if (this.isApplicationContextAware(bean)) { bean.setApplicationContext(this.context); } // Lifecycle callback if (this.isLifecycleAware(bean)) { bean.onPostConstruct(); } } destroyBean(beanName: string, bean: object): void { if (this.isLifecycleAware(bean)) { bean.onPreDestroy(); } } // Type guards for Aware interface detection private isNameAware(obj: unknown): obj is NameAware { return typeof (obj as NameAware)?.setBeanName === 'function'; } private isEnvironmentAware(obj: unknown): obj is EnvironmentAware { return typeof (obj as EnvironmentAware)?.setEnvironment === 'function'; } private isApplicationContextAware(obj: unknown): obj is ApplicationContextAware { return typeof (obj as ApplicationContextAware)?.setApplicationContext === 'function'; } private isLifecycleAware(obj: unknown): obj is LifecycleAware { return typeof (obj as LifecycleAware)?.onPostConstruct === 'function' && typeof (obj as LifecycleAware)?.onPreDestroy === 'function'; }}A significant tradeoff of Aware interfaces is that they couple your beans to the framework. A class implementing ApplicationContextAware is tied to that specific container abstraction. Use Aware interfaces sparingly—prefer constructor injection for application dependencies, and reserve Aware interfaces for true framework extension points.
We've established the foundational concept of Interface Injection—how dependencies provide injector interfaces that clients implement to receive them. This page covered the core mechanics; subsequent pages will explore implementation patterns, use cases, and detailed comparisons.
*Aware interfaces) popularized by enterprise frameworks.You now understand the fundamental concept of Interface Injection and how dependencies provide injector interfaces. Next, we'll explore how implementations provide dependencies through this mechanism, examining concrete implementation patterns and architecture considerations.