Skills

Install

$ npx ai-agents-skills add --skill solid
Domain v1.0

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 tests

Decision 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 abstractions

Example

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 tests

Edge 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