Loading learning content...
Understanding Strategy as an OCP enabler is one thing; implementing it effectively is another. The how of strategy usage—how strategies are created, selected, injected, and swapped—determines whether your OCP-compliant design is elegant and maintainable or a convoluted mess.
This page explores the mechanics of injecting behavior variations. We'll examine the different injection patterns, understand when each is appropriate, and see how strategy selection can be driven by configuration, user input, or runtime conditions. These techniques transform Strategy from a static pattern into a dynamic, powerful tool for building flexible systems.
Mastering these mechanics separates engineers who can apply patterns from those who can design sophisticated, production-ready architectures.
By the end of this page, you will master constructor injection, setter injection, and method injection for strategies. You'll understand factory patterns for strategy selection, runtime strategy switching, and how to handle strategy lifecycles in complex applications.
Constructor injection is the most common and generally preferred method for providing strategies to contexts. The strategy is passed as a constructor parameter, establishing the dependency at object creation time.
Why Constructor Injection Is Preferred:
Explicit Dependencies — The constructor signature clearly documents what the class needs. Anyone reading the code immediately sees that a strategy is required.
Immutability-Friendly — Once injected, the strategy field can be marked readonly/final, preventing accidental changes.
Complete Objects — The context is fully functional from instantiation. There's no intermediate state where the object exists but lacks required collaborators.
Testability — Tests can trivially inject mock strategies through the constructor.
DI Container Integration — Constructor injection is the default mode for most dependency injection frameworks.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
// CONSTRUCTOR INJECTION - The recommended default approach interface CompressionStrategy { compress(data: Buffer): CompressedData; decompress(compressed: CompressedData): Buffer; getCompressionRatio(): number;} class GzipCompressionStrategy implements CompressionStrategy { private level: number; constructor(level: number = 6) { this.level = level; } compress(data: Buffer): CompressedData { const compressed = zlib.gzipSync(data, { level: this.level }); return { data: compressed, algorithm: 'gzip', originalSize: data.length }; } decompress(compressed: CompressedData): Buffer { return zlib.gunzipSync(compressed.data); } getCompressionRatio(): number { return 0.7; // Typical ratio for gzip }} class ZstdCompressionStrategy implements CompressionStrategy { private compressionLevel: number; constructor(compressionLevel: number = 3) { this.compressionLevel = compressionLevel; } compress(data: Buffer): CompressedData { const compressed = zstd.compress(data, this.compressionLevel); return { data: compressed, algorithm: 'zstd', originalSize: data.length }; } decompress(compressed: CompressedData): Buffer { return zstd.decompress(compressed.data); } getCompressionRatio(): number { return 0.65; // Zstd typically achieves better ratios }} // The context receives its strategy via constructorclass FileStorageService { // Strategy is readonly - set once, never changed private readonly compressionStrategy: CompressionStrategy; private readonly storageBackend: StorageBackend; private readonly logger: Logger; // Constructor clearly documents dependencies constructor( compressionStrategy: CompressionStrategy, storageBackend: StorageBackend, logger: Logger ) { this.compressionStrategy = compressionStrategy; this.storageBackend = storageBackend; this.logger = logger; } async storeFile(filename: string, content: Buffer): Promise<StorageResult> { this.logger.info(`Storing file: ${filename}, original size: ${content.length}`); // Use injected strategy for compression const compressed = this.compressionStrategy.compress(content); this.logger.info( `Compressed to ${compressed.data.length} bytes ` + `(${(compressed.data.length / content.length * 100).toFixed(1)}%)` ); return this.storageBackend.save(filename, compressed); } async retrieveFile(filename: string): Promise<Buffer> { const compressed = await this.storageBackend.load(filename); return this.compressionStrategy.decompress(compressed); }} // Usage: Strategy determined at construction timeconst gzipService = new FileStorageService( new GzipCompressionStrategy(9), // Maximum compression s3Backend, logger); const zstdService = new FileStorageService( new ZstdCompressionStrategy(5), localBackend, logger); // With DI container (NestJS-style)@Injectable()class FileStorageService { constructor( @Inject(COMPRESSION_STRATEGY) private readonly compressionStrategy: CompressionStrategy, private readonly storageBackend: StorageBackend, private readonly logger: Logger ) {}}When using constructor injection with no intent to change strategies at runtime, mark the strategy field readonly (TypeScript), final (Java), or const (C++). This documents intent and prevents accidental mutation.
Setter injection (also called property injection) allows the strategy to be changed after object construction. This provides runtime flexibility at the cost of increased complexity.
When Setter Injection Makes Sense:
Dynamic Reconfiguration — The strategy needs to change based on runtime conditions, user preferences, or external events.
Admin Overrides — Operators need ability to swap strategies without restarting the application.
A/B Testing — Strategy can be switched mid-session for experimentation.
Graceful Degradation — System can fall back to simpler strategies under load.
The Trade-offs:
Setter injection introduces concerns that constructor injection avoids:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
// SETTER INJECTION - For runtime strategy changes interface CachingStrategy { get(key: string): Promise<CacheResult>; set(key: string, value: unknown, ttl?: number): Promise<void>; delete(key: string): Promise<void>; clear(): Promise<void>;} class InMemoryCacheStrategy implements CachingStrategy { private cache = new Map<string, { value: unknown; expires: number }>(); async get(key: string): Promise<CacheResult> { const entry = this.cache.get(key); if (!entry) return CacheResult.miss(); if (Date.now() > entry.expires) { this.cache.delete(key); return CacheResult.expired(); } return CacheResult.hit(entry.value); } async set(key: string, value: unknown, ttl: number = 3600000): Promise<void> { this.cache.set(key, { value, expires: Date.now() + ttl }); } async delete(key: string): Promise<void> { this.cache.delete(key); } async clear(): Promise<void> { this.cache.clear(); }} class RedisCacheStrategy implements CachingStrategy { constructor(private redis: RedisClient) {} async get(key: string): Promise<CacheResult> { const value = await this.redis.get(key); return value ? CacheResult.hit(JSON.parse(value)) : CacheResult.miss(); } async set(key: string, value: unknown, ttl: number = 3600): Promise<void> { await this.redis.setex(key, ttl, JSON.stringify(value)); } async delete(key: string): Promise<void> { await this.redis.del(key); } async clear(): Promise<void> { // Redis clear is typically done differently in production await this.redis.flushdb(); }} // Context with setter injection for runtime strategy changesclass CacheService { private cachingStrategy: CachingStrategy; private readonly logger: Logger; private readonly strategyChangeLock: AsyncLock; constructor(initialStrategy: CachingStrategy, logger: Logger) { this.cachingStrategy = initialStrategy; this.logger = logger; this.strategyChangeLock = new AsyncLock(); } // Setter for runtime strategy changes // Note the careful handling of concurrent access async setCachingStrategy(newStrategy: CachingStrategy): Promise<void> { await this.strategyChangeLock.acquire("strategy-change", async () => { this.logger.info( `Switching cache strategy from ${this.cachingStrategy.constructor.name} ` + `to ${newStrategy.constructor.name}` ); // Optional: Migrate existing cache data // This is a design decision - sometimes you want clean slate const oldStrategy = this.cachingStrategy; this.cachingStrategy = newStrategy; // Clear old strategy resources await oldStrategy.clear(); }); } // Get current strategy (useful for monitoring/debugging) getCurrentStrategyType(): string { return this.cachingStrategy.constructor.name; } // Standard caching operations delegate to current strategy async get<T>(key: string): Promise<T | null> { const result = await this.cachingStrategy.get(key); if (result.isHit) { this.logger.debug(`Cache hit: ${key}`); return result.value as T; } this.logger.debug(`Cache miss: ${key}`); return null; } async set(key: string, value: unknown, ttl?: number): Promise<void> { await this.cachingStrategy.set(key, value, ttl); } async getOrSet<T>( key: string, factory: () => Promise<T>, ttl?: number ): Promise<T> { const cached = await this.get<T>(key); if (cached !== null) return cached; const value = await factory(); await this.set(key, value, ttl); return value; }} // Usage: Strategy can change at runtimeconst cacheService = new CacheService(new InMemoryCacheStrategy(), logger); // Initial operation uses in-memory cacheawait cacheService.set("user:123", userData); // Administrator triggers strategy change (e.g., via admin API)adminRouter.post("/cache/strategy/redis", async (req, res) => { const redisClient = await createRedisClient(config); await cacheService.setCachingStrategy(new RedisCacheStrategy(redisClient)); res.json({ status: "Cache strategy changed to Redis" });}); // Under memory pressure, system can auto-switchmemoryMonitor.on("highMemory", async () => { if (cacheService.getCurrentStrategyType() === "InMemoryCacheStrategy") { logger.warn("High memory detected, switching to Redis cache"); await cacheService.setCachingStrategy(new RedisCacheStrategy(redis)); }});When strategies can be changed at runtime, consider thread safety. In the example above, we use an AsyncLock during strategy swap. Without synchronization, one thread might use a strategy while another is replacing it, leading to race conditions or using a disposed resource.
Method injection passes the strategy as a method parameter, allowing different strategies for each individual operation. This provides maximum granularity but requires the caller to manage strategy selection.
When Method Injection Makes Sense:
Per-Request Behavior — Each operation may need a different strategy based on input.
Caller-Determined Strategy — The caller, not the service, knows which strategy is appropriate.
One-Off Customization — Most calls use a default, but occasional calls need overrides.
Testing Specific Scenarios — Tests can inject specific strategies for specific test cases.
Best Practice: Include Default
When using method injection, consider providing a default strategy through constructor injection, with method injection as an override:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
// METHOD INJECTION - Strategy provided per-operation interface SerializationStrategy { serialize(data: unknown): Buffer; deserialize<T>(buffer: Buffer): T; getContentType(): string;} class JsonSerializationStrategy implements SerializationStrategy { serialize(data: unknown): Buffer { return Buffer.from(JSON.stringify(data), 'utf-8'); } deserialize<T>(buffer: Buffer): T { return JSON.parse(buffer.toString('utf-8')); } getContentType(): string { return 'application/json'; }} class ProtobufSerializationStrategy implements SerializationStrategy { constructor(private schema: ProtobufSchema) {} serialize(data: unknown): Buffer { return this.schema.encode(data); } deserialize<T>(buffer: Buffer): T { return this.schema.decode(buffer) as T; } getContentType(): string { return 'application/x-protobuf'; }} class MessagePackSerializationStrategy implements SerializationStrategy { serialize(data: unknown): Buffer { return msgpack.encode(data); } deserialize<T>(buffer: Buffer): T { return msgpack.decode(buffer) as T; } getContentType(): string { return 'application/x-msgpack'; }} // Service with method injection + default strategyclass MessageService { private readonly defaultStrategy: SerializationStrategy; private readonly transport: MessageTransport; constructor( defaultStrategy: SerializationStrategy, transport: MessageTransport ) { this.defaultStrategy = defaultStrategy; this.transport = transport; } // Method injection: caller can override strategy per-call async sendMessage( destination: string, message: unknown, strategy?: SerializationStrategy // Optional override ): Promise<SendResult> { // Use provided strategy or fall back to default const activeStrategy = strategy ?? this.defaultStrategy; const serialized = activeStrategy.serialize(message); const contentType = activeStrategy.getContentType(); return this.transport.send(destination, { body: serialized, headers: { 'Content-Type': contentType } }); } async receiveMessage<T>( source: string, strategy?: SerializationStrategy ): Promise<T> { const activeStrategy = strategy ?? this.defaultStrategy; const raw = await this.transport.receive(source); return activeStrategy.deserialize<T>(raw.body); }} // Usage: Different strategies for different situationsconst messageService = new MessageService( new JsonSerializationStrategy(), // Default to JSON transport); // Most messages use default JSON serializationawait messageService.sendMessage("queue-a", { type: "event", data: {} }); // High-throughput channel uses more efficient MessagePackconst msgpackStrategy = new MessagePackSerializationStrategy();for (const item of bulkData) { await messageService.sendMessage("bulk-queue", item, msgpackStrategy);} // Schema-enforced channel uses Protobufconst protoStrategy = new ProtobufSerializationStrategy(userSchema);await messageService.sendMessage("user-updates", userUpdate, protoStrategy); // Hybrid approach: strategy selection based on message characteristicsasync function sendOptimized(destination: string, message: unknown) { const size = JSON.stringify(message).length; // Small messages: JSON is fine (human-readable debugging) // Large messages: Use MessagePack for efficiency const strategy = size > 10000 ? new MessagePackSerializationStrategy() : new JsonSerializationStrategy(); return messageService.sendMessage(destination, message, strategy);}| Aspect | Constructor | Setter | Method |
|---|---|---|---|
| When strategy is determined | Object creation | After creation | Each call |
| Runtime flexibility | None (fixed) | High (changeable) | Maximum (per-call) |
| Complexity | Low | Medium | Medium-High |
| Thread safety concern | None | Yes (swap safety) | Generally safe |
| Testing ease | Excellent | Good | Excellent |
| DI container support | Native | Supported | Manual |
| Typical use case | Standard DI | Dynamic config | Per-request behavior |
When strategy selection follows well-defined rules based on input data or context, a factory encapsulates the selection logic. This keeps the selection centralized and testable while providing OCP compliance for the factory itself.
The Strategy Factory Pattern:
A strategy factory examines input (request data, configuration, user preferences) and returns the appropriate strategy. The factory is the single place where concrete strategy types are known.
OCP for Factories:
Even factories can be made OCP-compliant using a registry pattern. New strategies register themselves, and the factory dispatches based on a key—no modification to factory code required.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
// FACTORY-BASED STRATEGY SELECTION interface ExportStrategy { export(data: Data): Promise<ExportResult>; getFileExtension(): string; getMimeType(): string;} class CsvExportStrategy implements ExportStrategy { async export(data: Data): Promise<ExportResult> { const rows = data.map(item => Object.values(item).map(escapeCSV).join(',') ); const csv = [Object.keys(data[0]).join(','), ...rows].join('\n'); return { content: Buffer.from(csv), format: 'csv' }; } getFileExtension(): string { return 'csv'; } getMimeType(): string { return 'text/csv'; }} class ExcelExportStrategy implements ExportStrategy { async export(data: Data): Promise<ExportResult> { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('Data'); sheet.columns = Object.keys(data[0]).map(key => ({ header: key, key })); sheet.addRows(data); const buffer = await workbook.xlsx.writeBuffer(); return { content: Buffer.from(buffer), format: 'xlsx' }; } getFileExtension(): string { return 'xlsx'; } getMimeType(): string { return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; }} class PdfExportStrategy implements ExportStrategy { async export(data: Data): Promise<ExportResult> { const doc = new PDFDocument(); const table = new PdfTable(doc, data); table.render(); const buffer = await doc.toBuffer(); return { content: buffer, format: 'pdf' }; } getFileExtension(): string { return 'pdf'; } getMimeType(): string { return 'application/pdf'; }} // SIMPLE FACTORY - Encapsulates strategy selection logicclass ExportStrategyFactory { create(format: string): ExportStrategy { switch (format.toLowerCase()) { case 'csv': return new CsvExportStrategy(); case 'xlsx': case 'excel': return new ExcelExportStrategy(); case 'pdf': return new PdfExportStrategy(); default: throw new UnsupportedFormatError(`Unknown format: ${format}`); } }} // OCP-COMPLIANT FACTORY with Registry Patternclass ExportStrategyRegistry { private readonly strategies = new Map<string, () => ExportStrategy>(); // Strategies register themselves - no modification to registry needed register(format: string, factory: () => ExportStrategy): void { this.strategies.set(format.toLowerCase(), factory); } // Also supports registering a class directly registerClass(format: string, StrategyClass: new () => ExportStrategy): void { this.strategies.set(format.toLowerCase(), () => new StrategyClass()); } create(format: string): ExportStrategy { const factory = this.strategies.get(format.toLowerCase()); if (!factory) { throw new UnsupportedFormatError( `Unknown format: ${format}. Available: ${this.getAvailableFormats().join(', ')}` ); } return factory(); } getAvailableFormats(): string[] { return Array.from(this.strategies.keys()); } isSupported(format: string): boolean { return this.strategies.has(format.toLowerCase()); }} // Registration at application startupfunction configureExportStrategies(registry: ExportStrategyRegistry) { registry.registerClass('csv', CsvExportStrategy); registry.registerClass('xlsx', ExcelExportStrategy); registry.registerClass('pdf', PdfExportStrategy); // New: JSON export (can be added without modifying registry code) registry.registerClass('json', JsonExportStrategy); // New: XML export with custom configuration registry.register('xml', () => new XmlExportStrategy({ pretty: true }));} // Usage with registry-based factoryclass ExportService { constructor( private readonly strategyRegistry: ExportStrategyRegistry, private readonly storage: StorageService ) {} async exportData( data: Data, format: string, filename: string ): Promise<ExportResult> { // Factory selects appropriate strategy const strategy = this.strategyRegistry.create(format); // Export using selected strategy const result = await strategy.export(data); // Store with correct extension const fullFilename = `${filename}.${strategy.getFileExtension()}`; await this.storage.save(fullFilename, result.content, { contentType: strategy.getMimeType() }); return result; } getAvailableFormats(): string[] { return this.strategyRegistry.getAvailableFormats(); }} // API handler using factory-based selectionrouter.get('/export', async (req, res) => { const { format = 'csv', reportId } = req.query; if (!exportService.getAvailableFormats().includes(format)) { return res.status(400).json({ error: `Unsupported format. Available: ${exportService.getAvailableFormats()}` }); } const data = await dataService.getReportData(reportId); const result = await exportService.exportData(data, format, reportId); res.setHeader('Content-Type', result.mimeType); res.send(result.content);});The registry pattern makes even factories OCP-compliant. Instead of switch statements that must be modified for new strategies, the registry accepts registrations at startup. New strategies simply register themselves—the registry code never changes.
In sophisticated systems, strategy selection isn't just based on explicit parameters—it considers the full context: user identity, feature flags, request attributes, system state, and more. This context-aware resolution enables powerful patterns like gradual rollouts, personalization, and adaptive behavior.
Context Resolution Layers:
The strategy resolver examines these layers and determines the appropriate strategy. This is a more sophisticated factory that encapsulates complex decision logic.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
// CONTEXT-AWARE STRATEGY RESOLUTION interface RequestContext { user: User | null; request: Request; featureFlags: FeatureFlagSet; systemMetrics: SystemMetrics;} interface PricingStrategy { calculatePrice(product: Product, quantity: number): Money; getDiscountReason(): string | null;} class StandardPricingStrategy implements PricingStrategy { calculatePrice(product: Product, quantity: number): Money { return product.basePrice.multiply(quantity); } getDiscountReason(): string | null { return null; }} class VIPPricingStrategy implements PricingStrategy { private discountPercent: number; constructor(discountPercent: number) { this.discountPercent = discountPercent; } calculatePrice(product: Product, quantity: number): Money { const base = product.basePrice.multiply(quantity); return base.multiply(1 - this.discountPercent / 100); } getDiscountReason(): string | null { return `VIP ${this.discountPercent}% discount applied`; }} class BulkPricingStrategy implements PricingStrategy { private tiers: PricingTier[]; constructor(tiers: PricingTier[]) { this.tiers = tiers.sort((a, b) => b.minQuantity - a.minQuantity); } calculatePrice(product: Product, quantity: number): Money { const tier = this.tiers.find(t => quantity >= t.minQuantity); const multiplier = tier ? tier.priceMultiplier : 1; return product.basePrice.multiply(quantity).multiply(multiplier); } getDiscountReason(): string | null { return "Bulk pricing applied"; }} class PromotionalPricingStrategy implements PricingStrategy { constructor( private baseStrategy: PricingStrategy, private promotion: Promotion ) {} calculatePrice(product: Product, quantity: number): Money { const base = this.baseStrategy.calculatePrice(product, quantity); return this.promotion.apply(base); } getDiscountReason(): string | null { return `Promotion: ${this.promotion.name}`; }} // CONTEXT-AWARE RESOLVERclass PricingStrategyResolver { constructor( private readonly featureFlagService: FeatureFlagService, private readonly promotionService: PromotionService, private readonly metricsService: MetricsService ) {} async resolve(context: RequestContext): Promise<PricingStrategy> { // Layer 1: Check for promotional override (highest priority) const activePromotion = await this.promotionService.getActivePromotion( context.user?.id, context.request.query.promoCode ); // Layer 2: Determine base strategy based on user tier let baseStrategy: PricingStrategy = new StandardPricingStrategy(); if (context.user) { if (context.user.tier === 'vip') { baseStrategy = new VIPPricingStrategy(15); } else if (context.user.tier === 'platinum') { baseStrategy = new VIPPricingStrategy(25); } } // Layer 3: Check feature flags for bulk pricing experiment if (this.featureFlagService.isEnabled('bulk_pricing_v2', context.user?.id)) { baseStrategy = this.applyBulkPricingIfApplicable(baseStrategy, context); } // Layer 4: Apply promotional wrapper if active if (activePromotion) { baseStrategy = new PromotionalPricingStrategy(baseStrategy, activePromotion); } // Track which strategy was selected (for analytics) this.metricsService.recordStrategySelection('pricing', { strategy: baseStrategy.constructor.name, userId: context.user?.id, hasPromotion: !!activePromotion }); return baseStrategy; } private applyBulkPricingIfApplicable( strategy: PricingStrategy, context: RequestContext ): PricingStrategy { // Only apply bulk pricing for B2B customers if (context.user?.accountType === 'business') { return new BulkPricingStrategy([ { minQuantity: 100, priceMultiplier: 0.85 }, { minQuantity: 50, priceMultiplier: 0.90 }, { minQuantity: 20, priceMultiplier: 0.95 } ]); } return strategy; }} // Usage in service layerclass OrderService { constructor( private readonly pricingResolver: PricingStrategyResolver, private readonly orderRepository: OrderRepository ) {} async calculateOrderTotal( items: OrderItem[], context: RequestContext ): Promise<OrderTotal> { // Resolve strategy based on full context const pricingStrategy = await this.pricingResolver.resolve(context); let total = Money.zero(); const discounts: string[] = []; for (const item of items) { const lineTotal = pricingStrategy.calculatePrice( item.product, item.quantity ); total = total.add(lineTotal); } const discountReason = pricingStrategy.getDiscountReason(); if (discountReason) { discounts.push(discountReason); } return { total, discounts }; }}Notice how PromotionalPricingStrategy wraps another strategy (Decorator pattern combined with Strategy). This composition allows building complex pricing rules from simple, testable components. Each layer adds behavior without modifying others.
Strategies aren't always stateless. Some maintain connections, caches, or expensive resources. Managing their lifecycle—creation, usage, and disposal—is crucial for resource efficiency and correctness.
Lifecycle Patterns:
Singleton Strategies — One instance for entire application lifetime. Best for stateless strategies or those with thread-safe shared state.
Scoped Strategies — One instance per request/session. Appropriate when strategy needs request-specific state.
Transient Strategies — New instance per use. OK for stateless strategies; wasteful for expensive-to-create strategies.
Pooled Strategies — Strategies borrowed from pool and returned after use. Good for strategies with expensive resources (DB connections).
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
// STRATEGY LIFECYCLE MANAGEMENT interface DatabaseMigrationStrategy { migrate(connection: DatabaseConnection): Promise<MigrationResult>; rollback(connection: DatabaseConnection): Promise<void>; supportsTransactions(): boolean; // Lifecycle methods initialize?(): Promise<void>; dispose?(): Promise<void>;} // Stateless strategy - safe as singletonclass SqlMigrationStrategy implements DatabaseMigrationStrategy { async migrate(connection: DatabaseConnection): Promise<MigrationResult> { // Runs SQL from migration files const migrations = await this.loadPendingMigrations(connection); for (const migration of migrations) { await connection.execute(migration.sql); } return { migrationsApplied: migrations.length }; } async rollback(connection: DatabaseConnection): Promise<void> { const lastMigration = await this.getLastMigration(connection); if (lastMigration.rollbackSql) { await connection.execute(lastMigration.rollbackSql); } } supportsTransactions(): boolean { return true; }} // Stateful strategy - needs lifecycle managementclass SchemaComparisonMigrationStrategy implements DatabaseMigrationStrategy { private schemaCache: Map<string, TableSchema> = new Map(); private initialized = false; // Expensive initialization - load and parse all schema definitions async initialize(): Promise<void> { if (this.initialized) return; const schemaFiles = await glob('schemas/**/*.schema.ts'); for (const file of schemaFiles) { const schema = await this.parseSchemaFile(file); this.schemaCache.set(schema.tableName, schema); } this.initialized = true; } // Clean up resources async dispose(): Promise<void> { this.schemaCache.clear(); this.initialized = false; } async migrate(connection: DatabaseConnection): Promise<MigrationResult> { if (!this.initialized) { throw new Error('Strategy not initialized. Call initialize() first.'); } const changes: MigrationChange[] = []; for (const [tableName, targetSchema] of this.schemaCache) { const currentSchema = await this.getCurrentSchema(connection, tableName); const diff = this.compareSchemas(currentSchema, targetSchema); if (diff.hasChanges) { const sql = this.generateMigrationSql(diff); await connection.execute(sql); changes.push(diff); } } return { migrationsApplied: changes.length, changes }; } async rollback(connection: DatabaseConnection): Promise<void> { // Schema comparison doesn't support rollback easily throw new Error('Rollback not supported for schema comparison strategy'); } supportsTransactions(): boolean { return true; }} // LIFECYCLE-AWARE SERVICEclass MigrationService { private readonly strategyFactory: MigrationStrategyFactory; private currentStrategy: DatabaseMigrationStrategy | null = null; constructor(strategyFactory: MigrationStrategyFactory) { this.strategyFactory = strategyFactory; } async setStrategy(strategyType: string): Promise<void> { // Dispose of old strategy if it has cleanup if (this.currentStrategy?.dispose) { await this.currentStrategy.dispose(); } // Create and initialize new strategy this.currentStrategy = this.strategyFactory.create(strategyType); if (this.currentStrategy.initialize) { await this.currentStrategy.initialize(); } } async runMigrations(connection: DatabaseConnection): Promise<MigrationResult> { if (!this.currentStrategy) { throw new Error('No migration strategy configured'); } const useTransaction = this.currentStrategy.supportsTransactions(); if (useTransaction) { return this.runInTransaction(connection, async () => { return this.currentStrategy!.migrate(connection); }); } return this.currentStrategy.migrate(connection); } // Graceful shutdown async shutdown(): Promise<void> { if (this.currentStrategy?.dispose) { await this.currentStrategy.dispose(); } this.currentStrategy = null; }} // POOLED STRATEGY PATTERN for expensive resourcesclass ConnectionPoolStrategy implements QueryStrategy { private readonly pool: ConnectionPool; constructor(poolConfig: PoolConfig) { this.pool = new ConnectionPool(poolConfig); } async executeQuery<T>(query: Query): Promise<T[]> { // Borrow connection from pool const connection = await this.pool.acquire(); try { return await connection.execute<T>(query); } finally { // Always return to pool await this.pool.release(connection); } } async initialize(): Promise<void> { await this.pool.initialize(); } async dispose(): Promise<void> { await this.pool.drain(); await this.pool.clear(); }} // DI CONTAINER LIFECYCLE REGISTRATIONfunction configureStrategyLifecycles(container: DependencyContainer) { // Singleton - one instance for app lifetime container.register(SqlMigrationStrategy, { useClass: SqlMigrationStrategy, lifecycle: Lifecycle.Singleton }); // Scoped - one instance per request container.register(RequestScopedStrategy, { useClass: RequestScopedStrategy, lifecycle: Lifecycle.Scoped }); // Transient - new instance each time container.register(TransientStrategy, { useClass: TransientStrategy, lifecycle: Lifecycle.Transient }); // Custom factory with async initialization container.register(SchemaComparisonMigrationStrategy, { useFactory: async () => { const strategy = new SchemaComparisonMigrationStrategy(); await strategy.initialize(); return strategy; }, lifecycle: Lifecycle.Singleton, dispose: (strategy) => strategy.dispose() });}If a strategy holds resources (connections, file handles, timers), it must be properly disposed. Use try-finally, using statements (C#), or container-managed disposal. Resource leaks from undisposed strategies cause production incidents.
We've explored the full spectrum of techniques for injecting behavior variations through the Strategy Pattern. These mechanics transform OCP from theory into practice.
Let's consolidate the key insights:
What's Next:
We've learned how to inject strategies. Next, we'll explore the true test of OCP: adding new strategies without modifying existing code. We'll trace through complete examples of extending systems purely through addition, proving that our architecture truly achieves the Open/Closed Principle.
You now have practical mastery of strategy injection techniques. These skills enable you to build flexible, extensible systems that can adapt to changing requirements without destabilizing existing functionality. Next, we'll prove OCP through pure extension.