Claude Code for SOLID Principles: Applying Design Patterns in Practice — Claude Skills 360 Blog
Blog / Architecture / Claude Code for SOLID Principles: Applying Design Patterns in Practice
Architecture

Claude Code for SOLID Principles: Applying Design Patterns in Practice

Published: October 16, 2026
Read time: 8 min read
By: Claude Skills 360

SOLID principles guide the design of maintainable object-oriented code, but they’re often described abstractly. The real value is knowing when applying them reduces complexity — and when it adds unnecessary abstraction. Claude Code applies SOLID principles to real codebases: identifying violations, proposing concrete refactors, and explaining the tradeoff between purity and pragmatism.

Single Responsibility: One Reason to Change

This UserService is doing too much. Identify violations and refactor.
// Before: UserService has 5 responsibilities
class UserService {
  async register(email: string, password: string) {
    // 1. Validates input
    if (!email.includes('@')) throw new Error('Invalid email');
    if (password.length < 8) throw new Error('Password too short');
    
    // 2. Hashes password
    const hash = await bcrypt.hash(password, 12);
    
    // 3. Writes to database
    const user = await db('users').insert({ email, password_hash: hash }).returning('*');
    
    // 4. Sends welcome email
    await sendgrid.send({
      to: email,
      subject: 'Welcome!',
      text: 'Thanks for registering.',
    });
    
    // 5. Tracks analytics event
    await analytics.track({ event: 'user_registered', userId: user[0].id });
    
    return user[0];
  }
}

// After: each class has one reason to change
class UserRepository {
  async create(email: string, passwordHash: string): Promise<User> {
    const [user] = await db('users').insert({ email, password_hash: passwordHash }).returning('*');
    return user;
  }
  
  async findByEmail(email: string): Promise<User | null> {
    return db('users').where({ email }).first() ?? null;
  }
}

class PasswordService {
  async hash(plaintext: string): Promise<string> {
    return bcrypt.hash(plaintext, 12);
  }
  
  async verify(plaintext: string, hash: string): Promise<boolean> {
    return bcrypt.compare(plaintext, hash);
  }
}

class RegistrationService {
  constructor(
    private userRepo: UserRepository,
    private passwordService: PasswordService,
    private emailService: EmailService,
    private analyticsService: AnalyticsService,
  ) {}
  
  async register(email: string, password: string): Promise<User> {
    this.validateInput(email, password);
    
    const existing = await this.userRepo.findByEmail(email);
    if (existing) throw new ConflictError('Email already registered');
    
    const passwordHash = await this.passwordService.hash(password);
    const user = await this.userRepo.create(email, passwordHash);
    
    // Fire-and-forget: don't let non-critical failures block registration
    this.emailService.sendWelcome(user).catch(e => logger.warn('Welcome email failed', e));
    this.analyticsService.track('user_registered', { userId: user.id }).catch(() => {});
    
    return user;
  }
  
  private validateInput(email: string, password: string) {
    if (!email.includes('@')) throw new ValidationError('Invalid email');
    if (password.length < 8) throw new ValidationError('Password must be 8+ characters');
  }
}

Open-Closed: Open for Extension, Closed for Modification

We need to add new payment processors without modifying existing code.
// Violation: switch statement that grows with every new processor
async function processPayment(type: string, amount: number, details: any) {
  switch (type) {
    case 'stripe': return await chargeStripe(amount, details.cardId);
    case 'paypal': return await chargePayPal(amount, details.email);
    case 'crypto': return await chargeCrypto(amount, details.walletAddress);
    // Adding new processor requires modifying this function
  }
}

// Fix: Define the interface, each processor implements it
interface PaymentProcessor {
  readonly name: string;
  charge(amount: number, details: unknown): Promise<PaymentResult>;
  refund(chargeId: string, amount: number): Promise<RefundResult>;
}

class StripeProcessor implements PaymentProcessor {
  readonly name = 'stripe';
  
  async charge(amount: number, details: { cardId: string }): Promise<PaymentResult> {
    const charge = await stripe.charges.create({
      amount,
      currency: 'usd',
      customer: details.cardId,
    });
    return { chargeId: charge.id, status: 'success' };
  }
  
  async refund(chargeId: string, amount: number): Promise<RefundResult> {
    await stripe.refunds.create({ charge: chargeId, amount });
    return { refundId: chargeId, status: 'refunded' };
  }
}

// Registry: add new processors without modifying existing code
class PaymentProcessorRegistry {
  private processors = new Map<string, PaymentProcessor>();
  
  register(processor: PaymentProcessor) {
    this.processors.set(processor.name, processor);
  }
  
  get(name: string): PaymentProcessor {
    const processor = this.processors.get(name);
    if (!processor) throw new Error(`Unknown processor: ${name}`);
    return processor;
  }
}

const registry = new PaymentProcessorRegistry();
registry.register(new StripeProcessor());
registry.register(new PayPalProcessor());
// Adding CryptoProcessor doesn't touch existing code
registry.register(new CryptoProcessor());

Liskov Substitution: Subclasses Must Be Substitutable

// Violation: subclass narrows the contract (Rectangle → Square breaks width/height independence)
class Rectangle {
  constructor(public width: number, public height: number) {}
  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
  area() { return this.width * this.height; }
}

class Square extends Rectangle {
  // Violates LSP: callers expect width and height to be independent
  setWidth(w: number) { this.width = w; this.height = w; }
  setHeight(h: number) { this.width = h; this.height = h; }
}

function doubleWidth(shape: Rectangle) {
  shape.setWidth(shape.width * 2);
  // For Rectangle: area doubles. For Square: area quadruples. Surprise!
  console.log(shape.area());
}

// Fix: use composition or separate interfaces
interface Shape {
  area(): number;
  perimeter(): number;
}

class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  area() { return this.width * this.height; }
  perimeter() { return 2 * (this.width + this.height); }
}

class Square implements Shape {
  constructor(private side: number) {}
  area() { return this.side * this.side; }
  perimeter() { return 4 * this.side; }
}

Interface Segregation: Don’t Force Unused Methods

// Violation: fat interface forces all implementors to implement everything
interface UserRepository {
  findById(id: string): Promise<User>;
  findByEmail(email: string): Promise<User | null>;
  create(data: CreateUserData): Promise<User>;
  update(id: string, data: Partial<User>): Promise<User>;
  delete(id: string): Promise<void>;
  findAll(filters: UserFilters): Promise<User[]>;
  count(filters: UserFilters): Promise<number>;
  // A read-only service shouldn't need create/update/delete
  bulkDelete(ids: string[]): Promise<void>;
  exportCSV(filters: UserFilters): Promise<Buffer>;
}

// Fix: segregate by consumer need
interface UserReader {
  findById(id: string): Promise<User>;
  findByEmail(email: string): Promise<User | null>;
}

interface UserWriter {
  create(data: CreateUserData): Promise<User>;
  update(id: string, data: Partial<User>): Promise<User>;
  delete(id: string): Promise<void>;
}

interface UserQuery {
  findAll(filters: UserFilters): Promise<User[]>;
  count(filters: UserFilters): Promise<number>;
}

// Concrete implementation can implement all
class PostgresUserRepository implements UserReader, UserWriter, UserQuery {
  // All methods
}

// Read-only service only depends on what it needs
class UserProfileService {
  constructor(private users: UserReader) {}  // Not all of UserRepository
}

Dependency Inversion: Depend on Abstractions

// Violation: high-level policy depends on low-level details
class OrderService {
  // Directly coupled to a specific logger implementation
  private logger = new WinstonLogger({ level: 'info' });
  
  // Directly coupled to specific HTTP client
  private http = axios.create({ baseURL: 'https://inventory.api.com' });
  
  async fulfillOrder(orderId: string) {
    this.logger.info(`Fulfilling order ${orderId}`);
    const response = await this.http.post('/reserve', { orderId });
    return response.data;
  }
}

// Fix: depend on interfaces, inject implementations
interface Logger {
  info(message: string, context?: object): void;
  error(message: string, error?: Error): void;
}

interface InventoryClient {
  reserveItems(orderId: string): Promise<ReservationResult>;
}

class OrderService {
  constructor(
    private readonly logger: Logger,              // Injected
    private readonly inventory: InventoryClient,  // Injected
    private readonly orderRepo: OrderReader,      // Injected
  ) {}
  
  async fulfillOrder(orderId: string): Promise<FulfillmentResult> {
    this.logger.info('Fulfilling order', { orderId });
    
    const order = await this.orderRepo.findById(orderId);
    if (!order) throw new NotFoundError(`Order ${orderId} not found`);
    
    const reservation = await this.inventory.reserveItems(orderId);
    return { orderId, reservationId: reservation.id };
  }
}

// In tests: inject mocks
const mockLogger: Logger = { info: jest.fn(), error: jest.fn() };
const mockInventory: InventoryClient = { reserveItems: jest.fn().mockResolvedValue({ id: 'rsv-1' }) };
const service = new OrderService(mockLogger, mockInventory, mockOrderRepo);

// In production: inject real implementations
const service = new OrderService(new WinstonLogger(), new HttpInventoryClient(config), repo);

When to Break the Rules

I have a simple 3-endpoint CRUD API for a settings page.
Should I apply all SOLID principles?

SOLID adds indirection. For simple CRUD with no anticipated variations, applying all five principles:

  • SRP: Extracting a separate SettingsRepository from SettingsService when there’s only one storage backend adds files without reducing complexity.
  • OCP: If you have two payment processors now and one in 18 months, use the strategy pattern. For one processor and no concrete plans for more, skip the registry.
  • DIP: If a module is never tested in isolation and its collaborators never change, injecting dependencies adds noise.

Rule of thumb: SOLID pays off when code needs to be tested in isolation, when implementations change, or when the codebase is large enough that unexpected changes cause cascading failures. For small, stable, simple modules — straightforward code beats elegant abstractions.

For the refactoring patterns that apply SOLID to existing code safely, see the monolith-to-microservices guide for large-scale restructuring. For testing the components after applying dependency inversion, the TypeScript testing guide covers mocking injected dependencies. The Claude Skills 360 bundle includes architecture skill sets covering SOLID principles, design patterns, and the judgment for when to apply them. Start with the free tier to try architecture analysis prompts.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free