Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 002 / 100

SOLID in one carousel: a practical Design Patterns guide

Five principles that make code easier to change.

Expona Admin12 min read

After spending years shipping features across e-commerce platforms, CRM systems, and ride-hailing apps, I've learned that the code that survives isn't always the most clever—it's the code that bends without breaking when requirements change. SOLID in one carousel captures five principles that, when applied thoughtfully, give you exactly that resilience. This isn't about perfection; it's about making one change without triggering a cascade of failures across your codebase.

The five principles

SOLID is an acronym coined by Robert C. Martin that represents five design principles. Rather than treating them as commandments, think of them as guardrails. Each addresses a specific pain point that emerges as codebases grow.

Principle In one line The problem it solves
Single Responsibility One reason to change Classes doing too much become fragile and hard to test
Open/Closed Extend without editing Modifying existing code breaks assumptions downstream
Liskov Substitution Subtypes keep promises Broken contracts between base and derived classes cause silent bugs
Interface Segregation Small, focused interfaces Forcing clients to depend on methods they don't use creates coupling
Dependency Inversion Depend on abstractions Direct dependencies on concrete implementations lock you into one way of doing things

These five aren't independent. They work together to push you toward code that's modular, testable, and change-friendly. The through-line is always the same: reduce coupling, increase cohesion.

Single Responsibility: One reason to change

A class should have exactly one reason to change. That reason is its responsibility.

I once inherited a UserService in a ride-hailing platform that handled user registration, password resets, email notifications, payment validation, and driver ratings. When the payment provider API changed, we had to modify the class. When email templates needed updating, we modified it again. Every change risked breaking something unrelated.

Here's what that looked like:

typescript
// ❌ Too many reasons to change
class UserService {
  registerUser(email: string, password: string) {
    // Registration logic
  }

  resetPassword(userId: string, newPassword: string) {
    // Password reset logic
  }

  sendWelcomeEmail(email: string) {
    // Email sending logic
  }

  validatePayment(userId: string, amount: number) {
    // Payment validation
  }

  rateDriver(userId: string, driverId: string, rating: number) {
    // Rating logic
  }
}

The fix: split responsibilities into separate classes, each owning one reason to change.

typescript
// ✓ Each class has one reason to change
class UserRegistrationService {
  registerUser(email: string, password: string) {
    // Registration logic
  }
}

class PasswordResetService {
  resetPassword(userId: string, newPassword: string) {
    // Password reset logic
  }
}

class EmailNotificationService {
  sendWelcomeEmail(email: string) {
    // Email sending logic
  }
}

class PaymentValidationService {
  validatePayment(userId: string, amount: number) {
    // Payment validation
  }
}

class DriverRatingService {
  rateDriver(userId: string, driverId: string, rating: number) {
    // Rating logic
  }
}

Now when the payment provider changes, only PaymentValidationService needs attention. Tests are simpler. Dependencies are explicit.

Open/Closed: Extend without editing

Software entities should be open for extension but closed for modification. In practice, this means you should add new behavior without changing existing code.

I built a notification system for a travel platform that originally supported email only. When we needed to add SMS and in-app notifications, the naive approach was to modify the existing class:

typescript
// ❌ Violates open/closed: need to edit existing code
class NotificationService {
  send(userId: string, message: string, type: 'email' | 'sms' | 'app') {
    if (type === 'email') {
      // email logic
    } else if (type === 'sms') {
      // sms logic
    } else if (type === 'app') {
      // app logic
    }
  }
}

Every new notification type requires editing the class and adding another conditional. That's error-prone and violates the principle.

The SOLID approach uses an abstraction and lets you extend without editing:

typescript
// ✓ Open for extension, closed for modification
interface NotificationChannel {
  send(userId: string, message: string): Promise<void>;
}

class EmailNotificationChannel implements NotificationChannel {
  async send(userId: string, message: string): Promise<void> {
    // email logic
  }
}

class SmsNotificationChannel implements NotificationChannel {
  async send(userId: string, message: string): Promise<void> {
    // sms logic
  }
}

class AppNotificationChannel implements NotificationChannel {
  async send(userId: string, message: string): Promise<void> {
    // app logic
  }
}

class NotificationService {
  constructor(private channels: NotificationChannel[]) {}

  async notifyUser(userId: string, message: string): Promise<void> {
    await Promise.all(
      this.channels.map(channel => channel.send(userId, message))
    );
  }
}

Now adding Slack notifications means creating a new class that implements NotificationChannel. The existing NotificationService doesn't change. It's open for extension through composition.

Liskov Substitution: Subtypes keep promises

If a program expects a PaymentProcessor, it should be able to use any subtype of PaymentProcessor without breaking. Subtypes must honor the contract of their base type.

I saw this principle violated in a CRM system where we had a base CacheService and a PremiumCacheService` subtype that sometimes failed silently under load instead of throwing errors like the base class promised:

typescript
// ❌ Violates Liskov substitution
class CacheService {
  async get(key: string): Promise<string | null> {
    // Always resolves or throws
  }
}

class PremiumCacheService extends CacheService {
  async get(key: string): Promise<string | null> {
    // Sometimes returns undefined instead of null
    // Sometimes swallows errors and returns null
    // Breaks the contract
  }
}

Code relying on the base class's contract breaks when given the subtype:

typescript
// Caller expects consistent behavior
async function getCachedUser(cache: CacheService, userId: string) {
  const cached = await cache.get(`user:${userId}`);
  if (cached === null) {
    // Fetch from database
  }
  // With PremiumCacheService, this logic fails
}

The fix: ensure subtypes strengthen or maintain, never weaken, the contract:

typescript
// ✓ PremiumCacheService honors the base contract
class PremiumCacheService extends CacheService {
  async get(key: string): Promise<string | null> {
    // Always returns string or null, never undefined
    // Throws on actual errors, doesn't swallow them
    try {
      const value = await this.redisClient.get(key);
      return value ?? null;
    } catch (error) {
      throw new CacheError(`Failed to get ${key}`, error);
    }
  }
}

Interface Segregation: Small, focused interfaces

Don't force clients to depend on methods they don't use. Many small, client-specific interfaces beat one large, general-purpose interface.

In an e-commerce platform, I encountered a massive ProductRepository interface that included methods for searching, filtering, caching, analytics, and batch operations. A simple cache warmer only needed a fetch method, but it inherited a dependency on logging, analytics, and error reporting.

typescript
// ❌ Violates interface segregation
interface ProductRepository {
  getById(id: string): Promise<Product>;
  search(query: string): Promise<Product[]>;
  filter(criteria: any): Promise<Product[]>;
  logAccess(productId: string): Promise<void>;
  recordAnalytics(event: string): Promise<void>;
  batchInsert(products: Product[]): Promise<void>;
  clearCache(): Promise<void>;
}

// Cache warmer doesn't need most of these
class CacheWarmer {
  constructor(private repo: ProductRepository) {}

  async warm(): Promise<void> {
    // Only uses getById, but inherits dependency on all methods
    const product = await this.repo.getById('123');
  }
}

Instead, split into small, focused interfaces:

typescript
// ✓ Small, focused interfaces
interface ProductReader {
  getById(id: string): Promise<Product>;
}

interface ProductSearcher {
  search(query: string): Promise<Product[]>;
  filter(criteria: any): Promise<Product[]>;
}

interface ProductWriter {
  batchInsert(products: Product[]): Promise<void>;
}

interface CacheManager {
  clearCache(): Promise<void>;
}

interface AnalyticsRecorder {
  recordAccess(productId: string): Promise<void>;
  recordAnalytics(event: string): Promise<void>;
}

// Now each client depends only on what it needs
class CacheWarmer {
  constructor(private reader: ProductReader) {}

  async warm(): Promise<void> {
    const product = await this.reader.getById('123');
  }
}

This also makes testing easier. You mock only what's needed.

Dependency Inversion: Depend on abstractions

High-level modules shouldn't depend on low-level modules; both should depend on abstractions. In simpler terms: inject dependencies, don't create them inside.

I worked on a ride-hailing platform where the BookingService directly instantiated a SmsNotifier and a DatabaseConnection:

typescript
// ❌ Violates dependency inversion
class BookingService {
  private notifier = new SmsNotifier();
  private db = new DatabaseConnection('production');

  async bookRide(userId: string, pickupLocation: string): Promise<void> {
    // Hard-wired to concrete implementations
    await this.db.query('INSERT INTO bookings ...');
    await this.notifier.send(`Ride confirmed to ${pickupLocation}`);
  }
}

This creates tight coupling. Testing requires spinning up a real SMS provider and database. Switching to a different notifier means changing BookingService.

The fix: depend on abstractions through constructor injection:

typescript
// ✓ Depends on abstractions, not concrete implementations
interface Notifier {
  send(message: string): Promise<void>;
}

interface BookingRepository {
  create(booking: Booking): Promise<void>;
}

class BookingService {
  constructor(
    private notifier: Notifier,
    private repository: BookingRepository
  ) {}

  async bookRide(userId: string, pickupLocation: string): Promise<void> {
    const booking = new Booking(userId, pickupLocation);
    await this.repository.create(booking);
    await this.notifier.send(`Ride confirmed to ${pickupLocation}`);
  }
}

Now BookingService depends on interfaces. Testing is straightforward: pass mock implementations. Switching to email notifications means creating a new EmailNotifier and injecting it.

They're guidelines, not laws

Here's something I wish I'd understood earlier: SOLID principles are guides, not absolutes. Applied blindly, SOLID creates as many problems as it solves.

I've seen teams create seventeen small classes and interfaces to satisfy SOLID rules for a simple utility function. I've watched projects become overengineered before they were finished. I've inherited codebases so fragmented that finding the actual logic took an hour of file hopping.

SOLID shines when your codebase is large enough that change is expensive and frequent. A small utility library doesn't need five layers of abstraction. A feature you'll delete in three months doesn't need to be extensible.

The right approach: start with straightforward code. Refactor toward SOLID when pain appears—when you're editing five files to make one change, when tests become nightmares, when adding a feature forces you to touch code that has nothing to do with it. Let your pain guide your design.

The common goal

Strip away the jargon, and SOLID exists for one reason: to make your code more receptive to change. The goal is simple: modify one thing without breaking ten others.

Every time you apply SRP, Open/Closed, Liskov Substitution, Interface Segregation, or Dependency Inversion, you're reducing coupling. Loosely coupled systems change safely.

Think about your last production incident. How many files did you have to touch to fix it? How confident were you that your fix didn't break something else? SOLID principles reduce both those numbers. A single-responsibility class means the bug is isolated. Dependency injection means you can test in isolation before deploying.

Over a five-year career building multiple platforms, I've noticed something: the systems that aged best weren't the ones with the most sophisticated architecture. They were the ones that changed often and safely. SOLID is the discipline that enables that.

Start with SRP and DIP

If you're overwhelmed by five principles, focus on two: Single Responsibility and Dependency Inversion. These two deliver most of the value in everyday code.

SRP forces you to name things clearly and keep responsibilities separate. When a class starts doing two things, you feel it—the name becomes awkward, tests split into unrelated scenarios. That's your signal to split.

DIP ensures flexibility. Once you're injecting dependencies, you've already bought yourself extensibility and testability. You can swap implementations, mock for tests, and evolve behavior without editing existing code.

Start there. Master those two. The other three principles will follow naturally as your codebase grows and your needs become clearer. Open/Closed and Liskov Substitution protect you from subtle bugs. Interface Segregation keeps your interfaces lean. But SRP and DIP are the foundation.

Common mistakes

  • Premature abstraction: Creating interfaces for classes that never have multiple implementations. You end up with UserServiceInterface and one concrete UserService—busywork.
  • Confusing SRP with granularity: Breaking a class into tiny pieces just to have one reason to change per class. Sometimes a class legitimately has two related reasons to change; that's fine.
  • Dependency injection everywhere: Every dependency is an injection point. But configuration and value objects don't need to be injected. Inject behavior and dependencies, not data.
  • Violating Liskov silently: Returning a different type from a subtype method (null instead of an empty list, undefined instead of a default value) breaks assumptions downstream. Keep contracts strict.
  • Over-segregating interfaces: Creating one-method interfaces everywhere. Interfaces should represent meaningful contracts, not individual methods. Three to five methods per interface is often the sweet spot.

Key takeaways

  • SOLID in one carousel gives you five principles that work together to reduce coupling and make code resilient to change.
  • Single Responsibility means one reason to change per class; Open/Closed means extend without editing; Liskov Substitution means subtypes honor the base contract; Interface Segregation means small, focused interfaces; Dependency Inversion means depend on abstractions.
  • SOLID is guidance, not law. Let pain guide your refactoring. Overengineering small projects wastes time.
  • The goal is always the same: modify one thing without breaking ten others. Loosely coupled code achieves that.
  • Start with SRP and DIP; they deliver the most value. The other three will follow as needs emerge.
  • Mistakes cluster around premature abstraction, confusing principles with cargo cult practices, and violating contracts silently. Keep it pragmatic.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles