SOLID Principles, Shown on One Class

October 10, 2026 · 10 min read

SOLID is five design principles for object-oriented code, collected by Robert C. Martin. They come up in every low-level design interview, and they are often taught as five separate definitions to memorise. They make more sense applied to one piece of code, one at a time.

Here is the starting point — an order service that does everything itself:

class OrderService {
  async place(order: Order) {
    if (order.items.length === 0) throw new Error('Empty order')
    if (order.total > 100_000) throw new Error('Needs approval')

    const card = new CardApiClient(process.env.CARD_KEY!)
    const receipt = await card.charge(order.total, order.cardToken)

    await db.query('INSERT INTO orders (id, total, receipt) VALUES ($1, $2, $3)',
      [order.id, order.total, receipt.id])

    await smtp.send(order.customer.email, 'Order placed', renderTemplate(order))
  }
}

It works. The trouble starts when things change. Step through the refactor below, one principle per step:

class OrderService {
constructor(
private repo: OrderRepository,
private payments: PaymentGateway,
private notifier: Notifier,
) {}
 
async place(order: Order) {
order.validate()
const receipt = await this.payments.charge(order.total)
await this.repo.save(order.paid(receipt))
await this.notifier.send(order.customer, 'Order placed')
}
}
 
interface PaymentGateway {
charge(amount: Money): Promise<Receipt> // or throws Declined
}
class CardGateway implements PaymentGateway { /* … */ }
class UpiGateway implements PaymentGateway { /* … */ }
 
interface Notifier {
send(to: Customer, message: string): Promise<void>
}
before
OrderService did
validation, card API calls, SQL, SMTP
reasons to change4
S — single responsibility
OrderService nowcoordinates the steps
rules live inOrder.validate()

S: a class should have one reason to change. The old OrderService changed whenever tax rules, the card provider, the schema or the email template changed. Now it only sequences the steps; each step lives with the object that owns it.

0 / 4

S — Single responsibility

A class should have one reason to change.

"One responsibility" is vague. "One reason to change" is testable: list the people or events that would make you edit this class. The original OrderService changes when the business rules change, when the card provider changes, when the schema changes, and when marketing rewrites the email. Four reasons, four different teams editing one file.

The fix is not "one method per class". It is to move each concern to the object that owns it: validation to Order, persistence to a repository, charging to a gateway. OrderService keeps the one thing that is genuinely its own — the order of the steps.

O — Open/closed

Open for extension, closed for modification.

Adding UPI payments to the original means another branch:

if (order.method === 'card') { /* … */ }
else if (order.method === 'upi') { /* … */ }   // edit, re-test, redeploy

With a PaymentGateway interface, a new method is a new class. Existing, tested code isn't touched. The service picks a gateway by method, and adding one is a registration, not an edit:

const gateways: Record<PaymentMethod, PaymentGateway> = {
  card: new CardGateway(cardClient),
  upi: new UpiGateway(upiClient),          // the whole change for UPI
}
const receipt = await gateways[order.method].charge(order.total, order.token)

With PaymentMethod as a union type, TypeScript also refuses to compile until every method has a gateway — the compiler checks the extension for you. That is the whole idea: put the parts that will vary behind an interface, so variation means adding code rather than editing it.

Note the word will. You can't make code closed against every possible change, and trying produces interfaces with one implementation each. Apply it where you can already see a second case coming.

L — Liskov substitution

Subtypes must be usable through the base type without the caller knowing.

This is about contracts, not just type signatures. If PaymentGateway.charge promises "returns a receipt, or throws Declined", every implementation has to keep that promise. A gateway that returns null on failure compiles fine and still breaks every caller.

The classic violation is the square that extends a rectangle: setting a square's width also changes its height, so code that sets width and height separately on "a rectangle" gets the wrong area. The smell in real code is instanceof checks: if callers have to ask which subtype they got, the subtypes aren't substitutable. The fix is rarely a cleverer subclass. Usually it's admitting the two types don't share a contract — a square and a rectangle can both be Shapes with an area() method without one extending the other — or moving the varying behaviour behind a strategy instead of an override.

I — Interface segregation

Clients shouldn't depend on methods they don't use.

OrderService sends one message. If it depended on a CustomerComms interface with send, sendBulk, unsubscribe and template management, then every fake in its tests would need all of those, and every change to bulk mail would show up as a change to its dependency. Small interfaces, shaped around what the caller needs, keep unrelated changes apart.

D — Dependency inversion

High-level policy shouldn't depend on low-level details. Both depend on abstractions.

The original constructs CardApiClient itself and imports the database module. The refactored version receives its collaborators:

// main.ts — the one place that knows about concrete classes
const service = new OrderService(
  new PostgresOrderRepository(pool),
  new CardGateway(process.env.CARD_KEY!),
  new EmailNotifier(smtp),
)

// order-service.test.ts
const service = new OrderService(new InMemoryRepo(), new FakeGateway(), new SpyNotifier())

The "inversion" is in the direction of the dependency: the low-level CardGateway now depends on an interface that the high-level module defines, instead of the high-level module depending on the card SDK.

The refactored service, whole

After all five steps, the service reads like a description of the business process, with every detail behind something it can name:

interface OrderRepository { save(order: Order, receipt: Receipt): Promise<void> }
interface PaymentGateway  { charge(amount: number, token: string): Promise<Receipt> }  // throws Declined
interface Notifier        { orderPlaced(order: Order): Promise<void> }

class Order {
  validate() {
    if (this.items.length === 0) throw new EmptyOrderError()
    if (this.total > 100_000) throw new NeedsApprovalError()
  }
}

class OrderService {
  constructor(
    private orders: OrderRepository,
    private payments: PaymentGateway,
    private notifier: Notifier,
  ) {}

  async place(order: Order) {
    order.validate()
    const receipt = await this.payments.charge(order.total, order.cardToken)
    await this.orders.save(order, receipt)
    await this.notifier.orderPlaced(order)
  }
}

Count the reasons to change now. A new payment provider: a new PaymentGateway class. A schema change: the repository. A new email: the notifier. A new validation rule: Order. OrderService changes only if the sequence of placing an order changes — its one responsibility.

Notice also what didn't happen: no AbstractOrderServiceFactory, no interface for Order, no generic Handler<T>. Three interfaces, each at a boundary with an external system. That's usually the right amount.

Liskov, in real code

The rectangle-and-square example is famous and a bit abstract. A more realistic violation:

interface Cache {
  get(key: string): Promise<string | undefined>
  set(key: string, value: string, ttlSeconds: number): Promise<void>
}

class RedisCache implements Cache { /* honours the TTL */ }

class NoopCache implements Cache {           // fine: always a miss, never stores
  async get() { return undefined }
  async set() {}
}

class LocalCache implements Cache {
  private map = new Map<string, string>()
  async get(k: string) { return this.map.get(k) }
  async set(k: string, v: string) { this.map.set(k, v) }   // ignores ttlSeconds!
}

All three compile. NoopCache is substitutable: callers must already handle misses. LocalCache isn't: callers rely on entries expiring — a session cache that never expires is a security bug — and nothing in the type system says so. Liskov is about the promises in a contract, including the ones only written in comments and docs. Tests written against the interface and run against every implementation are the practical way to enforce it.

Interface segregation, in real code

A repository interface that grew with every feature:

interface UserRepository {
  findById(id: string): Promise<User>
  findByEmail(email: string): Promise<User>
  save(user: User): Promise<void>
  exportAllAsCsv(): Promise<Buffer>        // added for the admin panel
  rebuildSearchIndex(): Promise<void>      // added for a migration
}

The login handler needs one method. Its test fake must implement five, and a change to CSV export recompiles and re-tests login. Split by client:

interface UserReader { findById(id: string): Promise<User>; findByEmail(email: string): Promise<User> }
interface UserWriter { save(user: User): Promise<void> }
interface UserExport { exportAllAsCsv(): Promise<Buffer> }

class PostgresUsers implements UserReader, UserWriter, UserExport { /* … */ }

One class can still implement all of them; what changes is what each caller depends on. In TypeScript you don't even need named interfaces — Pick<UserRepository, 'findByEmail'> as a parameter type gives the same narrowing.

Dependency inversion is not dependency injection

The two are related and often confused:

  • Dependency injection is a technique: pass collaborators in instead of constructing them inside. See dependency injection.
  • Dependency inversion is a principle about which way source-code dependencies point: the interface belongs to the high-level module that uses it, and the low-level module conforms to it.

You can inject a concrete CardApiClient and still depend on the card SDK's types throughout OrderService — injection without inversion. The test for inversion is in the imports: does order-service.ts import anything from the payment provider's package? If it does, the high-level policy still depends on the detail.

SOLID without classes

The principles are about dependencies and change, not about the class keyword. In a codebase of modules and functions:

  • Single responsibility — a module with one reason to change.
  • Open/closed — a function that takes a strategy function, or a map of handlers keyed by type, instead of a growing switch.
  • Liskov — any function passed where a type (order: Order) => Promise<Receipt> is expected must honour the same contract.
  • Interface segregation — functions that take the narrow object they need ({ email: string }), not the whole User.
  • Dependency inversion — the core module defines the function types it needs; the edges of the app pass in implementations.

The vocabulary transfers almost unchanged, which is a good sign the ideas are about structure rather than syntax.

Smells and the principle behind them

you notice…principleusual fix
one file edited by unrelated featuresSingle responsibilitymove each concern to the object that owns it
a growing if/else or switch on a typeOpen/closedan interface and one class per case
instanceof checks on an abstractionLiskovfix the contract, or stop pretending they share one
test fakes full of methods that throw "not implemented"Interface segregationsplit the interface by client
business logic importing an SDK or ORMDependency inversiondefine an interface in the core; implement at the edge

When following them makes code worse

Each principle has a characteristic way of being overdone:

  • Single responsibility, overdone: a feature split into fifteen one-method classes, so reading one request means opening fifteen files. Responsibility is about reasons to change; pieces that always change together belong together.
  • Open/closed, overdone: an interface, a factory and a registry for a behaviour that has exactly one implementation and no second one in sight. You paid the indirection cost and bought nothing.
  • Liskov, overdone: refusing any inheritance at all for fear of breaking it. Plenty of hierarchies — an HttpError with NotFoundError and ConflictError subclasses — are perfectly substitutable.
  • Interface segregation, overdone: one interface per method, so every class implements eight tiny interfaces and nobody can see what a type actually does.
  • Dependency inversion, overdone: abstracting the standard library or your own pure functions. interface IDateProvider is worth it for a clock you need to fake in tests; interface IStringUtils is not.

The counterweights are YAGNI ("you aren't gonna need it") and plain readability. A design is good when the likely changes are cheap — not every conceivable one.

How to use them

SOLID principles are heuristics for managing change, not rules to satisfy. Each one costs something — more types, more indirection, more files to read to follow one request. They pay off in proportion to how much the code changes.

  • Use them to explain a smell, not to generate structure. "This class has three reasons to change" is a useful review comment. "This class violates SRP" without saying why isn't.
  • Wait for the second case before adding an interface, unless the boundary is an external system (payments, email, storage) — those are worth abstracting early because you'll want fakes in tests.
  • In an interview, name the principle when you make a decision, not as a checklist at the end: "I'll put pricing behind an interface because weekend pricing is already a second rule."
  • In a code review, tie the comment to a concrete future change: "when we add UPI, this if grows another branch — could the gateway be passed in?" is persuasive in a way that naming the principle alone isn't.
  • When refactoring legacy code, apply them one seam at a time. Extract the payment call behind an interface first, add tests through it, then move on. A big-bang "make it SOLID" rewrite rarely lands.

Try it on a real problem: the LLD practice tool scores designs on responsibilities and extensibility, which is most of SOLID in practice.