Loading learning content...
Real-world software systems rarely have problems that fit neatly into a single pattern's domain. A complex subsystem might need interface adaptation, access control, and memory optimization simultaneously. A plugin architecture might require composition, decoration, and factory coordination. Production-grade design often involves multiple patterns working in concert.
But pattern combination is not arbitrary assembly. Some patterns naturally complement each other; others create friction. Some combinations produce elegant solutions; others create unmaintainable tangles. The difference between a well-composed pattern ensemble and a pattern disaster lies in understanding why patterns combine well and how to structure their interactions.
This page develops that understanding. You'll learn which patterns have natural affinity, how to layer patterns without creating complexity monsters, and—critically—when combining patterns is a sign you're over-engineering.
By the end of this page, you will understand pattern synergies—which structural patterns combine naturally, how to compose them, and warning signs of harmful over-combination. You'll develop the judgment to know when multi-pattern solutions are necessary vs. when simpler designs suffice.
Before examining specific combinations, we need principles that guide when and how to combine patterns.
Principle 1: Each Pattern Should Address a Distinct Concern
If two patterns address the same concern, you're likely applying one incorrectly. Valid combination means each pattern solves a different aspect of the overall problem. For example:
Principle 2: Patterns Should Compose at Clear Boundaries
Patterns interact through well-defined interfaces, not through internal coupling. If pattern A needs to know implementation details of pattern B to work, the combination is fragile.
Principle 3: Complexity Must Be Justified
Every pattern adds cognitive load. Combining three patterns multiplies that load. The combination must solve a problem that justifies its complexity. Ask: 'Would a simpler solution with one pattern or no pattern work nearly as well?'
Principle 4: Prefer Composition to Inheritance for Pattern Assembly
When combining patterns, use composed objects (dependencies) rather than inheritance hierarchies. This maintains pattern independence and enables runtime flexibility.
Inexperienced developers sometimes stack patterns like layers in a tower: Adapter wrapping Decorator wrapping Proxy wrapping Facade. This creates debugging nightmares, performance issues, and maintenance hell. Patterns should be composed beside each other (addressing orthogonal concerns), not arbitrarily on top of each other.
Some pattern pairs have inherent synergy—they address complementary concerns and compose cleanly. Understanding these natural affinities accelerates design.
| Pattern Pair | Why They Combine Well | Common Use Case |
|---|---|---|
| Composite + Decorator | Both use recursive wrapper structure; Decorator adds behavior to Composite nodes | Rich UI with behavior-enhanced containers |
| Adapter + Facade | Facade simplifies subsystem; Adapters translate individual components | Legacy system integration layer |
| Proxy + Decorator | Proxy controls access; Decorator adds behavior—orthogonal concerns | Cached, logged, rate-limited API access |
| Flyweight + Composite | Composite structures can share flyweight leaf nodes | Document rendering (shared character formats in text tree) |
| Bridge + Adapter | Bridge separates abstraction/implementation; Adapter integrates external implementations | Platform-independent library with platform adapters |
| Facade + Proxy | Facade simplifies; Proxy adds lazy loading, caching, or security to facade | Service layer with performance optimization |
Why These Pairs Work:
The key to natural affinity is orthogonal concerns. Each pattern in the pair addresses a different dimension of the problem:
When concerns are orthogonal, patterns don't compete—they collaborate.
Some pattern combinations create friction or confusion. These aren't necessarily wrong, but they require careful justification and implementation.
| Pattern Pair | Potential Issue | When It's Actually Okay |
|---|---|---|
| Decorator + Decorator (deep stacking) | 5+ decorators make debugging nearly impossible; call stack depth explodes | 2-3 levels with clear purpose for each layer |
| Adapter + Adapter (chained) | Translating between 3+ interfaces suggests deeper design issues | When bridging genuinely independent systems |
| Facade + Facade (nested) | Facade over facade suggests the underlying subsystem is too complex | Rare; usually indicates need for subsystem refactoring |
| Bridge + Decorator | Both involve wrapper-like structures; roles can become confused | Clear separation: Bridge for variation, Decorator for dynamic behavior |
| Flyweight + Proxy | Proxy adds per-instance overhead, defeating Flyweight's memory savings | Only if proxy is also shared/pooled |
If you're combining more than three patterns in a single subsystem, pause and reflect. Either you have a genuinely complex problem that warrants the complexity, or you're over-engineering. Have a colleague review the design—fresh eyes often spot unnecessary pattern usage.
Let's examine a realistic multi-pattern composition: an extensible plugin system for an image processing application.
Requirements:
Patterns Identified:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
// ============================================// Core Interface (all plugins implement this)// ============================================interface ImagePlugin { readonly name: string; process(image: Image): Image;} // ============================================// ADAPTER: Third-party plugin integration// ============================================// Third-party library with different interfaceinterface ThirdPartyFilter { applyEffect(pixels: Uint8Array, width: number, height: number): Uint8Array;} class ThirdPartyPluginAdapter implements ImagePlugin { constructor( public readonly name: string, private readonly filter: ThirdPartyFilter ) {} process(image: Image): Image { // Translate between our Image type and their pixel array const pixels = image.toPixelArray(); const result = this.filter.applyEffect(pixels, image.width, image.height); return Image.fromPixelArray(result, image.width, image.height); }} // ============================================// PROXY: Lazy loading of heavy plugins// ============================================type PluginLoader = () => Promise<ImagePlugin>; class LazyPluginProxy implements ImagePlugin { private loadedPlugin: ImagePlugin | null = null; private loading: Promise<ImagePlugin> | null = null; constructor( public readonly name: string, private readonly loader: PluginLoader ) {} async process(image: Image): Image { if (!this.loadedPlugin) { // Ensure single loading even with concurrent calls if (!this.loading) { this.loading = this.loader(); } this.loadedPlugin = await this.loading; } return this.loadedPlugin.process(image); }} // ============================================// COMPOSITE: Pipeline composition// ============================================class PluginPipeline implements ImagePlugin { private plugins: ImagePlugin[] = []; constructor(public readonly name: string) {} add(plugin: ImagePlugin): this { this.plugins.push(plugin); return this; } process(image: Image): Image { return this.plugins.reduce( (img, plugin) => plugin.process(img), image ); }} // ============================================// FLYWEIGHT: Shared resources// ============================================class FilterEffect { // Intrinsic state: expensive to create, shared across plugins constructor( public readonly type: string, private readonly kernel: Float32Array, private readonly lookupTable: Uint8Array ) {} apply(image: Image): Image { // Apply convolution with kernel and lookup table return image; // simplified }} class FilterEffectFactory { private effects = new Map<string, FilterEffect>(); getEffect(type: string): FilterEffect { if (!this.effects.has(type)) { // Expensive creation happens once const kernel = this.createKernel(type); const lut = this.createLookupTable(type); this.effects.set(type, new FilterEffect(type, kernel, lut)); } return this.effects.get(type)!; } private createKernel(type: string): Float32Array { /* ... */ return new Float32Array(0); } private createLookupTable(type: string): Uint8Array { /* ... */ return new Uint8Array(0); }} // Plugin using shared flyweightsclass ConvolutionPlugin implements ImagePlugin { private effect: FilterEffect; constructor( public readonly name: string, effectType: string, factory: FilterEffectFactory ) { // Multiple ConvolutionPlugins can share the same FilterEffect this.effect = factory.getEffect(effectType); } process(image: Image): Image { return this.effect.apply(image); }} // ============================================// Composition: All patterns working together// ============================================async function createImageProcessor() { const effectFactory = new FilterEffectFactory(); // Native plugins use flyweights const blur = new ConvolutionPlugin("Gaussian Blur", "gaussian", effectFactory); const sharpen = new ConvolutionPlugin("Sharpen", "sharpen", effectFactory); // Third-party plugin wrapped with adapter const vintageFilter = new ThirdPartyPluginAdapter( "Vintage Effect", new SomeThirdPartyLibrary.VintageFilter() ); // Heavy AI plugin loaded lazily via proxy const aiEnhance = new LazyPluginProxy( "AI Enhancement", async () => { const module = await import('./heavy-ai-plugin'); return new module.AIEnhancePlugin(); } ); // Compose into pipelines using composite const quickFix = new PluginPipeline("Quick Fix") .add(blur) .add(sharpen); const fullEnhance = new PluginPipeline("Full Enhancement") .add(quickFix) // Pipeline can contain pipelines .add(vintageFilter) .add(aiEnhance); // Lazy loaded only when pipeline runs return fullEnhance;}Pattern Interaction Analysis:
Notice how each pattern operates in its own domain:
They don't interfere because they address orthogonal concerns. A lazy-loaded plugin (Proxy) can be a third-party plugin (Adapter) that uses shared effects (Flyweight) and participates in pipelines (Composite).
Let's examine another pattern ensemble: a data access layer with security, caching, and logging concerns.
Requirements:
Patterns Identified:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
// ============================================// BRIDGE: Abstraction + Implementation separation// ============================================// Implementation interface (different databases)interface DatabaseDriver { connect(): Promise<void>; query<T>(sql: string, params: unknown[]): Promise<T[]>; execute(sql: string, params: unknown[]): Promise<number>; close(): Promise<void>;} class PostgresDriver implements DatabaseDriver { async connect(): Promise<void> { /* postgres-specific */ } async query<T>(sql: string, params: unknown[]): Promise<T[]> { /* ... */ return []; } async execute(sql: string, params: unknown[]): Promise<number> { /* ... */ return 0; } async close(): Promise<void> { /* ... */ }} class MySQLDriver implements DatabaseDriver { async connect(): Promise<void> { /* mysql-specific */ } async query<T>(sql: string, params: unknown[]): Promise<T[]> { /* ... */ return []; } async execute(sql: string, params: unknown[]): Promise<number> { /* ... */ return 0; } async close(): Promise<void> { /* ... */ }} // Abstraction uses driver via composition (Bridge)abstract class Repository<T> { constructor(protected driver: DatabaseDriver) {} abstract findById(id: string): Promise<T | null>; abstract findAll(): Promise<T[]>; abstract save(entity: T): Promise<void>; abstract delete(id: string): Promise<void>;} class UserRepository extends Repository<User> { async findById(id: string): Promise<User | null> { const results = await this.driver.query<User>( 'SELECT * FROM users WHERE id = $1', [id] ); return results[0] || null; } async findAll(): Promise<User[]> { return this.driver.query<User>('SELECT * FROM users', []); } async save(user: User): Promise<void> { await this.driver.execute( 'INSERT INTO users (id, name, email) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET name = $2, email = $3', [user.id, user.name, user.email] ); } async delete(id: string): Promise<void> { await this.driver.execute('DELETE FROM users WHERE id = $1', [id]); }} // ============================================// PROXY: Caching (performance) + Security (access control)// ============================================// Interface for all repositoriesinterface IRepository<T> { findById(id: string): Promise<T | null>; findAll(): Promise<T[]>; save(entity: T): Promise<void>; delete(id: string): Promise<void>;} // Caching proxyclass CachingRepositoryProxy<T extends { id: string }> implements IRepository<T> { private cache = new Map<string, { data: T; expires: number }>(); private cacheTTL = 60000; // 1 minute constructor(private repository: IRepository<T>) {} async findById(id: string): Promise<T | null> { const cached = this.cache.get(id); if (cached && cached.expires > Date.now()) { return cached.data; } const result = await this.repository.findById(id); if (result) { this.cache.set(id, { data: result, expires: Date.now() + this.cacheTTL }); } return result; } async findAll(): Promise<T[]> { // Don't cache findAll to avoid stale data return this.repository.findAll(); } async save(entity: T): Promise<void> { await this.repository.save(entity); // Invalidate cache on write this.cache.delete(entity.id); } async delete(id: string): Promise<void> { await this.repository.delete(id); this.cache.delete(id); }} // Security proxy (access control)class SecureRepositoryProxy<T> implements IRepository<T> { constructor( private repository: IRepository<T>, private requiredRole: string, private securityContext: () => SecurityContext ) {} private checkPermission(action: string): void { const ctx = this.securityContext(); if (!ctx.hasRole(this.requiredRole)) { throw new AccessDeniedError( `User ${ctx.userId} lacks role ${this.requiredRole} for ${action}` ); } } async findById(id: string): Promise<T | null> { this.checkPermission('read'); return this.repository.findById(id); } async findAll(): Promise<T[]> { this.checkPermission('read'); return this.repository.findAll(); } async save(entity: T): Promise<void> { this.checkPermission('write'); return this.repository.save(entity); } async delete(id: string): Promise<void> { this.checkPermission('delete'); return this.repository.delete(id); }} // ============================================// DECORATOR: Logging as stackable behavior// ============================================class LoggingRepositoryDecorator<T> implements IRepository<T> { constructor( private repository: IRepository<T>, private logger: Logger, private entityName: string ) {} async findById(id: string): Promise<T | null> { this.logger.info(`[${this.entityName}] Finding by id: ${id}`); const result = await this.repository.findById(id); this.logger.debug(`[${this.entityName}] Found: ${result ? 'yes' : 'no'}`); return result; } async findAll(): Promise<T[]> { this.logger.info(`[${this.entityName}] Finding all`); const results = await this.repository.findAll(); this.logger.debug(`[${this.entityName}] Found ${results.length} records`); return results; } async save(entity: T): Promise<void> { this.logger.info(`[${this.entityName}] Saving entity`); await this.repository.save(entity); this.logger.info(`[${this.entityName}] Saved successfully`); } async delete(id: string): Promise<void> { this.logger.warn(`[${this.entityName}] Deleting: ${id}`); await this.repository.delete(id); this.logger.info(`[${this.entityName}] Deleted successfully`); }} // ============================================// FACADE: Unified access point// ============================================class DataAccessFacade { readonly users: IRepository<User>; readonly products: IRepository<Product>; readonly orders: IRepository<Order>; constructor( driver: DatabaseDriver, logger: Logger, securityContext: () => SecurityContext ) { // Compose patterns for user repository let userRepo: IRepository<User> = new UserRepository(driver); userRepo = new CachingRepositoryProxy(userRepo); userRepo = new SecureRepositoryProxy(userRepo, 'user:read', securityContext); userRepo = new LoggingRepositoryDecorator(userRepo, logger, 'User'); this.users = userRepo; // Similar composition for other repositories // Different policies per entity type let productRepo: IRepository<Product> = new ProductRepository(driver); productRepo = new CachingRepositoryProxy(productRepo); // Cache products longer // Products are public, no security proxy productRepo = new LoggingRepositoryDecorator(productRepo, logger, 'Product'); this.products = productRepo; let orderRepo: IRepository<Order> = new OrderRepository(driver); // No caching for orders (consistency critical) orderRepo = new SecureRepositoryProxy(orderRepo, 'order:admin', securityContext); orderRepo = new LoggingRepositoryDecorator(orderRepo, logger, 'Order'); this.orders = orderRepo; }} // ============================================// Usage: Clean, simple client code// ============================================const facade = new DataAccessFacade( new PostgresDriver(), // Bridge: can swap to MySQLDriver logger, () => getCurrentSecurityContext()); // Client code is completely decoupled from complexityconst user = await facade.users.findById('123');const products = await facade.products.findAll();When composing multiple patterns, the order of composition matters. Different orderings produce different behaviors, and some orderings are clearly superior for specific use cases.
A common mistake: Cache(Security(Repository)). This caches based on query alone, potentially returning cached data to unauthorized users. The security check happens after cache hit, but data was cached from a previous authorized request. Always: Security(Cache(Repository)).
Pattern combinations can go wrong in predictable ways. Recognizing these anti-patterns helps you avoid common traps.
Before finalizing a multi-pattern design, ask: 'Can I explain this to a new team member in 5 minutes?' If not, the design may be too complex. Great architecture is comprehensible, not just 'correct.'
Combining structural patterns effectively is an advanced skill. Let's consolidate the key principles:
What's Next:
The final page brings everything together with Real-World Case Studies. We'll examine how actual production systems combine structural patterns to solve complex problems, providing concrete examples you can learn from and adapt.
You now understand how structural patterns combine effectively—and dangerously. You can identify natural pattern affinities, order composed patterns correctly, and recognize anti-patterns before they create maintenance nightmares. This is senior-level architectural judgment.