SOLID with AI: Using AI to Design, Review, and Enforce the Five Principles

SOLID principles are one of the most cited topics in software engineering interviews and one of the least applied disciplines in day-to-day codebases. Developers know the acronym. They can recite the principles. Then they ship a class with nine responsibilities, an abstraction that breaks every time a new feature lands, and an interface that forces implementors to stub out half its methods.

The gap between knowing and applying is where AI can help. Used well, AI becomes a SOLID design partner: it can detect violations before they hit code review, generate refactored alternatives that respect design boundaries, and produce new code that starts on the right side of each principle. This article shows exactly how to make that work.

S — Single Responsibility Principle

The principle: A class should have one, and only one, reason to change. A reason to change means a responsibility — a business concern that, if it evolved, would require you to open that class.

How AI helps: SRP violations are easy to describe in natural language but hard to see in your own code. Ask AI to audit a class by listing every distinct reason it might need to change. When the list has more than one item, you have an SRP violation and the list itself tells you how to split.

// Prompt: "List every distinct reason this class might need to change. // A 'reason to change' is a business concern — a separate stakeholder, // a separate policy, a separate lifecycle." class OrderService { async createOrder(data) { /* validate, save to DB */ } async sendConfirmationEmail(order) { /* build and send email */ } async generateInvoicePdf(order) { /* build PDF, upload to S3 */ } async notifyWarehouse(order) { /* call warehouse API */ } async applyLoyaltyPoints(order, user) { /* update points balance */ } } // AI response: // Reasons to change: // 1. Order creation rules change (business team) // 2. Email template or provider changes (marketing team) // 3. Invoice format or storage changes (finance/legal team) // 4. Warehouse API contract changes (ops team) // 5. Loyalty program rules change (CRM team) // // → 5 reasons = 5 classes

Once the analysis is done, ask for the refactored split. The AI generates the separated classes and an orchestrator that composes them — maintaining behavior while respecting SRP:

// Prompt: "Refactor this class following SRP. Create one class per responsibility. // Create a thin orchestrator that composes them. Keep the public interface identical." class OrderCreator { /* validate + persist */ } class OrderEmailNotifier { /* send confirmation */ } class OrderInvoiceGenerator { /* produce PDF */ } class WarehouseNotifier { /* call warehouse API */ } class LoyaltyPointsApplier { /* update points */ } class OrderService { constructor(creator, emailer, invoicer, warehouse, loyalty) { ... } async createOrder(data) { const order = await this.creator.create(data); await Promise.all([ this.emailer.notify(order), this.invoicer.generate(order), this.warehouse.notify(order), this.loyalty.apply(order), ]); return order; } }

O — Open/Closed Principle

The principle: Software entities should be open for extension but closed for modification. Adding a new behavior should not require changing existing, tested code.

The classic OCP smell: a switch or long if/else if chain that dispatches on a type. Every new type means opening the switch and adding another case — a modification of existing code instead of an extension.

// OCP violation: every new payment method requires modifying this class class PaymentProcessor { process(payment) { switch (payment.method) { case 'credit_card': return this.processCard(payment); case 'pix': return this.processPix(payment); case 'boleto': return this.processBoleto(payment); // New method? Open this file, add a case. Risky. } } }

The right prompt makes AI generate the OCP-compliant version using a strategy map — closed for modification, open for extension by registering new handlers:

// Prompt: "Refactor to follow OCP. Adding a new payment method should require // creating a new class only — no changes to existing code." // Strategy interface class PaymentStrategy { process(payment) { throw new Error('Not implemented'); } } // Concrete strategies — one class per method, each in its own file class CreditCardStrategy extends PaymentStrategy { ... } class PixStrategy extends PaymentStrategy { ... } class BoletoStrategy extends PaymentStrategy { ... } // Processor — never needs to change when a new method is added class PaymentProcessor { constructor(strategies) { this.strategies = strategies; // Map } process(payment) { const strategy = this.strategies.get(payment.method); if (!strategy) throw new UnsupportedPaymentMethodError(payment.method); return strategy.process(payment); } } // Adding "crypto" tomorrow: create CryptoStrategy, register it. Done. // PaymentProcessor is never touched.

L — Liskov Substitution Principle

The principle: Subtypes must be substitutable for their base types without breaking the program. If your code works with an Animal, it must work with any subclass of Animal — without special-casing, without checking instanceof, without catching unexpected exceptions.

LSP violations often hide in class hierarchies that look correct on paper. The tell-tale signs: a subclass that throws NotImplementedException for an inherited method, a subclass that weakens the parent's postconditions, or client code that must check the runtime type to avoid a crash.

// Classic LSP violation: Square extends Rectangle class Rectangle { setWidth(w) { this.width = w; } setHeight(h) { this.height = h; } area() { return this.width * this.height; } } class Square extends Rectangle { setWidth(w) { this.width = w; this.height = w; } // side effect! setHeight(h) { this.width = h; this.height = h; } // side effect! } // Client code breaks silently: function resizeAndMeasure(rect) { rect.setWidth(5); rect.setHeight(10); // Expected: 50. Square returns: 100. LSP violated. console.log(rect.area()); }

Use AI to audit a hierarchy before it causes production bugs:

// Prompt: "Audit this class hierarchy for LSP violations. For each subclass, check: // 1. Does it throw exceptions the parent contract doesn't declare? // 2. Does it weaken preconditions or postconditions relative to the parent? // 3. Would code written against the base class break if given this subclass? // If violations exist, suggest how to restructure the hierarchy." // AI will recommend flattening to a Shape interface with separate types // when inheritance doesn't model a true IS-A relationship.

I — Interface Segregation Principle

The principle: No client should be forced to depend on methods it does not use. Fat interfaces create artificial coupling: a class that implements only three of twelve methods must still import and stub the other nine, and changes to those nine affect classes that don't use them.

// ISP violation: implementors are forced to stub methods they don't use interface UserRepository { findById(id: string): User; findByEmail(email: string): User; save(user: User): void; delete(id: string): void; findAllWithExpiredSubscriptions(): User[]; // only used by a billing job generateActivityReport(from: Date, to: Date): Report; // only used by admin anonymize(id: string): void; // only used by GDPR compliance service } // ReadOnlyUserRepository only needs findById and findByEmail, // but is forced to implement all 7 methods.

The prompt for ISP refactoring asks AI to identify natural groupings and split by client:

// Prompt: "Split this interface following ISP. Group methods by which client uses them. // Each interface should contain only the methods a single type of caller needs." // AI produces: interface UserReader { findById(id: string): User; findByEmail(email: string): User; } interface UserWriter { save(user: User): void; delete(id: string): void; } interface BillingUserQuery { findAllWithExpiredSubscriptions(): User[]; } interface UserReporter { generateActivityReport(from: Date, to: Date): Report; } interface GdprUserManager { anonymize(id: string): void; } // Each concrete repository implements only what it needs. // UserService: implements UserReader + UserWriter // BillingJob: implements BillingUserQuery // AdminController: implements UserReporter

D — Dependency Inversion Principle

The principle: High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.

In practice: if your OrderService (high-level) directly instantiates PostgresOrderRepository (low-level), you've hardwired a dependency. Changing the database requires modifying business logic. Testing business logic requires a real database. Both are wrong.

// DIP violation: high-level module depends on concrete low-level module class OrderService { constructor() { // Direct instantiation = hardwired dependency this.repository = new PostgresOrderRepository(); this.emailClient = new SendGridEmailClient(); this.logger = new DatadogLogger(); } async createOrder(data) { const order = await this.repository.save(data); await this.emailClient.send(order.email, 'confirmation', order); this.logger.info('Order created', { orderId: order.id }); return order; } }

The DIP prompt asks AI to invert the dependencies by introducing interfaces and constructor injection:

// Prompt: "Refactor to follow DIP. The high-level class should depend on // abstractions injected through the constructor — not on concrete implementations. // Define the interfaces the class needs. Do not instantiate anything internally." // Abstractions (defined by the high-level module's needs) interface OrderRepository { save(data: OrderData): Promise; findById(id: string): Promise; } interface EmailNotifier { send(to: string, template: string, context: object): Promise; } interface Logger { info(message: string, meta?: object): void; } // High-level module — no concrete dependencies anywhere class OrderService { constructor( private readonly repository: OrderRepository, private readonly emailNotifier: EmailNotifier, private readonly logger: Logger, ) {} async createOrder(data: OrderData): Promise { const order = await this.repository.save(data); await this.emailNotifier.send(order.email, 'confirmation', order); this.logger.info('Order created', { orderId: order.id }); return order; } } // Low-level modules depend on the abstractions — not the other way around class PostgresOrderRepository implements OrderRepository { ... } class SendGridEmailNotifier implements EmailNotifier { ... } class DatadogLogger implements Logger { ... }

The SOLID Audit Prompt

Beyond working with each principle individually, one of the highest-value uses of AI is a comprehensive SOLID audit of a class or module before it reaches code review. This prompt runs all five principles in a single pass:

"Audit this class against each SOLID principle. For each one: S — Single Responsibility: List every distinct reason this class might need to change. If more than one, name each responsibility and suggest a split. O — Open/Closed: Identify any conditional dispatch on type (switch, if/else chains checking a type field). Show how to replace it with a strategy or polymorphism so adding behavior requires only new code, not changed code. L — Liskov Substitution: If this class is part of a hierarchy, check whether any subclass throws exceptions not declared by the parent, weakens postconditions, or requires callers to check instanceof. Flag each violation. I — Interface Segregation: If this class implements an interface, check whether any method goes unused by some callers. If so, suggest smaller interfaces grouped by client type. D — Dependency Inversion: List every concrete dependency instantiated inside this class. For each one, show how to extract an interface and inject it through the constructor. For each violation found, explain the concrete consequence (what breaks, what becomes harder to change) and show the refactored code."

Prompting for SOLID Code From the Start

Detection and refactoring are valuable, but the highest leverage comes from generating SOLID-compliant code before violations are introduced. These constraints, added to any code generation prompt, eliminate the most common violations from the first output:

"Write [the class/module]. Apply these SOLID constraints: - SRP: One class, one reason to change. If the implementation needs to do more than one thing, create separate collaborators for each responsibility. - OCP: Do not write switch or if/else chains that dispatch on a type. Use polymorphism or a strategy map so new types require only new classes. - LSP: If creating a hierarchy, ensure every subtype is a true IS-A. Prefer composition over inheritance when the relationship is has-a. - ISP: Define the narrowest interface possible for each collaborator. Clients should depend only on the methods they actually call. - DIP: Never instantiate dependencies inside the class. Accept all dependencies as constructor parameters typed to interfaces, not concretions. Show the interfaces alongside the implementation."

Where AI Gets SOLID Wrong

AI is not infallible on SOLID. Two failure modes appear consistently:

Over-engineering the OCP. AI sometimes introduces Strategy + Factory + Registry for a case that has two variants and will never have more. Three concrete classes for two payment methods is probably over-abstracted. The test: if you can see all the types from here and they're stable, a simple switch is honest. Apply OCP where the set of variants is open — where you genuinely expect it to grow.

Misapplying DIP to create interfaces for everything. DIP says depend on abstractions — it does not say wrap every class in an interface. An interface has a cost: a second file, indirection, mock complexity in tests. Create an interface when you have at least two implementations (production + test double, or two real variants), or when you explicitly want to protect a high-level module from a volatile low-level one. One implementation + one interface = unnecessary abstraction.

The SOLID judgment call: SOLID is not a checklist to maximize. It's a set of principles to apply where they reduce the cost of change. Ask "what changes independently here?" — the answer tells you where to draw the boundaries. AI can spot violations and generate compliant code; you decide which violations actually hurt and which "violations" are honest simplicity.

Using AI to Write SOLID Tests

One underused AI capability is generating tests that enforce SOLID boundaries. A test suite written with DIP in mind uses injected fakes instead of mocks, tests one behavior per test, and fails for exactly one reason. Ask AI to generate tests that validate SOLID compliance:

// Prompt: "Write unit tests for OrderService. Use in-memory fakes for all // dependencies — no mocking frameworks, no real databases. Each test should: // - Test exactly one behavior // - Inject its own fakes so it's completely isolated // - Name the tested behavior in the test name // - Assert only the outcome relevant to that behavior" class InMemoryOrderRepository implements OrderRepository { private orders: Order[] = []; async save(data) { const order = { id: uuid(), ...data }; this.orders.push(order); return order; } async findById(id) { return this.orders.find(o => o.id === id) ?? null; } getSavedOrders() { return this.orders; } } class SpyEmailNotifier implements EmailNotifier { sent: Array<{ to: string; template: string }> = []; async send(to, template) { this.sent.push({ to, template }); } } it('persists the order when creation succeeds', async () => { const repository = new InMemoryOrderRepository(); const service = new OrderService(repository, new SpyEmailNotifier(), silentLogger); await service.createOrder({ email: 'user@example.com', items: [{ sku: 'A', qty: 1 }] }); expect(repository.getSavedOrders()).toHaveLength(1); }); it('sends a confirmation email to the order owner after creation', async () => { const emailNotifier = new SpyEmailNotifier(); const service = new OrderService(new InMemoryOrderRepository(), emailNotifier, silentLogger); await service.createOrder({ email: 'user@example.com', items: [] }); expect(emailNotifier.sent).toContainEqual({ to: 'user@example.com', template: 'confirmation' }); });

These tests are possible precisely because DIP was applied. If OrderService instantiated PostgresOrderRepository internally, none of this in-memory testing would work. SOLID and testability reinforce each other — and AI generates both when you prompt for both.

The compounding effect: SOLID and AI form a virtuous cycle. Clean boundaries make AI suggestions more accurate — the AI can reason about a small, focused class far better than a 400-line god class. Better boundaries make tests cheaper to write, so AI generates better tests. Better tests catch violations early, so violations accumulate less debt. The discipline pays compound interest.

Key Takeaways

Back to all articles