Loading content...
SOLID principles represent decades of accumulated wisdom about object-oriented design—hard-won lessons from countless projects that succeeded or failed based on their structural decisions. These five principles are not arbitrary rules; they are distilled patterns of successful design that prevent specific categories of problems.
During design validation, SOLID compliance serves as a systematic conscience that catches design flaws before they become implementation headaches. This page transforms SOLID from theoretical knowledge into a practical validation toolkit, providing concrete criteria for evaluating whether your design truly embodies these principles.
By the end of this page, you will have a rigorous evaluation framework for each SOLID principle, complete with specific questions to ask, anti-patterns to detect, and refactoring strategies when violations are discovered. You'll understand not just what SOLID means, but how to systematically validate compliance.
Before examining each principle individually, we must adopt the right mindset for SOLID validation. SOLID is not a checkbox exercise—it's a continuous spectrum of compliance. Designs aren't simply 'SOLID' or 'not SOLID'; they exist on a continuum from flagrant violation to exemplary adherence.
The Goal: Pragmatic Compliance
The objective of SOLID validation is not dogmatic adherence but practical improvement. We seek to identify violations that will cause real problems:
Some minor SOLID tensions are acceptable trade-offs. The skill is distinguishing acceptable pragmatic decisions from violations that will cause pain.
| Principle | Core Question | Violation Symptom | Primary Benefit |
|---|---|---|---|
| Single Responsibility | Does this class have one reason to change? | Changes to unrelated features require modifying this class | Maintainability, Testability |
| Open/Closed | Can behavior be extended without source modification? | Adding new variants requires editing existing code | Extensibility, Stability |
| Liskov Substitution | Can derived types be used wherever base types are expected? | Substituting a subclass causes unexpected behavior | Correctness, Polymorphism |
| Interface Segregation | Are interfaces focused on client needs? | Clients depend on methods they don't use | Decoupling, Cohesion |
| Dependency Inversion | Do high-level modules depend on abstractions? | Business logic directly instantiates infrastructure | Testability, Flexibility |
The Single Responsibility Principle states: A class should have only one reason to change. This seemingly simple statement is often the most difficult to validate because 'reason to change' requires understanding the forces that might cause modification.
The Stakeholder Test:
Robert Martin clarifies SRP as: 'A class should be responsible to one, and only one, actor (stakeholder).' This makes validation more concrete. For each class, ask: Who would request changes to this class?
If the answer includes multiple, independent stakeholders (e.g., 'The CFO for financial calculations AND the IT operations team for performance logging'), the class violates SRP.
❌ SRP Violation Example:
class UserService { // Responsibility 1: User Management createUser(data: UserData): User {} updateUser(id: string, data: UserData): User {} deleteUser(id: string): void {} // Responsibility 2: Authentication login(credentials: Credentials): Token {} logout(token: Token): void {} validateToken(token: Token): boolean {} // Responsibility 3: Email Notifications sendWelcomeEmail(user: User): void {} sendPasswordReset(user: User): void {} // Responsibility 4: Reporting generateUserReport(): Report {} getUserStatistics(): Statistics {}} // Four stakeholders: User Admin, Security Team,// Marketing, and Business Intelligence// Any change for one affects the others✅ SRP Compliant Design:
// Each class has ONE responsibilityclass UserRepository { create(data: UserData): User {} update(id: string, data: UserData): User {} delete(id: string): void {} findById(id: string): User | null {}} class AuthenticationService { constructor(private userRepo: UserRepository) {} login(credentials: Credentials): Token {} logout(token: Token): void {} validateToken(token: Token): boolean {}} class UserNotificationService { constructor(private emailGateway: EmailGateway) {} sendWelcomeEmail(user: User): void {} sendPasswordReset(user: User): void {}} class UserReportingService { constructor(private userRepo: UserRepository) {} generateReport(): Report {} getStatistics(): Statistics {}}SRP can be taken too far. A class with only one method is probably too granular. The goal is cohesive responsibilities, not atomic fragmentation. If separating responsibilities creates a tangled web of tiny classes that are always used together, reconsider the boundaries.
The Open/Closed Principle states: Software entities should be open for extension but closed for modification. This means adding new behavior should not require changing existing, tested code.
The Extension Thought Experiment:
For OCP validation, identify likely extensions—new variants, new behaviors, new integrations that might be needed. Then ask: Can I add this without modifying existing classes?
If adding a new payment type requires editing PaymentProcessor, OCP is violated. If you can add BitcoinPaymentStrategy without touching PaymentProcessor, OCP is satisfied.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// ❌ OCP Violation: Adding new payment type requires modifying this classclass PaymentProcessor { processPayment(payment: Payment): Result { switch (payment.type) { case "credit_card": return this.processCreditCard(payment); case "paypal": return this.processPayPal(payment); case "bank_transfer": return this.processBankTransfer(payment); // Adding crypto requires editing this file! default: throw new Error("Unknown payment type"); } } private processCreditCard(payment: Payment): Result { /* ... */ } private processPayPal(payment: Payment): Result { /* ... */ } private processBankTransfer(payment: Payment): Result { /* ... */ }} // ✅ OCP Compliant: New payment types added without modificationinterface PaymentStrategy { canHandle(payment: Payment): boolean; process(payment: Payment): Result;} class PaymentProcessor { constructor(private strategies: PaymentStrategy[]) {} processPayment(payment: Payment): Result { const strategy = this.strategies.find(s => s.canHandle(payment)); if (!strategy) { throw new Error("No strategy found for payment type"); } return strategy.process(payment); }} // Add new payment types by creating new classes, no modification neededclass CryptoPaymentStrategy implements PaymentStrategy { canHandle(payment: Payment): boolean { return payment.type === "crypto"; } process(payment: Payment): Result { /* ... */ }} // Registration via dependency injection or factoryOCP is most valuable when applied to anticipated variation points. Don't add extension mechanisms for unlikely changes—that's speculative generality. Apply OCP strategically where you have evidence (requirements, domain knowledge, past experience) that extension is likely.
The Liskov Substitution Principle states: Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. This is deeper than it appears—it requires that subtypes honor the behavioral contracts of their supertypes.
The Substitution Test:
For every inheritance relationship in your design, ask: If I substitute the subtype for the supertype in any code that uses the supertype, will behavior remain correct?
This isn't just about method signatures—it's about semantic behavior, invariants, preconditions, and postconditions.
save() that doesn't persist data violates the contract even if it compiles.❌ LSP Violation (Classic Example):
class Rectangle { protected width: number; protected height: number; setWidth(w: number): void { this.width = w; } setHeight(h: number): void { this.height = h; } getArea(): number { return this.width * this.height; }} class Square extends Rectangle { // Violates LSP: changes behavior setWidth(w: number): void { this.width = w; this.height = w; // Surprise! } setHeight(h: number): void { this.width = h; // Surprise! this.height = h; }} // Client code breaks:function clientCode(rect: Rectangle) { rect.setWidth(5); rect.setHeight(10); // Expects area = 50, but Square gives 100! assert(rect.getArea() === 50); // FAILS}✅ LSP Compliant Design:
// Separate abstractions instead of forcing// Square into Rectangle's mutable contractinterface Shape { getArea(): number;} // Immutable shapes avoid the mutation trapclass Rectangle implements Shape { constructor( readonly width: number, readonly height: number ) {} getArea(): number { return this.width * this.height; } withWidth(w: number): Rectangle { return new Rectangle(w, this.height); } withHeight(h: number): Rectangle { return new Rectangle(this.width, h); }} class Square implements Shape { constructor(readonly side: number) {} getArea(): number { return this.side * this.side; } withSide(s: number): Square { return new Square(s); }} // No substitution problems because// Square IS-NOT-A Rectangle in behaviorMathematically, a square IS-A rectangle. But in OOP, inheritance means 'behaves the same as under all operations.' A square doesn't behave the same as a mutable rectangle. LSP teaches us that inheritance is about behavioral contracts, not taxonomic classification.
The Interface Segregation Principle states: Clients should not be forced to depend on interfaces they do not use. Large, monolithic interfaces create unnecessary coupling—changes to unused methods trigger recompilation and can introduce breaking changes.
The Client Perspective Analysis:
For ISP validation, examine interfaces from the perspective of clients. Ask: What does each client actually need? Then compare this to what the interface provides.
If clients consistently use only a subset of an interface's methods, the interface is too broad and should be segregated.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// ❌ ISP Violation: Fat interface forces clients to depend on unused methodsinterface DataService { create(entity: Entity): Entity; read(id: string): Entity | null; update(id: string, entity: Entity): Entity; delete(id: string): void; bulkCreate(entities: Entity[]): Entity[]; search(criteria: SearchCriteria): Entity[]; export(format: ExportFormat): Buffer; import(data: Buffer, format: ImportFormat): ImportResult; validate(entity: Entity): ValidationResult; getStatistics(): Statistics;} // ReportingService only needs read + export + statistics// but is forced to depend on entire interfaceclass ReportingService { constructor(private dataService: DataService) {} generateReport(): Report { const stats = this.dataService.getStatistics(); const exported = this.dataService.export("csv"); // Other methods pollute the dependency }} // ✅ ISP Compliant: Segregated interfaces allow selective dependencyinterface EntityReader { read(id: string): Entity | null; search(criteria: SearchCriteria): Entity[];} interface EntityWriter { create(entity: Entity): Entity; update(id: string, entity: Entity): Entity; delete(id: string): void;} interface EntityExporter { export(format: ExportFormat): Buffer; getStatistics(): Statistics;} interface EntityImporter { import(data: Buffer, format: ImportFormat): ImportResult; bulkCreate(entities: Entity[]): Entity[];} interface EntityValidator { validate(entity: Entity): ValidationResult;} // ReportingService only depends on what it needsclass ReportingService { constructor( private reader: EntityReader, private exporter: EntityExporter ) {} generateReport(): Report { const stats = this.exporter.getStatistics(); const exported = this.exporter.export("csv"); // Clean, focused dependencies }} // One class can implement multiple interfacesclass DataRepository implements EntityReader, EntityWriter, EntityExporter, EntityImporter, EntityValidator { // Complete implementation}When you can't modify a fat third-party interface, create adapter interfaces that expose only what your code needs. This insulates your design from the bloated external interface and provides a natural seam for testing.
The Dependency Inversion Principle states: High-level modules should not depend on low-level modules. Both should depend on abstractions. Additionally, abstractions should not depend on details; details should depend on abstractions.
The Dependency Direction Analysis:
For DIP validation, examine the direction of dependencies throughout the design. Ask: Do business logic classes directly reference infrastructure classes?
High-level business policies should not import database clients, HTTP libraries, or file system utilities. Instead, they should depend on abstract interfaces that hide these details.
new MySQLDatabase()) violates DIP. Injection of interfaces enables inversion.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// ❌ DIP Violation: Business logic depends on concrete infrastructureimport { MySQLConnection } from "mysql2";import { RedisClient } from "redis";import { HTTPClient } from "axios"; class OrderService { private db = new MySQLConnection("mysql://..."); private cache = new RedisClient("redis://..."); private paymentApi = new HTTPClient("https://payment.api"); async placeOrder(order: Order): Promise<OrderResult> { // Business logic tightly coupled to MySQL, Redis, external API const inventory = await this.db.query("SELECT ..."); const cached = await this.cache.get(order.userId); const payment = await this.paymentApi.post("/charge", order); // Cannot test without all infrastructure running }} // ✅ DIP Compliant: Business logic depends on abstractions// Abstractions defined in domain layerinterface OrderRepository { findById(id: string): Promise<Order | null>; save(order: Order): Promise<void>; findPending(): Promise<Order[]>;} interface InventoryChecker { checkAvailability(productIds: string[]): Promise<AvailabilityResult>;} interface PaymentProcessor { charge(userId: string, amount: Money): Promise<PaymentResult>;} interface OrderCache { getRecentOrders(userId: string): Promise<Order[]>; cacheOrder(order: Order): Promise<void>;} // Business logic depends only on abstractionsclass OrderService { constructor( private orderRepository: OrderRepository, private inventoryChecker: InventoryChecker, private paymentProcessor: PaymentProcessor, private orderCache: OrderCache ) {} async placeOrder(order: Order): Promise<OrderResult> { // Pure business logic, infrastructure is abstracted const availability = await this.inventoryChecker.checkAvailability( order.items.map(i => i.productId) ); if (!availability.allAvailable) { return OrderResult.unavailable(availability.missingItems); } const payment = await this.paymentProcessor.charge( order.userId, order.totalAmount ); if (!payment.successful) { return OrderResult.paymentFailed(payment.error); } await this.orderRepository.save(order); await this.orderCache.cacheOrder(order); return OrderResult.success(order.id); }} // Concrete implementations in infrastructure layerclass MySQLOrderRepository implements OrderRepository { /* ... */ }class RedisOrderCache implements OrderCache { /* ... */ }class StripePaymentProcessor implements PaymentProcessor { /* ... */ }DIP is the foundation of Hexagonal (Ports and Adapters) architecture. The 'ports' are the abstractions defined by business logic, and 'adapters' are the concrete implementations. DIP validation naturally leads toward this powerful architectural pattern.
Not all SOLID violations are equally severe. During design validation, you must assess which violations require immediate correction and which can be accepted as conscious trade-offs.
Severity Classification Framework:
| Severity | Characteristics | Action Required | Example |
|---|---|---|---|
| Critical | Prevents testing, blocks evolution, causes subtle bugs | Must fix before proceeding | LSP violation causing incorrect calculations in production |
| High | Will cause significant maintenance burden | Fix in current iteration | God class with 5+ responsibilities |
| Medium | Creates friction but doesn't block progress | Schedule for near-term refactoring | Fat interface that could be segregated |
| Low | Minor deviation, pragmatic trade-off | Document decision, monitor for escalation | Small switch statement unlikely to grow |
| Acceptable | Conscious decision with clear justification | Record in design decisions | Performance optimization trading purity for speed |
The Escalation Pattern:
Low-severity violations have a tendency to escalate. A switch statement with three cases becomes ten. A slightly bloated class accumulates more responsibilities. During validation, consider not just current severity but trajectory.
Documentation of Accepted Violations:
When you consciously accept a SOLID tension, document it:
You now have a rigorous framework for validating SOLID compliance, with specific checkpoints for each principle and severity assessment guidelines. The next page focuses on extensibility validation—ensuring your design can gracefully accommodate anticipated changes.