"SOLID principles for maintainable OOP design. Trigger: When designing classes, services, or repositories in object-oriented code."
Resources
1Install
npx skillscat add joabgonzalez/ai-agents-skills/solid Install via the SkillsCat registry.
SOLID Principles
Five principles by Robert C. Martin for maintainable, testable OOP design. Apply to backend services, repositories, controllers, and complex frontend components.
When to Use
- Designing class or service structures
- Identifying why code is hard to test or change
- Reviewing class responsibilities and dependencies
- Building plugin/extension systems
Don't use for:
- Simple scripts or utilities (<200 LOC)
- Prototypes or MVPs where speed > correctness
- Procedural code with no classes
Critical Patterns
✅ REQUIRED: Single Responsibility (SRP)
One reason to change per class. If you need "and" to describe it, split it.
❌ UserManager: validates + hashes + saves + sends email + logs
✅ UserValidator, PasswordService, UserRepository, EmailService, UserService (orchestrates)✅ REQUIRED: Open/Closed (OCP)
Extend via new classes, not by modifying existing ones. Use interfaces.
// ❌ Add new notification type → modify NotificationService
// ✅ Add SlackChannel implements INotificationChannel → no modification✅ REQUIRED: Liskov Substitution (LSP)
Subtypes must honor base contracts. Replacing A with B must not break callers.
❌ Penguin extends Bird { fly() { throw } } — breaks callers expecting Bird to fly
✅ Sparrow implements IFlyable; Penguin implements ISwimmable✅ REQUIRED: Interface Segregation (ISP)
Small, focused interfaces. Clients depend only on what they use.
❌ IRepository<T> with findAll + create + update + delete → ReportService only needs findAll
✅ IReadRepository<T> + IWriteRepository<T> → ReportService depends on IReadRepository✅ REQUIRED: Dependency Inversion (DIP)
High-level modules depend on abstractions, not concretions. Enable injection.
// ❌ private emailProvider = new SendGridEmailProvider()
// ✅ constructor(private emailService: IEmailService) {}
// → inject SendGrid, AWS SES, or mock in testsDecision Tree
Hard to test (requires complex mocks)?
→ DIP: Depend on interface, inject concrete via constructor
Adding new feature requires modifying existing class?
→ OCP: Extract interface, implement via new class
Class has multiple reasons to change?
→ SRP: Split responsibilities into separate classes
Interface has methods the implementor doesn't need?
→ ISP: Split into smaller focused interfaces
Subclass throws or behaves unexpectedly for base contract?
→ LSP: Redesign hierarchy with proper abstractionsExample
All 5 SOLID principles applied to a notification service.
// SRP — each class has one reason to change
class EmailNotifier { send(to: string, body: string): void { /* SMTP */ } }
class SlackNotifier { send(channel: string, body: string): void { /* Slack API */ } }
class NotificationFormatter { format(event: DomainEvent): string { /* templates */ } }
// OCP — add new channels without modifying existing code
interface INotificationChannel { notify(recipient: string, message: string): void; }
class EmailChannel implements INotificationChannel { /* wraps EmailNotifier */ }
class SlackChannel implements INotificationChannel { /* wraps SlackNotifier */ }
// Adding PushChannel → new class only, no existing code touched
// LSP — any INotificationChannel substitutes safely for another
function sendAlert(channel: INotificationChannel, recipient: string, msg: string) {
channel.notify(recipient, msg); // works with Email, Slack, or Push — no surprises
}
// ISP — split by consumer need (reporters only read, admins write)
interface IReadNotificationLog { findByRecipient(id: string): Notification[]; }
interface IWriteNotificationLog { save(n: Notification): void; }
class ReportService { constructor(private log: IReadNotificationLog) {} } // no unused methods
class NotificationService { constructor(private log: IWriteNotificationLog) {} }
// DIP — high-level service depends on abstraction, not concrete class
class AlertService {
constructor(private channels: INotificationChannel[]) {} // inject any channel(s)
broadcastAlert(event: DomainEvent): void {
const msg = new NotificationFormatter().format(event);
for (const ch of this.channels) ch.notify(event.recipientId, msg);
}
}
// Test: inject mock channels — no SMTP or Slack calls in unit testsEdge Cases
Over-engineering SRP: Splitting too far creates 20 tiny classes with one method each. SRP means "one reason to change", not "one method". A repository with findById + save + delete has ONE responsibility (data access).
OCP in practice: Full OCP from the start is premature. First violation: duplicate the code. Second violation: extract and parameterize. Only then apply OCP.
LSP and mocks: Test mocks technically violate LSP (they don't fully honor contracts). Acceptable because tests are not production consumers.
SOLID in functional code: DIP → inject functions instead of interfaces. SRP → each function has one purpose. OCP → extend via composition.
Resources
- solid-principles.md — Overview: benefits, when not to apply, practice checklists, navigation to all principle files
- single-responsibility.md — SRP: UserManager split, React component separation
- open-closed.md — OCP: NotificationService via INotificationChannel, React composition
- liskov-substitution.md — LSP: Bird/Penguin anti-pattern, InMemoryRepository contract
- interface-segregation.md — ISP: IReadRepository/IWriteRepository split, React container pattern
- dependency-inversion.md — DIP: IEmailService injection, IUserApi hook abstraction, mock testing