Skip to content

SOLID in a Real Project: From the Book to the Codebase

SOLID often stays an interview question. Applied well, though, adding a new payment provider becomes a half-day job that never touches existing code. We walk through the five principles with examples from a live project.

3 min readEngineering

We named the company after SOLID, so the question we hear most is: "Do these principles actually pay off, or are they just nice theory?" Short answer: the principles earn nothing on their own. What they earn is a lower cost of change. Most of a product's life comes after its first release, and the most expensive thing in that period is a "small change" breaking something far away.

The examples below are simplified from an order flow that takes payments.

S — Single responsibility: one reason to change

The first version of OrderService saved the order, charged the card, sent the invoice e-mail and decremented stock. When accounting wanted a new e-mail template, we had to open a file that also contained payment code.

The fix was to split classes by reason to change: OrderRepository owns persistence, PaymentGateway owns payments, InvoiceMailer owns e-mail. A template change now touches one file, and its tests are just as small.

O — Open/closed: open for extension, closed for modification

When the client wanted Stripe next to iyzico, adding if (provider === 'stripe') branches was the easy road. Instead we put payments behind an interface:

ts
interface PaymentGateway {
  charge(order: Order): Promise<Receipt>;
}

class IyzicoGateway implements PaymentGateway { /* ... */ }
class StripeGateway implements PaymentGateway { /* ... */ }

A new provider is a new class; the checkout flow itself does not change. The risky part is touching code that already works, and this structure avoids exactly that.

L — Liskov: a subtype keeps the parent's promise

A "test mode" gateway returned null on failure instead of throwing. The interface promised a Receipt; this class broke the promise, and a caller that did not check the result misbehaved silently in production. The rule is simple: every implementation must honour the same contract in the same way. We enforce it by running one shared test suite against every implementation.

I — Interface segregation: nobody depends on methods they do not use

Our original MediaStorage interface had upload, delete, signed URLs and image transforms. Even the read-only reporting service "knew" all of them. We split it into ObjectReader and ObjectWriter; each consumer sees only what it needs, and test doubles shrank to two lines.

D — Dependency inversion: business rules do not know the infrastructure

This one made the biggest difference. Our domain layer never imports Prisma, NestJS or S3; it only uses the repository and service interfaces it defines itself. Concrete classes live in the outer layer and are wired up at start-up.

The result: business-rule tests run in milliseconds without a database, and upgrading the database or swapping storage happens without touching the rules.

When is it too much?

SOLID is not bureaucracy. Three interfaces in a one-off script are waste. Our practical test:

  • If a second implementation is genuinely on the table, extract the interface now.

  • If a file changes at the request of two unrelated teams, split it.

  • If a unit test needs a real database, the dependency points the wrong way.

Good architecture is not measured by how elegant it looks on day one, but by how boring the change in year two turns out to be.

If you wonder why changes in your product take so long, we are happy to look at the codebase together.