joabgonzalez

solid

"SOLID principles for maintainable OOP design. Trigger: When designing classes, services, or repositories in object-oriented code."

joabgonzalez 5 Updated 3mo ago

Resources

1
GitHub

Install

npx skillscat add joabgonzalez/ai-agents-skills/solid

Install via the SkillsCat registry.

SKILL.md

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