DEV Community

Aishwarya B R
Aishwarya B R

Posted on

SOLID Principles: From Chaos to Clean Code

The God Class Problem

SOLID principles aren't about memorizing definitions or using fancy keywords. They're about writing code that doesn't make you want to cry when you revisit it six months later.

In today's age of "vibe coding" where AI generates hundreds of lines in seconds, we're out of excuses. We have the tools to write clean, testable code—we just need to prompt AI correctly. SOLID isn't optional anymore; it's the difference between shipping fast AND maintaining sanity.

Think of SOLID as five common-sense rules:

  • Single Responsibility: One class, one job
  • Open/Closed: Add features without breaking existing code
  • Liskov Substitution: Swap implementations without things exploding
  • Interface Segregation: Small, focused contracts
  • Dependency Inversion: Depend on abstractions, not concrete implementations

The Problem: A Real-World Nightmare

Here's a typical e-commerce order processor that violates every SOLID principle:

class OrderProcessor {
  constructor() {
    this.orders = [];
  }

  processOrder(customerData, items, paymentType, shippingAddress) {
    // Validates customer
    if (!customerData.name || !customerData.email) {
      throw new Error("Invalid customer");
    }

    // Calculates total
    let total = 0;
    for (let item of items) {
      total += item.price * item.quantity;
    }

    // Applies discount
    if (customerData.isPremium) {
      total *= 0.9;
    }

    // Processes payment
    if (paymentType === "credit") {
      console.log("Processing credit card...");
    } else if (paymentType === "paypal") {
      console.log("Processing PayPal...");
    } else if (paymentType === "bitcoin") {
      console.log("Processing Bitcoin...");
    }

    // Calculates shipping
    let shippingCost = 0;
    if (shippingAddress.country === "US") shippingCost = 10;
    else if (shippingAddress.country === "CA") shippingCost = 15;
    else shippingCost = 25;

    // Sends email
    console.log("Sending email to: " + customerData.email);

    // Saves to database
    this.orders.push({ customerData, items, total });

    // Generates invoice
    console.log("=== INVOICE ===");
    console.log("Total: $" + (total + shippingCost));
  }
}
Enter fullscreen mode Exit fullscreen mode

What's Wrong?

This class isn't just a God Class—it's the entire pantheon. It's doing validation, calculation, payment processing, shipping, emails, database operations, and invoice generation. Want to add Apple Pay? Touch this monolith. Want to test payments? Good luck mocking everything else.

Requirements always change. Usually at 4 PM on Friday. Right before a demo.

The reality: A "2-hour feature" becomes 6 hours because changing one thing breaks three others.

The Refactoring Journey

Attempt 1: Extract Methods (15% there)

class OrderProcessor {
  processOrder(data) {
    this.validateCustomer(data.customer);
    const total = this.calculateTotal(data.items);
    this.processPayment(total, data.paymentType);
  }

  validateCustomer(customer) { /* ... */ }
  calculateTotal(items) { /* ... */ }
  processPayment(total, type) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Problem: Still one giant class. Can't test parts independently. Just organized chaos.

Attempt 2: Separate Classes (30% there)

class CustomerValidator {
  validate(customer) { /* ... */ }
}

class PaymentProcessor {
  process(amount, type) {
    if (type === "credit") { /* ... */ }
    else if (type === "paypal") { /* ... */ }
  }
}
Enter fullscreen mode Exit fullscreen mode

Problem: PaymentProcessor still has that if-else chain. Adding new payment types means modifying this class.

Attempt 3: Abstractions! (85% there)

abstract class Payment {
  abstract processPayment(total: number): void;
}

class CreditPayment extends Payment {
  processPayment(total: number): void {
    console.log(`Processing credit card: $${total}`);
    if (total > 10000) throw new Error("Limit exceeded");
  }
}

class PayPalPayment extends Payment {
  processPayment(total: number): void {
    console.log(`Processing PayPal: $${total}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Win: Each payment type is its own class. Want to add Apple Pay? Create ApplePayPayment without touching existing code.

Apply the same pattern to shipping, discounts, and notifications:

abstract class ShippingCalculator {
  abstract calculateShippingCost(address: Address): number;
}

abstract class DiscountStrategy {
  abstract applyDiscount(total: number): number;
}

abstract class NotificationService {
  abstract sendNotification(message: string): void;
}
Enter fullscreen mode Exit fullscreen mode

The Final Solution

Here's the refactored architecture:

// Core abstractions
abstract class Payment {
  abstract processPayment(total: number): void;
}

abstract class ShippingCalculator {
  abstract calculateShippingCost(address: Address): number;
}

abstract class DiscountStrategy {
  abstract applyDiscount(total: number): number;
}

// Concrete implementations
class CreditPayment extends Payment {
  processPayment(total: number): void {
    console.log(`Processing credit card: $${total}`);
  }
}

class USShipping extends ShippingCalculator {
  calculateShippingCost(address: Address): number {
    return 10;
  }
}

class PremiumDiscount extends DiscountStrategy {
  applyDiscount(total: number): number {
    return total * 0.9;
  }
}

// The orchestrator (simplified)
class OrderProcessor {
  constructor(
    private validator: CustomerValidator,
    private calculator: OrderCalculator,
    private repository: OrderRepository,
    private invoiceGenerator: InvoiceGenerator
  ) {}

  processOrder(
    customerData: Customer,
    items: Item[],
    shippingAddress: Address,
    payment: Payment,
    shipping: ShippingCalculator,
    discount: DiscountStrategy,
    notification: NotificationService
  ): void {
    this.validator.validate(customerData);
    const subtotal = this.calculator.calculateTotal(items);
    const total = discount.applyDiscount(subtotal);
    const shippingCost = shipping.calculateShippingCost(shippingAddress);

    payment.processPayment(total + shippingCost);
    this.invoiceGenerator.generate(customerData, items, total, shippingCost);
    notification.sendNotification("Order confirmed!");
    this.repository.save({ customerData, items, total });
  }
}
Enter fullscreen mode Exit fullscreen mode

See the complete working solution with all implementations, tests, and examples:
👉 GitHub Repository

When NOT to Use SOLID

Don't over-engineer small projects:

  • Prototypes/MVPs: Ship fast, refactor later
  • Simple CRUD apps: Sometimes a God Class is fine for 3 endpoints
  • Scripts/utilities: A 50-line script doesn't need dependency injection

Start simple. Refactor when you feel pain (tests are hard, changes break things, adding features takes too long).

Quick Reference

Principle Question Red Flag Quick Fix
SRP Does this class have one reason to change? Multiple responsibilities Extract separate classes
OCP Can I add features without modifying code? Long if-else chains Use strategy pattern or polymorphism
LSP Can I swap implementations safely? No abstractions Define interfaces/base classes
ISP Am I forced to depend on unused methods? Fat interfaces Split into smaller, focused interfaces
DIP Do I depend on abstractions? Direct instantiation Inject dependencies

Using AI to Write SOLID Code

AI can generate clean code—you just need to prompt it correctly. Here's how:

The Prompt

You are a senior software engineer who follows SOLID principles strictly.

When writing code:
- Each class should have ONE responsibility
- Use abstractions (interfaces/abstract classes) instead of concrete implementations
- Apply dependency injection via constructors
- Use strategy pattern for conditional logic (no long if-else chains)
- Keep classes small and focused

For the following requirement, provide:
1. Interface/abstract class definitions
2. Concrete implementations
3. A simple orchestrator class
4. Usage example

Requirement: [YOUR REQUIREMENT HERE]
Enter fullscreen mode Exit fullscreen mode

Enforcing SOLID in Cursor/Windsurf

Create a .cursorrules or .windsurfrules file in your project root:

# Code Style Rules

## Architecture Principles
- Follow SOLID principles strictly
- Each class must have a single responsibility
- Use dependency injection via constructor
- Depend on abstractions, not concrete classes
- No God Classes - break down into focused classes

## Code Patterns
- Replace if-else chains with strategy pattern
- Use factory pattern for object creation
- Apply polymorphism over conditionals
- Keep methods under 20 lines
- Keep classes under 200 lines

## Testing Requirements
- All business logic must be testable
- Use dependency injection to enable mocking
- Write unit tests for each class independently

## Red Flags to Avoid
- Classes with 5+ methods doing unrelated things
- Methods with 3+ levels of nesting
- Direct instantiation of dependencies (use DI)
- Long parameter lists (>4 parameters suggests poor design)
Enter fullscreen mode Exit fullscreen mode

In Cursor/Windsurf, reference this during prompts:

@.cursorrules Refactor this code following our SOLID principles
Enter fullscreen mode Exit fullscreen mode

The AI will now automatically apply these constraints. No more excuses for messy code.

Key Takeaways

Before SOLID: One massive class doing everything. Adding Apple Pay means touching payment, validation, and database code.

After SOLID:

  • Want Apple Pay? Create ApplePayPayment class. Done.
  • Want SMS notifications? Create SMSNotification class. Done.
  • Want to test payments? Mock just the Payment interface. Done.

The goal isn't perfect architecture—it's code that adapts to change without breaking everything else.


Building an AI SaaS with clean architecture? I write about scalable patterns and practical refactoring. Follow me on Dev.to| GitHub

Top comments (0)