Design Patterns with AI: How to Detect, Apply, and Avoid Overusing Them

Design patterns are proven solutions to recurring design problems. The Gang of Four catalogued 23 of them in 1994. Developers have been misapplying them ever since — using Singleton where a plain function would do, adding Factory hierarchies to code that creates exactly one type of object, and wrapping everything in an Observer when a direct method call would be simpler and faster.

AI changes the equation on both sides. It can scan existing code and identify where a pattern would genuinely reduce complexity. It can generate pattern-correct implementations in seconds. It can also hallucinate pattern opportunities that don't exist and produce over-engineered abstractions no one asked for. The skill is learning to use AI for the first two while guarding against the third. This article covers exactly that.

How to Detect Pattern Opportunities in Existing Code

The best use of AI for design patterns is not generating them from scratch — it's recognizing when existing code is already trying to be a pattern, just poorly. These are the smells that point to specific patterns:

// The pattern-detection audit prompt: "Analyze this code for design pattern opportunities. For each one found: 1. Name the code smell that signals the pattern 2. Name the pattern that resolves it 3. Explain the concrete benefit (what becomes easier to change or test) 4. Show the before and after Focus only on patterns that reduce real complexity in this specific code. Do not suggest patterns just because they are applicable in theory."

The constraint "focus only on patterns that reduce real complexity" is critical. Without it, AI will suggest patterns for every identifiable structure, producing a refactoring proposal that's more complex than the original code.

Creational Patterns

Factory Method: When Object Creation Is the Variation Point

The smell that signals a Factory Method opportunity: a class that contains a switch or if/else to decide which concrete type to instantiate, mixed with the logic that uses the instantiated object. Creation and use are tangled. Every new type requires modifying the class.

// Smell: creation logic mixed with business logic class ReportGenerator { generate(type, data) { let formatter; if (type === 'pdf') { formatter = new PdfFormatter({ margins: 20, fonts: 'serif' }); } else if (type === 'excel') { formatter = new ExcelFormatter({ sheetName: 'Report', freeze: true }); } else if (type === 'csv') { formatter = new CsvFormatter({ delimiter: ',', headers: true }); } // ... 50 more lines using formatter } } // Prompt: "Extract the object creation into a Factory Method. // The factory should be the only thing that changes when a new format is added." // Result: class ReportFormatterFactory { static create(type) { const factories = { pdf: () => new PdfFormatter({ margins: 20, fonts: 'serif' }), excel: () => new ExcelFormatter({ sheetName: 'Report', freeze: true }), csv: () => new CsvFormatter({ delimiter: ',', headers: true }), }; const factory = factories[type]; if (!factory) throw new UnsupportedReportFormatError(type); return factory(); } } class ReportGenerator { generate(type, data) { const formatter = ReportFormatterFactory.create(type); // ... business logic, unchanged regardless of format } }

Builder: When Construction Has Many Optional Steps

The smell: a constructor or factory function with five or more parameters, many of them optional, producing different configurations depending on which combination is passed. Callers must know the correct order. Null/undefined markers appear everywhere. Adding a new configuration option changes every call site.

// Smell: telescoping constructor with optional parameters const email = new Email( 'user@example.com', 'Welcome', 'Hello!', null, // cc — not used null, // bcc — not used true, // isHtml 'noreply@co.com', null, // attachments — not used 'high' // priority ); // Prompt: "Refactor this constructor using the Builder pattern. // Each optional field should be a chainable setter method. // The build() method should validate required fields before returning the object." // Result: const email = new EmailBuilder() .to('user@example.com') .subject('Welcome') .body('Hello!') .from('noreply@co.com') .htmlEnabled() .priority('high') .build(); // validates: to, subject, body are required

Singleton: The Most Misused Pattern

Singleton is the pattern AI most frequently suggests when it shouldn't. The real use cases are narrow: a single shared configuration object, a connection pool, a registry. The wrong use cases are vast: services that don't need shared state, utilities that could simply be modules, anything that makes testing harder because the instance can't be replaced.

// The Singleton prompt that avoids misuse: "Implement this as a Singleton ONLY IF it genuinely requires: 1. Exactly one instance across the entire application lifetime 2. Shared mutable state that multiple callers must see consistently 3. A resource that is expensive to create and must be reused If none of these apply, implement it as a regular class and explain why Singleton is not appropriate here." // AI with this constraint will often respond: // "This class has no shared mutable state and no expensive initialization. // A Singleton would make it untestable without adding global state. // Implement it as a regular class and inject it where needed."

Structural Patterns

Adapter: Bridging Incompatible Interfaces

The smell: your code depends on a third-party or legacy API that doesn't match the interface your system expects. Conversion logic is scattered across multiple call sites. Switching providers requires modifying business logic.

// Smell: third-party SDK leaking into business logic class OrderService { async notifyShipment(order) { // StripeShipping SDK doesn't match our interface await stripeShipping.createShipmentNotification({ customer_id: order.userId, // different naming shipment_ref: order.trackingCode, // different naming destination: { street: order.address.street, postal_code: order.address.zip, // different naming }, metadata: { internal_id: order.id }, }); } } // Prompt: "Create an Adapter that wraps the StripeShipping SDK and exposes // the interface our system already uses. The business logic should never // see SDK-specific naming or structure." interface ShipmentNotifier { notify(order: Order): Promise; } class StripeShippingAdapter implements ShipmentNotifier { async notify(order) { await stripeShipping.createShipmentNotification({ customer_id: order.userId, shipment_ref: order.trackingCode, destination: { street: order.address.street, postal_code: order.address.zip, }, metadata: { internal_id: order.id }, }); } } // Business logic now speaks our language, never the SDK's class OrderService { constructor(private shipmentNotifier: ShipmentNotifier) {} async notifyShipment(order) { await this.shipmentNotifier.notify(order); } }

Decorator: Adding Behavior Without Subclassing

The smell: cross-cutting behavior — logging, caching, retry, metrics — copy-pasted into multiple classes. Adding tracing to ten services means modifying ten classes. Decorator extracts that behavior into a wrapper that shares the same interface as the wrapped object.

// Smell: caching logic duplicated across multiple repositories class ProductRepository { async findById(id) { const cached = await redis.get(`product:${id}`); if (cached) return JSON.parse(cached); const product = await db.products.findOne({ id }); await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 300); return product; } } // Same pattern repeated in UserRepository, OrderRepository, CategoryRepository... // Prompt: "Extract the caching logic into a Decorator that wraps any repository // implementing the same interface. The decorator should be generic — it should // work with any repository without knowing its internals." class CachingRepositoryDecorator { constructor(inner, cache, keyPrefix, ttlSeconds) { this.inner = inner; this.cache = cache; this.keyPrefix = keyPrefix; this.ttlSeconds = ttlSeconds; } async findById(id) { const key = `${this.keyPrefix}:${id}`; const cached = await this.cache.get(key); if (cached) return JSON.parse(cached); const result = await this.inner.findById(id); await this.cache.set(key, JSON.stringify(result), 'EX', this.ttlSeconds); return result; } } // Usage: zero changes to ProductRepository const productRepo = new CachingRepositoryDecorator( new ProductRepository(db), redis, 'product', 300 );

Facade: Simplifying a Complex Subsystem

The smell: a common operation requires orchestrating four or five different subsystems in the right order. Every caller that needs this operation duplicates the orchestration. When the subsystem changes, all callers break.

// Smell: subsystem orchestration repeated at every call site async function handleUserRegistration(data) { const user = await authService.createUser(data.email, data.password); const profile = await profileService.createProfile(user.id, data.name); await emailService.sendWelcomeEmail(user.email); await analyticsService.trackEvent('user_registered', { userId: user.id }); await billingService.createFreeTrial(user.id, 14); return { user, profile }; } // Duplicated in admin panel, mobile API, CLI tool, test setup... // Prompt: "Create a Facade that encapsulates the user registration workflow. // The Facade should expose one method that hides all subsystem coordination. // Callers should not need to know which subsystems are involved." class UserRegistrationFacade { constructor(auth, profile, email, analytics, billing) { ... } async register(data) { const user = await this.auth.createUser(data.email, data.password); const profile = await this.profile.createProfile(user.id, data.name); await Promise.all([ this.email.sendWelcomeEmail(user.email), this.analytics.trackEvent('user_registered', { userId: user.id }), this.billing.createFreeTrial(user.id, FREE_TRIAL_DAYS), ]); return { user, profile }; } } // All call sites become one line: return await registrationFacade.register(data);

Behavioral Patterns

Strategy: Swappable Algorithms

Strategy and Factory Method are often confused. Factory decides what to create. Strategy decides how to behave. The smell: a class that changes its algorithm based on a mode or type flag passed at runtime — the algorithm is buried in a conditional, and adding a new algorithm means modifying the class.

// Smell: algorithm selection tangled with algorithm execution class PricingEngine { calculate(order, pricingMode) { if (pricingMode === 'standard') { return order.items.reduce((sum, i) => sum + i.price * i.qty, 0); } else if (pricingMode === 'wholesale') { const subtotal = order.items.reduce((sum, i) => sum + i.price * i.qty, 0); return subtotal * 0.8; // 20% discount } else if (pricingMode === 'subscription') { return order.items.reduce((sum, i) => sum + i.subscriberPrice * i.qty, 0); } } } // Prompt: "Refactor using the Strategy pattern. Each pricing algorithm should // be a separate class. PricingEngine should execute the injected strategy // without knowing which one it is." interface PricingStrategy { calculate(order: Order): number; } class StandardPricing implements PricingStrategy { calculate(order) { return order.items.reduce((sum, i) => sum + i.price * i.qty, 0); } } class WholesalePricing implements PricingStrategy { private static DISCOUNT = 0.20; calculate(order) { const subtotal = order.items.reduce((sum, i) => sum + i.price * i.qty, 0); return subtotal * (1 - WholesalePricing.DISCOUNT); } } class SubscriptionPricing implements PricingStrategy { calculate(order) { return order.items.reduce((sum, i) => sum + i.subscriberPrice * i.qty, 0); } } class PricingEngine { constructor(private strategy: PricingStrategy) {} calculate(order) { return this.strategy.calculate(order); } }

Observer: Decoupling Event Producers from Consumers

The smell: a class directly calls several other classes whenever something happens. The producer knows about all its consumers. Adding a new reaction to an event means modifying the producer. The producer and consumer lifecycles are coupled.

// Smell: producer knows all its consumers class Order { async complete() { this.status = 'completed'; await this.save(); // Producer is tangled with all consumers: await emailService.sendConfirmation(this); await inventoryService.deductStock(this); await loyaltyService.addPoints(this); await analyticsService.trackConversion(this); // Adding a new reaction = modifying Order } } // Prompt: "Refactor using the Observer pattern. Order should emit an event // when completed. Each reaction should be a separate observer that subscribes // to that event. Order should have zero knowledge of what happens next." class Order extends EventEmitter { async complete() { this.status = 'completed'; await this.save(); this.emit('completed', this); // Order's job ends here } } // Each observer registered independently — Order never changes order.on('completed', async (order) => emailService.sendConfirmation(order)); order.on('completed', async (order) => inventoryService.deductStock(order)); order.on('completed', async (order) => loyaltyService.addPoints(order)); order.on('completed', async (order) => analyticsService.trackConversion(order)); // Adding a new reaction: register a new observer. Order is never touched.

Chain of Responsibility: Sequential Processing with Early Exit

The smell: a validation or processing pipeline implemented as nested conditionals or a long sequence of imperative checks. Adding, removing, or reordering steps requires modifying the pipeline function. Steps cannot be tested in isolation.

// Smell: validation pipeline as a flat sequence of conditionals async function validateOrder(order) { if (!order.items || order.items.length === 0) { return { valid: false, reason: 'EMPTY_CART' }; } if (order.total < 0) { return { valid: false, reason: 'NEGATIVE_TOTAL' }; } const stockOk = await inventoryService.checkStock(order.items); if (!stockOk) { return { valid: false, reason: 'INSUFFICIENT_STOCK' }; } const fraudOk = await fraudService.assess(order); if (!fraudOk) { return { valid: false, reason: 'FRAUD_RISK' }; } return { valid: true }; } // Prompt: "Refactor using Chain of Responsibility. Each validation step // should be a handler class that either passes to the next handler or // short-circuits with a failure result. Steps should be composable // and independently testable." class OrderValidator { private handlers: ValidationHandler[]; constructor(...handlers) { this.handlers = handlers; } async validate(order) { for (const handler of this.handlers) { const result = await handler.handle(order); if (!result.passed) return { valid: false, reason: result.reason }; } return { valid: true }; } } // Each handler is independently testable class CartNotEmptyHandler implements ValidationHandler { async handle(order) { return order.items?.length > 0 ? { passed: true } : { passed: false, reason: 'EMPTY_CART' }; } } class StockAvailabilityHandler implements ValidationHandler { constructor(private inventory: InventoryService) {} async handle(order) { const ok = await this.inventory.checkStock(order.items); return ok ? { passed: true } : { passed: false, reason: 'INSUFFICIENT_STOCK' }; } } // Composing the pipeline — order of handlers is explicit and flexible const validator = new OrderValidator( new CartNotEmptyHandler(), new PositiveTotalHandler(), new StockAvailabilityHandler(inventoryService), new FraudRiskHandler(fraudService), );

The Pattern Selection Prompt

When you're unsure which pattern fits a problem, use AI for structured analysis rather than asking it to pick one. The structured approach prevents the AI from defaulting to a popular pattern that doesn't fit:

"I have this design problem: [describe the problem] Analyze it against these pattern categories: - Is the variation point in object creation? (→ Creational: Factory, Builder, Prototype) - Is the variation point in how objects are composed? (→ Structural: Adapter, Decorator, Facade, Composite) - Is the variation point in how objects communicate? (→ Behavioral: Strategy, Observer, Command, Chain) For each candidate pattern: 1. Describe how it would apply to this specific problem 2. What becomes easier to change with this pattern 3. What overhead it introduces (indirection, files, cognitive load) 4. Which pattern fits best and why — considering the actual complexity of this code, not theoretical future extensibility"

Where AI Gets Patterns Wrong

AI pattern mistakes cluster into three categories:

Pattern where none is needed. Three lines of similar code that could stay inline become a Factory with a registry and an interface. The abstraction exists for its own sake. Watch for this when the AI generates more scaffolding than business logic in response to a simple request.

Wrong pattern for the problem. AI sometimes applies Decorator when Facade is needed, or Strategy when the variation is in creation not behavior. Use the pattern selection prompt above to force explicit analysis before committing to an implementation.

Pattern applied at the wrong level of abstraction. Observer is powerful at the service boundary; it's overkill between two methods in the same class. Facade makes sense over a complex external system; it's unnecessary over two internal methods. AI tends to apply patterns at whatever granularity the code is presented at, not at the granularity where they add most value.

The pattern test: Before accepting any AI-generated pattern, ask: "What concrete change would this make harder to make?" If the answer is "nothing I can think of today", the pattern is adding indirection without adding flexibility. A pattern that makes future changes easier but makes the current code harder to read is only worth it if those future changes are actually likely.

Generating Pattern-Complete Implementations

When you've decided a pattern is genuinely warranted, this prompt generates a complete, production-ready implementation — not a tutorial skeleton:

"Implement the [pattern name] pattern for this specific problem: [describe] Requirements for the implementation: - All classes must have intent-revealing names based on the domain, not pattern names (e.g., 'StripeShippingAdapter' not 'Adapter', 'WholesalePricingStrategy' not 'Strategy') - Define all interfaces alongside the implementation - Include constructor injection for all dependencies - No magic numbers — extract as named constants - Include one example of how to compose and use it - Identify what SOLID principles this pattern naturally enforces - Flag any trade-off: what becomes harder with this pattern compared to the inline version"

The compounding effect: Design patterns and SOLID principles are not separate disciplines — they reinforce each other. Strategy enforces OCP. Decorator enforces SRP (each behavior in its own wrapper) and OCP (adding behavior without modifying existing code). Adapter enforces DIP (business logic depends on an interface, not a concrete SDK). When you use AI to apply patterns, prompt it to name which principles the pattern enforces — it deepens both the implementation and your understanding of why the pattern works.

Key Takeaways

Back to all articles