#SOLID#Design Patterns#TypeScript#Software Architecture#Code Quality

SOLID Principles Revisited: A Practical TypeScript Guide for 2026

webhani·

Why SOLID still matters when code volume is exploding

With AI coding assistants now generating a large share of everyday boilerplate, the volume of code flowing into a typical TypeScript codebase has gone up, but the amount of human review time per line has gone down. That combination makes structural discipline more important, not less. SOLID isn't a checklist you memorize for interviews — it's a set of failure patterns you learn to recognize, whether the code in front of you was written by a teammate or generated by a prompt.

We see the same five violations recur across client codebases regardless of who or what wrote the code. Here's each principle with the shape of the problem and a concrete refactor.

Single Responsibility: one reason to change

The classic smell is a service class that started small and absorbed responsibilities one PR at a time.

// Before: OrderService knows about persistence, pricing, and notifications
class OrderService {
  async placeOrder(order: Order) {
    const total = order.items.reduce((sum, i) => sum + i.price * i.qty, 0);
    const tax = total * 0.1;
    await db.orders.insert({ ...order, total: total + tax });
    await emailClient.send(order.customerEmail, "Order confirmed");
    await analytics.track("order_placed", { orderId: order.id });
  }
}

Any change to tax logic, email copy, or analytics events touches this same class. Split by reason to change, not by "related-sounding" methods:

class PricingCalculator {
  calculateTotal(order: Order): number { /* ... */ }
}
 
class OrderRepository {
  async save(order: Order): Promise<void> { /* ... */ }
}
 
class OrderService {
  constructor(
    private pricing: PricingCalculator,
    private repo: OrderRepository,
    private notifier: OrderNotifier
  ) {}
 
  async placeOrder(order: Order) {
    const total = this.pricing.calculateTotal(order);
    await this.repo.save({ ...order, total });
    await this.notifier.orderPlaced(order);
  }
}

Now a pricing rule change can't accidentally break the notification flow.

Open/Closed: extend without editing

An if/else chain that grows every time a new integration is added is the most common Open/Closed violation we see in payment and shipping code:

// Before: adding a provider means editing this function again
function calculateShipping(provider: string, weight: number): number {
  if (provider === "fedex") return weight * 2.5;
  if (provider === "ups") return weight * 2.3;
  if (provider === "dhl") return weight * 2.8;
  throw new Error("Unknown provider");
}

A strategy map closes the function to modification while staying open to extension:

interface ShippingStrategy {
  calculate(weight: number): number;
}
 
const shippingStrategies: Record<string, ShippingStrategy> = {
  fedex: { calculate: (w) => w * 2.5 },
  ups: { calculate: (w) => w * 2.3 },
  dhl: { calculate: (w) => w * 2.8 },
};
 
function calculateShipping(provider: string, weight: number): number {
  const strategy = shippingStrategies[provider];
  if (!strategy) throw new Error("Unknown provider");
  return strategy.calculate(weight);
}

Adding a new carrier is now a new entry, not a diff to existing tested logic.

Liskov Substitution: subtypes must honor the contract

This one is subtle because TypeScript's structural typing won't catch it — the code compiles fine and breaks at runtime.

class Rectangle {
  constructor(protected width: number, protected height: number) {}
  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
  area() { return this.width * this.height; }
}
 
// Looks reasonable, breaks the contract silently
class Square extends Rectangle {
  setWidth(w: number) { this.width = w; this.height = w; }
  setHeight(h: number) { this.width = h; this.height = h; }
}
 
function testArea(r: Rectangle) {
  r.setWidth(5);
  r.setHeight(4);
  console.assert(r.area() === 20); // fails for Square
}

If a subtype has to override every setter to keep two fields in sync, it's not actually a subtype of the base class's behavior — it's a different concept wearing the base class's interface. The fix is usually to stop modeling it as inheritance and use a shared interface with independent implementations instead.

Interface Segregation: don't force unused methods

Fat interfaces push implementers to write dead code or throw NotImplementedError, which is a strong signal the interface is doing too much.

// Before: every worker must implement every capability
interface Worker {
  code(): void;
  attendMeeting(): void;
  fileTaxes(): void;
}
 
class RobotWorker implements Worker {
  code() { /* ... */ }
  attendMeeting() { throw new Error("Robots don't attend meetings"); }
  fileTaxes() { throw new Error("Not applicable"); }
}

Split by capability instead, and implement only what applies:

interface Codeable { code(): void; }
interface MeetingAttendee { attendMeeting(): void; }
 
class RobotWorker implements Codeable {
  code() { /* ... */ }
}

Dependency Inversion: depend on interfaces, not concrete classes

The last one is what makes the other four testable in practice. A service that instantiates its own dependencies can't be unit tested without spinning up the real thing.

// Before: hard-coded to a concrete Postgres client
class UserService {
  private db = new PostgresClient(connectionString);
  async getUser(id: string) {
    return this.db.query("SELECT * FROM users WHERE id = $1", [id]);
  }
}
 
// After: depends on an abstraction, injected at the boundary
interface UserRepository {
  findById(id: string): Promise<User | null>;
}
 
class UserService {
  constructor(private repo: UserRepository) {}
  async getUser(id: string) {
    return this.repo.findById(id);
  }
}

Now UserService can be tested with an in-memory fake, and swapping databases doesn't touch business logic.

Our recommendations

  • Use SOLID as a code review lens, not a rewrite mandate. Retrofitting all five principles into a stable codebase in one pass creates more risk than it removes. Apply them incrementally, at the point where a file is already being touched.
  • Treat AI-generated code with the same scrutiny. Generated code tends to reproduce the Single Responsibility and Open/Closed violations most often, since prompts are usually scoped to "make this feature work," not "keep this class focused."
  • Prioritize Dependency Inversion first if your test suite is thin. It has the highest leverage — untestable code is usually a Dependency Inversion problem before it's anything else.
  • Don't force interfaces where there's only one implementation. Interface Segregation and Dependency Inversion are means to an end, not requirements for every class in the codebase.

Takeaways

SOLID hasn't changed, but the context it operates in has: more code is being written faster, with less line-by-line human attention. The five principles remain a reliable way to catch structural problems before they compound — not because they're dogma, but because each one maps to a concrete failure mode we keep seeing in production codebases, generated or hand-written alike.