Loading content...
Software systems exist in a world of constant change. Requirements evolve, technologies shift, business priorities pivot, and user needs transform over time. The question isn't whether your system will need to change—it's how gracefully it will accommodate that change.
Composition provides a design approach that treats change as a first-class concern. Rather than fighting against modification, composition-based systems are structured to embrace it. This page explores the specific flexibility benefits that make composition invaluable for building maintainable, evolvable software.
This page examines how composition enables: open-closed compliance without modification, strategy injection for behavior variation, hot-swapping implementations, plugin architectures, feature flags and A/B testing, gradual migration paths, and design for the unknown. Each flexibility pattern is illustrated with real-world scenarios.
The Open-Closed Principle (OCP), one of the SOLID principles, states that software entities should be:
This seems paradoxical—how can you add behavior without modifying code? The answer is composition with abstraction.
Why Inheritance Struggles with OCP
Inheritance-based extension technically adds new behavior through subclasses. However:
How Composition Naturally Achieves OCP
With composition, new behavior is added by:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// Open-Closed Principle via Composition // The abstraction: a contract for discount strategiespublic interface DiscountStrategy { Money calculateDiscount(Order order); String getDescription();} // Existing discount strategies (closed - we never modify these)public class PercentageDiscount implements DiscountStrategy { private final double percentage; public PercentageDiscount(double percentage) { this.percentage = percentage; } @Override public Money calculateDiscount(Order order) { return order.getTotal().multiplyBy(percentage / 100); } @Override public String getDescription() { return percentage + "% off"; }} public class FixedAmountDiscount implements DiscountStrategy { private final Money amount; @Override public Money calculateDiscount(Order order) { return amount.min(order.getTotal()); // Can't exceed order total }} // The order service uses composition (closed for modification)public class OrderService { // NOT a fixed set of discounts - any DiscountStrategy works public Money applyDiscount(Order order, DiscountStrategy discount) { Money discountAmount = discount.calculateDiscount(order); return order.getTotal().subtract(discountAmount); }} // NEW REQUIREMENT: Add a "buy 2 get 1 free" discount// With OCP compliance, we add NEW code, modify NOTHING: public class BuyTwoGetOneFreeDiscount implements DiscountStrategy { private final ProductCategory category; public BuyTwoGetOneFreeDiscount(ProductCategory category) { this.category = category; } @Override public Money calculateDiscount(Order order) { List<OrderItem> eligibleItems = order.getItems().stream() .filter(item -> item.getCategory().equals(category)) .sorted(Comparator.comparing(OrderItem::getPrice)) .collect(toList()); // Every third item is free Money discount = Money.ZERO; for (int i = 2; i < eligibleItems.size(); i += 3) { discount = discount.add(eligibleItems.get(i).getPrice()); } return discount; }} // Usage - OrderService is completely unchanged!OrderService orderService = new OrderService();Order order = new Order(items); // Use the new discount without modifying any existing codeDiscountStrategy discount = new BuyTwoGetOneFreeDiscount(ELECTRONICS);Money finalPrice = orderService.applyDiscount(order, discount);Design your system with explicit extension points—interfaces where new behavior can be plugged in. Each extension point is a place where the system is "open" for extension while remaining "closed" to modification. Composition makes extension points natural.
The Strategy Pattern is composition's answer to varying behavior. Instead of encoding different behaviors in subclasses, you inject the behavior as a collaborating object.
The Power of Injected Strategies
When behavior is injected rather than inherited:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
// Strategy Injection: Comprehensive Example // Multiple strategy interfaces for different concernspublic interface CompressionStrategy { byte[] compress(byte[] data); byte[] decompress(byte[] data);} public interface EncryptionStrategy { byte[] encrypt(byte[] data, Key key); byte[] decrypt(byte[] data, Key key);} public interface TransportStrategy { void send(byte[] data, Destination destination); byte[] receive(Source source);} // The data service composes all strategiespublic class SecureDataService { private CompressionStrategy compression; private EncryptionStrategy encryption; private TransportStrategy transport; // Strategies injected via constructor public SecureDataService( CompressionStrategy compression, EncryptionStrategy encryption, TransportStrategy transport ) { this.compression = compression; this.encryption = encryption; this.transport = transport; } // Process data through the strategy pipeline public void sendSecurely(byte[] data, Destination dest, Key key) { byte[] compressed = compression.compress(data); byte[] encrypted = encryption.encrypt(compressed, key); transport.send(encrypted, dest); } // Runtime reconfiguration! public void setCompression(CompressionStrategy newCompression) { this.compression = newCompression; }} // Strategy implementationspublic class GzipCompression implements CompressionStrategy { /* ... */ }public class LZ4Compression implements CompressionStrategy { /* ... */ }public class NoCompression implements CompressionStrategy { /* ... */ } public class AES256Encryption implements EncryptionStrategy { /* ... */ }public class ChaCha20Encryption implements EncryptionStrategy { /* ... */ } public class HttpTransport implements TransportStrategy { /* ... */ }public class WebSocketTransport implements TransportStrategy { /* ... */ }public class GrpcTransport implements TransportStrategy { /* ... */ } // CONFIGURATION-DRIVEN STRATEGY SELECTIONpublic class SecureDataServiceFactory { public SecureDataService create(Configuration config) { CompressionStrategy compression = switch (config.compression()) { case "gzip" -> new GzipCompression(); case "lz4" -> new LZ4Compression(); case "none" -> new NoCompression(); default -> throw new IllegalArgumentException("Unknown: " + config.compression()); }; EncryptionStrategy encryption = switch (config.encryption()) { case "aes256" -> new AES256Encryption(); case "chacha20" -> new ChaCha20Encryption(); default -> throw new IllegalArgumentException("Unknown: " + config.encryption()); }; TransportStrategy transport = switch (config.transport()) { case "http" -> new HttpTransport(); case "websocket" -> new WebSocketTransport(); case "grpc" -> new GrpcTransport(); default -> throw new IllegalArgumentException("Unknown: " + config.transport()); }; return new SecureDataService(compression, encryption, transport); }} // Different configurations create different behaviors// WITHOUT changing any SecureDataService code!Strategies Enable Context-Sensitive Behavior
Strategies allow the same service to behave differently based on context:
123456789101112131415161718192021222324252627282930313233343536373839404142
// Context-Sensitive Strategy Selection public class AdaptiveSecureDataService { private final Map<ClientType, SecureDataService> servicesByClientType; public AdaptiveSecureDataService() { // Mobile clients: prioritize compression, lighter encryption SecureDataService mobileService = new SecureDataService( new LZ4Compression(), // Fast compression new ChaCha20Encryption(), // Efficient on mobile CPUs new HttpTransport() ); // Server-to-server: prioritize speed, maximum security SecureDataService serverService = new SecureDataService( new NoCompression(), // Servers have bandwidth new AES256Encryption(), // Maximum security new GrpcTransport() // Low latency ); // Browser clients: standard approach SecureDataService browserService = new SecureDataService( new GzipCompression(), // Broadly supported new AES256Encryption(), new WebSocketTransport() // Real-time capable ); servicesByClientType = Map.of( ClientType.MOBILE, mobileService, ClientType.SERVER, serverService, ClientType.BROWSER, browserService ); } public void sendSecurely(byte[] data, Destination dest, Key key, ClientType clientType) { SecureDataService service = servicesByClientType.get(clientType); service.sendSecurely(data, dest, key); }} // ONE service class, INFINITE behavioral variations// All without a single if-statement about client types in the core logicWith inheritance, each behavioral variation requires a subclass. With strategies, variations are combinations of injected components. For N compression options × M encryption options × P transport options, inheritance needs N×M×P subclasses. Strategies need N + M + P components, configured in any combination.
One of composition's most powerful capabilities is hot-swapping: replacing component implementations at runtime without restarting the application. This enables:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// Hot-Swapping: Runtime Component Replacement // Thread-safe component holder for hot-swappingpublic class HotSwappableComponent<T> { private volatile T component; public HotSwappableComponent(T initial) { this.component = initial; } public T get() { return component; } public void swap(T newComponent) { this.component = newComponent; // Atomic reference swap }} // Cache service with hot-swappable backendpublic class CacheService { private final HotSwappableComponent<CacheBackend> backend; public CacheService(CacheBackend initialBackend) { this.backend = new HotSwappableComponent<>(initialBackend); } public Optional<Object> get(String key) { return backend.get().get(key); } public void put(String key, Object value, Duration ttl) { backend.get().put(key, value, ttl); } // Hot-swap capability exposed public void switchBackend(CacheBackend newBackend) { CacheBackend old = backend.get(); backend.swap(newBackend); old.close(); // Cleanup old backend }} // Different cache backendspublic class RedisCache implements CacheBackend { /* ... */ }public class MemcachedCache implements CacheBackend { /* ... */ }public class LocalCache implements CacheBackend { /* ... */ } // PRODUCTION SCENARIO: Redis is failing, switch to local cache @RestControllerpublic class AdminController { private final CacheService cacheService; @PostMapping("/admin/cache/switch-to-local") public ResponseEntity<String> emergencySwitchToLocal() { // Hot-swap without restart! cacheService.switchBackend(new LocalCache()); alerting.notify("Cache switched to local mode due to Redis issues"); return ResponseEntity.ok("Switched to local cache"); } @PostMapping("/admin/cache/restore-redis") public ResponseEntity<String> restoreRedis() { CacheBackend newRedis = new RedisCache(redisConfig); if (newRedis.healthCheck()) { cacheService.switchBackend(newRedis); return ResponseEntity.ok("Redis restored"); } return ResponseEntity.status(503).body("Redis still unhealthy"); }}Circuit Breaker Pattern: Automatic Hot-Swapping
The Circuit Breaker pattern uses hot-swapping to automatically switch between primary and fallback implementations based on health:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// Circuit Breaker: Automatic Hot-Swapping Based on Health public class CircuitBreaker<T> { private final T primary; private final T fallback; private enum State { CLOSED, OPEN, HALF_OPEN } private volatile State state = State.CLOSED; private final int failureThreshold; private final Duration resetTimeout; private final AtomicInteger failureCount = new AtomicInteger(0); private volatile Instant lastFailure; public CircuitBreaker(T primary, T fallback, int failureThreshold, Duration resetTimeout) { this.primary = primary; this.fallback = fallback; this.failureThreshold = failureThreshold; this.resetTimeout = resetTimeout; } public T getActiveComponent() { return switch (state) { case CLOSED -> primary; case OPEN -> { // Check if we should try half-open if (Duration.between(lastFailure, Instant.now()).compareTo(resetTimeout) > 0) { state = State.HALF_OPEN; yield primary; // Try primary again } yield fallback; } case HALF_OPEN -> primary; // Testing primary }; } public void recordSuccess() { if (state == State.HALF_OPEN) { state = State.CLOSED; // Primary recovered! failureCount.set(0); } } public void recordFailure() { lastFailure = Instant.now(); if (state == State.HALF_OPEN) { state = State.OPEN; // Primary still failing } else if (failureCount.incrementAndGet() >= failureThreshold) { state = State.OPEN; // Trip the circuit } }} // Usage: Automatic failover between payment processorspublic class ResilientPaymentService { private final CircuitBreaker<PaymentProcessor> circuitBreaker; public ResilientPaymentService() { this.circuitBreaker = new CircuitBreaker<>( new StripeProcessor(), // Primary new PayPalProcessor(), // Fallback 5, // 5 failures to trip Duration.ofMinutes(1) // Try again after 1 minute ); } public PaymentResult process(Payment payment) { PaymentProcessor processor = circuitBreaker.getActiveComponent(); try { PaymentResult result = processor.process(payment); circuitBreaker.recordSuccess(); return result; } catch (ProcessorException e) { circuitBreaker.recordFailure(); // Retry with potentially different (fallback) processor return circuitBreaker.getActiveComponent().process(payment); } }}For safe hot-swapping: (1) Components must be stateless or handle state transfer, (2) References must be through interfaces, not concrete types, (3) Swap operations must be atomic (volatile, AtomicReference, or synchronized), (4) Old components must be properly cleaned up.
Composition enables plugin architectures where third parties can extend your system without modifying your code. This is the ultimate expression of the Open-Closed Principle.
Plugin Architecture Characteristics:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
// Plugin Architecture: Extensible by Third Parties // The plugin contract (extension point)public interface AnalyticsPlugin { String getName(); void initialize(Configuration config); void trackEvent(AnalyticsEvent event); void shutdown();} // Plugin registry and lifecycle managerpublic class PluginManager { private final List<AnalyticsPlugin> plugins = new CopyOnWriteArrayList<>(); // Discover and load plugins (e.g., via ServiceLoader, classpath scanning) public void loadPlugins() { ServiceLoader<AnalyticsPlugin> loader = ServiceLoader.load(AnalyticsPlugin.class); for (AnalyticsPlugin plugin : loader) { try { plugin.initialize(configuration); plugins.add(plugin); logger.info("Loaded plugin: " + plugin.getName()); } catch (Exception e) { logger.error("Failed to load plugin: " + plugin.getName(), e); } } } // Dispatch events to all plugins public void trackEvent(AnalyticsEvent event) { for (AnalyticsPlugin plugin : plugins) { try { plugin.trackEvent(event); } catch (Exception e) { logger.warn("Plugin " + plugin.getName() + " failed to track event", e); // Other plugins continue working } } } // Dynamic plugin registration (hot loading) public void registerPlugin(AnalyticsPlugin plugin) { plugin.initialize(configuration); plugins.add(plugin); } public void unregisterPlugin(String pluginName) { plugins.removeIf(p -> { if (p.getName().equals(pluginName)) { p.shutdown(); return true; } return false; }); }} // PLUGIN IMPLEMENTATIONS (can be in separate JARs, by third parties) // Google Analytics pluginpublic class GoogleAnalyticsPlugin implements AnalyticsPlugin { private GoogleAnalyticsClient client; @Override public String getName() { return "google-analytics"; } @Override public void initialize(Configuration config) { String trackingId = config.get("google.tracking.id"); this.client = new GoogleAnalyticsClient(trackingId); } @Override public void trackEvent(AnalyticsEvent event) { client.send(convertToGAEvent(event)); }} // Mixpanel pluginpublic class MixpanelPlugin implements AnalyticsPlugin { @Override public String getName() { return "mixpanel"; } @Override public void trackEvent(AnalyticsEvent event) { mixpanelClient.track(event.name(), event.properties()); }} // Custom internal analytics pluginpublic class InternalMetricsPlugin implements AnalyticsPlugin { @Override public String getName() { return "internal-metrics"; } @Override public void trackEvent(AnalyticsEvent event) { metricsRegistry.counter(event.name()).increment(); }} // THE CORE APPLICATION NEVER CHANGES// Third parties add new plugins by:// 1. Implementing AnalyticsPlugin interface// 2. Packaging as a JAR with ServiceLoader registration// 3. Dropping the JAR in the plugins directoryWith inheritance, third parties would need to subclass your classes—giving them access to internals they shouldn't have, and coupling them to your implementation. With composition-based plugins, third parties implement a clean interface and have no access to your internal code.
Modern product development relies heavily on feature flags (enabling/disabling features for subsets of users) and A/B testing (comparing different implementations to measure impact). Both are enabled by composition.
The Core Insight:
When behavior is composed rather than inherited, you can swap between implementations based on:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// Feature Flags and A/B Testing via Composition public interface SearchAlgorithm { SearchResults search(String query, SearchContext context);} // Current production implementationpublic class LuceneSearch implements SearchAlgorithm { @Override public SearchResults search(String query, SearchContext context) { // Existing Lucene-based search }} // New experimental implementationpublic class VectorSearch implements SearchAlgorithm { @Override public SearchResults search(String query, SearchContext context) { // New vector/embedding-based search }} // Feature-flag-aware search servicepublic class SearchService { private final SearchAlgorithm legacySearch; private final SearchAlgorithm experimentalSearch; private final FeatureFlagService featureFlags; private final MetricsService metrics; public SearchResults search(String query, User user) { SearchAlgorithm algorithm = selectAlgorithm(user); long start = System.nanoTime(); SearchResults results = algorithm.search(query, getContext(user)); long duration = System.nanoTime() - start; // Track metrics for comparison metrics.recordSearch( algorithm.getClass().getSimpleName(), duration, results.size(), user.getId() ); return results; } private SearchAlgorithm selectAlgorithm(User user) { // Feature flag: is user in the experiment? if (featureFlags.isEnabled("vector-search-experiment", user)) { return experimentalSearch; } return legacySearch; }} // A/B Testing configurationpublic class ABTestingConfig { // 10% of users get the new search public static final ABTest VECTOR_SEARCH_TEST = ABTest.builder() .name("vector-search-experiment") .controlPercentage(90) // 90% get legacy search .variantPercentage(10) // 10% get vector search .metrics(List.of("search_latency", "click_through_rate", "result_count")) .build();} // Gradual rollout: increase percentage over timepublic class GradualRolloutService { public void increaseRollout(String featureName, int newPercentage) { // Week 1: 1% // Week 2: 5% // Week 3: 25% // Week 4: 50% // Week 5: 100% (full rollout) featureFlagService.setRolloutPercentage(featureName, newPercentage); }}The Composition Advantage for Experimentation:
After an A/B test concludes, the winning implementation becomes the only implementation. With composition, this cleanup is trivial: delete the losing class, remove the feature flag check, and inject the winner directly. With inheritance, refactoring the hierarchy is far more complex.
Strangler Fig Pattern:
When replacing legacy systems, composition enables the Strangler Fig pattern: gradually routing functionality to new implementations while the old system remains operational. This avoids risky "big bang" migrations.
The pattern is named after strangler fig trees that grow around host trees, eventually replacing them entirely while the host continues to function during the transition.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// Strangler Fig Pattern: Gradual Migration via Composition // The interface both old and new systems implementpublic interface OrderProcessor { OrderResult process(Order order); OrderStatus getStatus(OrderId id); void cancel(OrderId id);} // Legacy monolith order processingpublic class LegacyOrderProcessor implements OrderProcessor { private final LegacyDatabase legacyDb; private final LegacyPaymentSystem legacyPayment; // 10 years of accumulated complexity here...} // New microservice-based order processingpublic class NewOrderProcessor implements OrderProcessor { private final OrderMicroservice orderService; private final PaymentMicroservice paymentService; private final InventoryMicroservice inventoryService; // Clean, modern implementation} // The strangler: routes between old and new based on migration progresspublic class StranglerOrderProcessor implements OrderProcessor { private final OrderProcessor legacy; private final OrderProcessor modern; private final MigrationConfig migrationConfig; @Override public OrderResult process(Order order) { // Route based on order type, customer segment, etc. if (shouldUseLegacy(order)) { return legacy.process(order); } return modern.process(order); } private boolean shouldUseLegacy(Order order) { // Week 1: Only new customers use modern system if (order.getCustomer().isNewCustomer()) { return false; // Use modern } // Week 4: Orders under $100 use modern if (order.getTotal().isLessThan(Money.of(100))) { return false; } // Week 8: All domestic orders use modern if (order.getShippingAddress().isDomestic()) { return false; } // Everything else uses legacy (for now) return true; } @Override public OrderStatus getStatus(OrderId id) { // Check which system has the order if (migrationConfig.isOrderInModernSystem(id)) { return modern.getStatus(id); } return legacy.getStatus(id); }} // Migration progress over time:// Month 1: 5% of traffic on modern system// Month 2: 20% of traffic on modern system// Month 3: 50% of traffic on modern system// Month 4: 80% of traffic on modern system// Month 5: 100% on modern, legacy decommissionedThis pattern is only possible when the routing layer and both systems share a common interface. If the legacy system's API is tightly coupled (inheritance-based), you'll need an adapter or facade first. Composition from the start makes migration patterns trivial.
Perhaps composition's greatest flexibility benefit is enabling design for change even when you don't know what changes will come.
The Uncertainty Principle of Software:
We cannot predict:
Composition's Response to Uncertainty:
Instead of trying to predict specific changes (which we'll get wrong), composition creates a structure that accommodates change in general. By minimizing coupling and maximizing cohesion, composition-based systems can flex in whatever direction reality demands.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// Designing for the Unknown: Extension-Ready Architecture // Core domain service with explicit extension pointspublic class NotificationService { private final List<NotificationChannel> channels; private final List<NotificationEnricher> enrichers; private final List<NotificationFilter> filters; private final NotificationPersistence persistence; // Extension points are INTERFACES, not base classes // We don't know what channels, enrichers, or filters we'll need // But we KNOW the types of extensions that make sense public void send(Notification notification, User recipient) { // Apply filters (extension point for future rules) for (NotificationFilter filter : filters) { if (!filter.shouldSend(notification, recipient)) { return; // Filtered out } } // Enrich notification (extension point for future enrichments) Notification enriched = notification; for (NotificationEnricher enricher : enrichers) { enriched = enricher.enrich(enriched, recipient); } // Send through all appropriate channels (extension point for new channels) UserPreferences prefs = recipient.getNotificationPreferences(); for (NotificationChannel channel : channels) { if (channel.supports(enriched.getType()) && prefs.allows(channel)) { channel.send(enriched, recipient); } } // Persist (extension point for different storage needs) persistence.save(enriched, recipient); }} // YEAR 1: Email and SMS channels, simple persistencechannels = List.of(new EmailChannel(), new SmsChannel());persistence = new PostgresPersistence(); // YEAR 2: Added push notifications, Slack (no service changes!)channels = List.of( new EmailChannel(), new SmsChannel(), new PushNotificationChannel(), // NEW new SlackChannel() // NEW); // YEAR 3: GDPR requires filtering and audit trail (no service changes!)filters = List.of( new GdprConsentFilter(), // NEW - block non-consented new QuietHoursFilter(), // NEW - respect quiet hours new RateLimitFilter() // NEW - prevent spam);persistence = new AuditingPersistence( // NEW - compliance wrapper new PostgresPersistence()); // YEAR 4: Personalization enrichment (no service changes!)enrichers = List.of( new LocalizationEnricher(), // Translate to user's language new PersonalizationEnricher(), // ML-powered personalization new A11yEnricher() // Accessibility enhancements); // The NotificationService code hasn't changed in 4 years// Yet the system has grown from 2 to 6 channels// Added 3 filtering rules// Gained enrichment capabilities// Met compliance requirements// ALL through compositionWhen designing a service, ask: "What kinds of variations might we need?" You don't need to predict specific variations—just categories. Notification delivery? That could vary by channel (email, SMS, push). Processing rules? Those could vary by filters, enrichers, validators. Create interfaces for each category, even if you only have one implementation today.
The flexibility benefits of composition aren't abstract—they translate directly to business value:
| Technical Benefit | Business Value |
|---|---|
| Open-Closed compliance | New features without regression risk |
| Strategy injection | Behavior-as-configuration enables customer customization |
| Hot-swapping | Zero-downtime operations, graceful degradation |
| Plugin architecture | Third-party ecosystem, reduced development burden |
| Feature flags / A/B testing | Data-driven product decisions, faster iteration |
| Gradual migration | Modernization without business disruption |
| Design for unknown | Adaptability as requirements evolve |
What's Next:
The next page addresses the critical question: When does this principle NOT apply? We'll explore scenarios where inheritance is the better choice, avoiding the dogmatic over-application of composition.
You now understand how composition enables the flexibility patterns that modern software systems require. These patterns—from feature flags to plugin architectures to gradual migrations—are the building blocks of adaptable, maintainable systems.