5. Best Practices and Common Patterns
Mastering a library like Injection-JS isn’t just about knowing its features; it’s about applying them effectively to write clean, maintainable, and robust code. This chapter covers essential best practices, common patterns, and potential pitfalls to help you leverage Injection-JS to its fullest.
Adhering to SOLID Principles
Dependency Injection itself is a powerful enabler of SOLID principles, which are fundamental to good software design.
- Single Responsibility Principle (SRP): Each class should have only one reason to change. DI helps by allowing a class to focus on its primary responsibility, delegating concerns like logging, data access, or authentication to injected dependencies.
- Bad: A
UserServicehandles user logic, and connects to the database, and logs messages. - Good:
UserServicehandles user logic and injectsUserRepository(for DB access) andLogger(for logging).
- Bad: A
- Open/Closed Principle (OCP): Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. With DI, you can introduce new implementations of an interface or abstract class without modifying the consumer of that dependency. This is especially clear with
useClass,useFactory, anduseExisting.- Example: If a
NotificationServiceis injected, you can switch fromEmailNotificationServicetoSMSNotificationServiceby changing a provider, not the client code.
- Example: If a
- Liskov Substitution Principle (LSP): Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program. When you inject an abstraction (like an interface represented by an
InjectionTokenor a base class), you can substitute different concrete implementations, and the client code should still work correctly. - Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions. DI enforces this by making high-level classes depend on abstract
InjectionTokens or interface-like class types, rather than concrete implementations. The injector then provides the concrete details.
When to Use Which Provider Type
useClass:- When: Most common scenario. When you need a new instance of a class, and that class has a straightforward constructor that the injector can resolve.
- Best For: Services, components, repositories, etc., that have their own dependencies resolved via their constructor.
- Example:
Logger,UserService.
useValue:- When: To provide a fixed, pre-existing JavaScript value (string, number, object, array, function) or an already instantiated object.
- Best For: Configuration objects (
APP_CONFIG), global constants, environment variables, or mock objects in tests. - Example:
{ provide: API_URL, useValue: 'https://api.example.com' }.
useFactory:- When: When the creation logic of a dependency is complex, conditional, or requires dynamic inputs not available during static provider definition. The factory function itself can depend on other injected services (
depsarray). - Best For: Services whose instantiation depends on runtime conditions (e.g., environment), or services that require some setup logic before being returned.
- Example:
analyticsServiceFactorycreating differentAnalyticsServiceinstances based onAppConfig.environment.
- When: When the creation logic of a dependency is complex, conditional, or requires dynamic inputs not available during static provider definition. The factory function itself can depend on other injected services (
useExisting:- When: To create an alias for an existing dependency. When you want a specific token to resolve to the same instance that another token resolves to. This is useful for providing multiple names for the same service or for implementing abstract class patterns.
- Best For: Implementing interfaces, providing alternative names, or ensuring a specific concrete implementation is returned when an abstraction is requested.
- Example:
{ provide: AbstractLogger, useExisting: FancyLogger }.
Leveraging InjectionToken
- Use
InjectionTokenfor non-class dependencies: Always useInjectionTokenfor primitive values, plain objects, functions, or when providing an abstraction that isn’t a class (like an interface). This ensures unique identification and strong typing. - Descriptive names: The string passed to
new InjectionToken<T>('YOUR_DESCRIPTIVE_NAME')is used in debugging messages. Make it clear what the token represents.
Architecting with Injector Hierarchies
- Root Injector for Singletons: Services that should be application-wide singletons (e.g.,
Logger,AuthService,ConfigurationService) should be provided at the root injector. - Child Injectors for Scoped Services: Use child injectors for services that need to be instantiated once per request, per module, or per feature. This helps with resource management and isolation.
- Clear Ownership: Each module or feature should be responsible for its own providers and services, registering them in its own injector.
- Avoid Overuse of
Injector.get(): Whileinjector.get()allows dynamic resolution, prefer constructor injection (@Inject()) whenever possible. Constructor injection makes dependencies explicit, improving readability and testability. UseInjector.get()sparingly, for truly dynamic or lazy-loaded scenarios where constructor injection isn’t feasible.
Handling Circular Dependencies
A circular dependency occurs when ServiceA depends on ServiceB, and ServiceB depends on ServiceA. This can lead to runtime errors or undefined values during initialization.
// Example of a circular dependency
@Injectable()
class ServiceA {
constructor(private serviceB: ServiceB) {} // Depends on ServiceB
}
@Injectable()
class ServiceB {
constructor(private serviceA: ServiceA) {} // Depends on ServiceA
}
Injection-JS (like Angular’s DI) can sometimes resolve simple circular class dependencies if one side of the circle is resolved lazily, but it’s generally an anti-pattern.
Strategies to resolve circular dependencies:
Refactor: The best solution is to refactor your code. Often, a circular dependency indicates a violation of SRP or a poorly defined boundary. Can you extract a shared dependency that both services use?
Introduce an Abstraction: Both services can depend on an interface (represented by an
InjectionToken) instead of concrete implementations.Lazy Resolution (using
Injector): If refactoring isn’t immediately possible, you can break the circularity by injecting theInjectoritself and resolving one dependency dynamically after construction. This should be used as a last resort.@Injectable() class ServiceA { constructor(private injector: Injector) {} // Inject Injector instead of ServiceB directly // Access ServiceB lazily getServiceB(): ServiceB { return this.injector.get(ServiceB); } } @Injectable() class ServiceB { constructor(private serviceA: ServiceA) {} // Still depends on ServiceA }Now
ServiceAdoesn’t demandServiceBat construction time.ServiceBcan be fully constructed, thenServiceA’s constructor finishes, and later,ServiceAcangetServiceBwhen needed.
Testing with Injection-JS
One of the biggest benefits of DI is improved testability.
- Mocking Dependencies: For unit tests, you can easily provide mock or stub implementations of services using
useValueoruseClassin a localReflectiveInjector. - Isolated Testing: Create a new
ReflectiveInjectorfor each test or test suite to ensure complete isolation and prevent side effects between tests.
// src/tests/user.service.spec.ts (example mock setup)
import "reflect-metadata";
import { ReflectiveInjector, Injectable } from 'injection-js';
// Assume these are your real services
@Injectable()
export class Logger {
log(message: string) { /* ... */ }
}
@Injectable()
export class UserRepository {
getUsers(): string[] { return ['Alice', 'Bob']; }
}
@Injectable()
export class UserService {
constructor(private logger: Logger, private userRepository: UserRepository) {}
getAllUserNames(): string[] {
this.logger.log('Fetching all users...');
return this.userRepository.getUsers();
}
}
// --- Test Setup ---
describe('UserService', () => {
let injector: ReflectiveInjector;
let userService: UserService;
let mockLogger: Partial<Logger>;
let mockUserRepository: Partial<UserRepository>;
beforeEach(() => {
// Create mock objects
mockLogger = {
log: jest.fn() // Use a mocking library like Jest for spy functions
};
mockUserRepository = {
getUsers: jest.fn(() => ['MockUser1', 'MockUser2'])
};
// Configure injector with mocks
injector = ReflectiveInjector.resolveAndCreate([
{ provide: Logger, useValue: mockLogger }, // Provide mock logger
{ provide: UserRepository, useValue: mockUserRepository }, // Provide mock repo
UserService // The service under test
]);
userService = injector.get(UserService);
});
it('should return mock users and log the action', () => {
const users = userService.getAllUserNames();
expect(users).toEqual(['MockUser1', 'MockUser2']);
expect(mockLogger.log).toHaveBeenCalledWith('Fetching all users...');
expect(mockUserRepository.getUsers).toHaveBeenCalledTimes(1);
});
it('should be a new instance for each test suite run', () => {
const anotherUserService = injector.get(UserService);
expect(anotherUserService).not.toBe(userService); // Depends on how test runner re-creates things
});
});
Common Pitfalls and How to Avoid Them
- Forgetting
reflect-metadataimport: Always import"reflect-metadata";at the very top of your application’s entry point, or at the top of shared files that might be imported first. Without it, decorator metadata won’t be available, andinjection-jswon’t know how to resolve class dependencies. - Incorrect
tsconfig.json: EnsureexperimentalDecoratorsandemitDecoratorMetadataaretruein yourtsconfig.json. - Missing
@Injectable(): Classes that have dependencies or are intended to be injected often need@Injectable()to register their metadata with the DI system, even if they don’t have explicit constructor parameters. - Not providing a dependency: If you try to
injector.get(Something)but no provider forSomethingis registered in the injector or its parents, you’ll get an error:No provider for Something!. Always ensure a provider exists. - Confusing
useValuevs.useClassvs.useFactory:useValue: Provides the exact value. Doesn’t instantiate.useClass: Instantiates the specified class.useFactory: Executes a function to get the value/instance. Be clear about which one you need.
- Over-reliance on
Injector.get(): As mentioned, prefer constructor injection. It’s more declarative and easier to reason about. - Deeply Nested Injector Hierarchies: While powerful, overly complex hierarchies can make debugging and understanding the application flow difficult. Strive for a balance between isolation and simplicity.
By following these best practices, you can design robust, maintainable, and scalable applications using Injection-JS. In the next section, we’ll apply these concepts in guided projects.
Exercises / Mini-Challenges
Exercise 5.1: Refactor for SRP with DI
Objective: Take a “god service” and refactor it into smaller services using DI.
Start with a
ReportGenerator(God Service):// src/bad-report-generator.ts class BadReportGenerator { generateMonthlyReport(month: string, year: number): string { console.log(`[ReportGenerator] Generating report for ${month}, ${year}...`); // Simulate fetching data const rawData = this.fetchSalesData(month, year); // Simulate processing data const processedData = this.processData(rawData); // Simulate formatting const formattedReport = this.formatReport(processedData); // Simulate sending email this.sendEmail(formattedReport); console.log(`[ReportGenerator] Report generation complete.`); return formattedReport; } private fetchSalesData(month: string, year: number): any[] { console.log(" Fetching sales data..."); return [{ product: 'A', sales: 100 }, { product: 'B', sales: 150 }]; } private processData(data: any[]): any { console.log(" Processing data..."); return data.reduce((acc, item) => acc + item.sales, 0); // Total sales } private formatReport(totalSales: number): string { console.log(" Formatting report..."); return `Monthly Sales Report: Total Sales = $${totalSales}`; } private sendEmail(reportContent: string): void { console.log(" Sending email..."); // Imagine sending an actual email here console.log(` Email sent with content: "${reportContent}"`); } } // How it's currently used: // const generator = new BadReportGenerator(); // generator.generateMonthlyReport("October", 2025);Refactor using DI:
- Create separate
@Injectable()services forSalesDataSource,DataProcessor,ReportFormatter, andEmailSender. - Modify
ReportGeneratorto inject these services and delegate responsibilities to them. - Update your
app.tsto provide all these new services andReportGenerator.
// src/refactored-report-generator.ts import "reflect-metadata"; import { ReflectiveInjector, Injectable } from 'injection-js'; // 1. Sales Data Source @Injectable() export class SalesDataSource { fetchSalesData(month: string, year: number): any[] { console.log(" [SalesDataSource] Fetching sales data..."); return [{ product: 'A', sales: 100 }, { product: 'B', sales: 150 }]; } } // 2. Data Processor @Injectable() export class DataProcessor { processData(data: any[]): number { console.log(" [DataProcessor] Processing data..."); return data.reduce((acc, item) => acc + item.sales, 0); } } // 3. Report Formatter @Injectable() export class ReportFormatter { formatReport(totalSales: number, month: string, year: number): string { console.log(" [ReportFormatter] Formatting report..."); return `Monthly Sales Report for ${month}, ${year}: Total Sales = $${totalSales}`; } } // 4. Email Sender @Injectable() export class EmailSender { sendEmail(recipient: string, subject: string, body: string): void { console.log(` [EmailSender] Sending email to ${recipient} with subject "${subject}"...`); console.log(` Email content: "${body}"`); } } // 5. Refactored ReportGenerator @Injectable() export class ReportGenerator { constructor( private salesDataSource: SalesDataSource, private dataProcessor: DataProcessor, private reportFormatter: ReportFormatter, private emailSender: EmailSender ) {} generateMonthlyReport(month: string, year: number): string { console.log(`\n[ReportGenerator] Generating report for ${month}, ${year}...`); const rawData = this.salesDataSource.fetchSalesData(month, year); const processedData = this.dataProcessor.processData(rawData); const formattedReport = this.reportFormatter.formatReport(processedData, month, year); this.emailSender.sendEmail("reports@example.com", "Monthly Sales", formattedReport); console.log(`[ReportGenerator] Report generation complete.`); return formattedReport; } } // --- Using the refactored system --- const providers = [ SalesDataSource, DataProcessor, ReportFormatter, EmailSender, ReportGenerator ]; const injector = ReflectiveInjector.resolveAndCreate(providers); const reportGenerator = injector.get(ReportGenerator); reportGenerator.generateMonthlyReport("October", 2025);Compare the
BadReportGeneratorwith theReportGenerator. The DI version is much more modular, testable, and adheres to SRP.- Create separate
Exercise 5.2: Conditional Email Sender with useFactory
Objective: Enhance the ReportGenerator system from Exercise 5.1 to conditionally use a MockEmailSender in development and the real EmailSender in production, using a useFactory provider and APP_CONFIG.
Re-use
APP_CONFIGandAppConfigfromsrc/tokens.ts(Chapter 2).Create a
MockEmailSender:// src/services/mock-email-sender.ts import { Injectable } from 'injection-js'; import { EmailSender } from './refactored-report-generator'; // Assuming EmailSender exists @Injectable() export class MockEmailSender extends EmailSender { override sendEmail(recipient: string, subject: string, body: string): void { console.warn(` [MockEmailSender] MOCK: Would send email to ${recipient} (Subject: "${subject}") with content: "${body}"`); } }Modify
app.ts(orconditional-email.ts):- Define an
AppConfigwithenvironment: 'development'or'production'. - Create a
emailSenderFactoryfunction that decides whether to returnEmailSenderorMockEmailSenderbased on theAppConfig. - Update the
providersarray to use this factory forEmailSender. - Run with both
developmentandproductionenvironments to see the difference.
// src/conditional-email.ts import "reflect-metadata"; import { ReflectiveInjector, Injectable, InjectionToken, Inject } from 'injection-js'; // --- Re-using and simplifying previous service definitions --- // (In a real app, these would be separate files) @Injectable() export class SalesDataSource { /* ... */ } @Injectable() export class DataProcessor { /* ... */ } @Injectable() export class ReportFormatter { /* ... */ } // Base EmailSender @Injectable() export class EmailSender { sendEmail(recipient: string, subject: string, body: string): void { console.log(` [REAL EmailSender] Sending email to ${recipient} (Subject: "${subject}") with content: "${body}"`); } } // Mock EmailSender @Injectable() export class MockEmailSender extends EmailSender { override sendEmail(recipient: string, subject: string, body: string): void { console.warn(` [MockEmailSender] MOCK: Would send email to ${recipient} (Subject: "${subject}") with content: "${body}"`); } } // AppConfig token export interface AppConfig { apiUrl: string; environment: 'development' | 'production'; } export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG'); // Refactored ReportGenerator (depends on EmailSender, which will be provided by factory) @Injectable() export class ReportGenerator { constructor( private salesDataSource: SalesDataSource, private dataProcessor: DataProcessor, private reportFormatter: ReportFormatter, private emailSender: EmailSender // Injected via factory ) {} generateMonthlyReport(month: string, year: number): string { console.log(`\n[ReportGenerator] Generating report for ${month}, ${year}...`); const rawData = this.salesDataSource.fetchSalesData(month, year); const processedData = this.dataProcessor.processData(rawData); const formattedReport = this.reportFormatter.formatReport(processedData, month, year); this.emailSender.sendEmail("reports@example.com", "Monthly Sales", formattedReport); console.log(`[ReportGenerator] Report generation complete.`); return formattedReport; } } // --- End Service Definitions --- // Factory to provide the correct EmailSender based on environment const emailSenderFactory = (config: AppConfig) => { if (config.environment === 'production') { return new EmailSender(); } else { return new MockEmailSender(); } }; // Scenario 1: Development Environment const devAppConfig: AppConfig = { apiUrl: 'dev-api.com', environment: 'development' }; const devProviders = [ SalesDataSource, DataProcessor, ReportFormatter, // Provide APP_CONFIG { provide: APP_CONFIG, useValue: devAppConfig }, // Provide EmailSender using the factory { provide: EmailSender, useFactory: emailSenderFactory, deps: [APP_CONFIG] }, ReportGenerator ]; console.log("--- Running in Development Environment ---"); const devInjector = ReflectiveInjector.resolveAndCreate(devProviders); const devReportGenerator = devInjector.get(ReportGenerator); devReportGenerator.generateMonthlyReport("November", 2025); // Scenario 2: Production Environment const prodAppConfig: AppConfig = { apiUrl: 'prod-api.com', environment: 'production' }; const prodProviders = [ SalesDataSource, DataProcessor, ReportFormatter, { provide: APP_CONFIG, useValue: prodAppConfig }, { provide: EmailSender, useFactory: emailSenderFactory, deps: [APP_CONFIG] }, ReportGenerator ]; console.log("\n--- Running in Production Environment ---"); const prodInjector = ReflectiveInjector.resolveAndCreate(prodProviders); const prodReportGenerator = prodInjector.get(ReportGenerator); prodReportGenerator.generateMonthlyReport("November", 2025); // Clean up env vars (important for subsequent tests/runs) delete process.env.DB_URL; delete process.env.API_URL; delete process.env.DEBUG_MODE;This exercise perfectly demonstrates how to use
useFactoryfor environment-specific dependency swapping, a critical pattern for robust applications.- Define an