SOLID Principles in TypeScript: A Practical Backend Example
Learn SOLID principles in TypeScript by refactoring a checkout service into smaller, testable, and easier-to-change components.
22 min read
A Checkout Service That Became Too Complicated
Imagine we are building an online store.
At first, placing an order sounds simple:
User places order
→ calculate price
→ charge payment
→ save order
→ send confirmationSo we create one CheckoutService:
class CheckoutService {
async placeOrder(input: PlaceOrderInput) {
const subtotalMinor = input.items.reduce(
(total, item) => total + item.unitPriceMinor * item.quantity,
0,
);
let totalMinor = subtotalMinor;
if (input.customerType === "premium") {
totalMinor = Math.round(subtotalMinor * 0.9);
}
const payment = await stripe.paymentIntents.create({
amount: totalMinor,
currency: "usd",
payment_method: input.paymentMethodId,
confirm: true,
});
await db.query(
"INSERT INTO orders (customer_id, total_minor, payment_id) VALUES ($1, $2, $3)",
[input.customerId, totalMinor, payment.id],
);
await email.send(input.email, "Order confirmed");
await audit.write("order.placed", {
customerId: input.customerId,
});
return {
paymentId: payment.id,
};
}
}This works.
But the service is doing many different things:
CheckoutService
├── Calculate subtotal
├── Apply discount
├── Charge Stripe
├── Save to PostgreSQL
├── Send email
└── Write audit logNow imagine the requirements start changing.
We need:
- employee discounts
- seasonal discounts
- another payment provider
- a different database
- SMS notifications
- better payment retries
- easier unit tests
Suddenly, almost every change touches CheckoutService.
That is the problem SOLID tries to solve.
SOLID is not about creating as many classes and interfaces as possible.
It is about making code easier to:
change
test
replace
extend
understandThe five principles are:
| Principle | Main question |
|---|---|
| SRP | Is this class responsible for too many things? |
| OCP | Can I add new behavior without rewriting stable code? |
| LSP | Can I safely replace one implementation with another? |
| ISP | Does this class depend only on the methods it actually needs? |
| DIP | Does business logic depend directly on infrastructure? |
We will refactor the same checkout example step by step.
Start With Clear Types
Before discussing SOLID, define the important data clearly.
type Currency = "USD";
type OrderItem = {
productId: string;
unitPriceMinor: number;
quantity: number;
};
type PlaceOrderInput = {
customerId: string;
customerType: "standard" | "premium";
email: string;
paymentMethodId: string;
items: OrderItem[];
};
type Order = {
id: string;
customerId: string;
email: string;
items: OrderItem[];
subtotalMinor: number;
totalMinor: number;
currency: Currency;
paymentId: string;
status: "paid";
};Notice that money is stored as an integer:
1099;instead of:
10.99;For USD:
1099 cents = $10.99Using integer minor units avoids floating-point problems.
For example:
0.1 + 0.2;does not produce exactly 0.3 in JavaScript.
For payment systems, storing:
1099 centsis usually safer than storing:
10.99 dollarsS — Single Responsibility Principle
What Does SRP Mean?
Single Responsibility Principle means:
A class should have one main responsibility and one main reason to change.
It does not mean:
one class = one methodThe idea is to avoid putting unrelated responsibilities in the same place.
The Problem
Our original service does all of this:
CheckoutService
Pricing
Discounts
Payments
Database
Email
Audit loggingThese responsibilities change for different reasons.
For example:
Finance team
→ changes discount rules
Payment team
→ changes Stripe integration
Database team
→ changes schema
Product team
→ changes confirmation emailBut all of those changes currently affect the same class.
That is a sign that the class has too many responsibilities.
Refactor the Price Calculation
We can move subtotal calculation into its own class:
class OrderTotalCalculator {
calculateSubtotal(items: OrderItem[]): number {
if (items.length === 0) {
throw new Error("An order must contain at least one item");
}
return items.reduce((total, item) => {
if (
!Number.isSafeInteger(item.unitPriceMinor) ||
item.unitPriceMinor < 0
) {
throw new Error("Unit price must be a non-negative integer");
}
if (!Number.isSafeInteger(item.quantity) || item.quantity <= 0) {
throw new Error("Quantity must be a positive integer");
}
return total + item.unitPriceMinor * item.quantity;
}, 0);
}
}Now this class has one clear job:
OrderTotalCalculator
↓
Calculate order subtotalIt does not know about:
Stripe
PostgreSQL
Email
HTTP
AuthenticationWhy Is This Better?
We can test it easily:
const calculator = new OrderTotalCalculator();
const subtotal = calculator.calculateSubtotal([
{
productId: "book",
unitPriceMinor: 2500,
quantity: 2,
},
]);
console.log(subtotal);Result:
5000The class can also be reused anywhere we need order calculations.
Most importantly:
Pricing changes
↓
OrderTotalCalculator changes
Stripe changes
↓
OrderTotalCalculator does NOT changeThat is the main idea behind SRP.
O — Open/Closed Principle
What Does OCP Mean?
Open/Closed Principle means:
Code should be open for extension but closed for unnecessary modification.
In simpler words:
Adding new behavior should not require rewriting stable code again and again.The Problem
Suppose discounts are implemented like this:
function applyDiscount(
subtotalMinor: number,
customerType: PlaceOrderInput["customerType"],
) {
if (customerType === "premium") {
return Math.round(subtotalMinor * 0.9);
}
return subtotalMinor;
}Currently we have:
standard
premiumLater we may add:
employee
partner
vip
seasonal
black-fridayThe function can quickly become:
if (type === "premium") {
// ...
} else if (type === "employee") {
// ...
} else if (type === "partner") {
// ...
} else if (type === "vip") {
// ...
}Every new discount requires modifying the existing pricing logic.
Use a Strategy Instead
First define what a discount must be able to do:
interface DiscountPolicy {
apply(subtotalMinor: number): number;
}Then create different strategies.
No discount:
class NoDiscountPolicy implements DiscountPolicy {
apply(subtotalMinor: number): number {
return subtotalMinor;
}
}Percentage discount:
class PercentageDiscountPolicy implements DiscountPolicy {
constructor(private readonly percentage: number) {
if (percentage < 0 || percentage > 100) {
throw new Error("Discount percentage must be between 0 and 100");
}
}
apply(subtotalMinor: number): number {
return Math.round((subtotalMinor * (100 - this.percentage)) / 100);
}
}Now premium customers can receive:
new PercentageDiscountPolicy(10);Meaning:
10% discountIf later we need a fixed discount:
class FixedDiscountPolicy implements DiscountPolicy {
constructor(private readonly amountMinor: number) {}
apply(subtotalMinor: number): number {
return Math.max(0, subtotalMinor - this.amountMinor);
}
}We added a new type of discount without changing the existing discount implementations.
Selecting the Strategy
We still need to decide which strategy to use:
function discountFor(
customerType: PlaceOrderInput["customerType"],
): DiscountPolicy {
if (customerType === "premium") {
return new PercentageDiscountPolicy(10);
}
return new NoDiscountPolicy();
}Notice something important.
We did not completely remove the conditional.
We moved it.
Before:
Checkout business workflow
↓
knows every discount ruleAfter:
Application setup
↓
chooses discount strategy
Checkout workflow
↓
just uses DiscountPolicyThe stable checkout workflow no longer cares which discount algorithm is being used.
This is also an example of the Strategy design pattern.
L — Liskov Substitution Principle
LSP is usually the most confusing SOLID principle.
The simple version is:
If two classes implement the same contract, the caller should be able to use either one without unexpected behavior.
A Bad Payment Interface
Imagine this interface:
interface PaymentGateway {
collect(amountMinor: number): Promise<string>;
refund(paymentId: string): Promise<void>;
}Stripe supports both:
collect payment
refund paymentBut imagine we also create:
class CashOnDeliveryGateway implements PaymentGateway {
async collect(amountMinor: number): Promise<string> {
return "fake-payment-id";
}
async refund(paymentId: string): Promise<void> {
throw new Error("Refund not supported");
}
}TypeScript says this is valid because the methods exist.
But logically it is broken.
Cash on delivery has not actually collected payment.
And refund is unsupported.
Why Is That a Problem?
Now the caller needs special checks:
if (gateway instanceof CashOnDeliveryGateway) {
// special logic
}At this point our abstraction has failed.
The whole reason for having:
PaymentGateway;was so callers would not care which concrete provider they were using.
Create an Honest Contract
For checkout we only need immediate payment collection.
Define exactly that:
type CollectPaymentInput = {
orderId: string;
amountMinor: number;
currency: Currency;
paymentMethodId: string;
};
type PaymentReceipt = {
paymentId: string;
};
class PaymentDeclinedError extends Error {}
interface PaymentCollector {
collect(input: CollectPaymentInput): Promise<PaymentReceipt>;
}The contract means:
collect(...)
↓
payment was successfully collected
↓
return PaymentReceiptIf payment is declined:
throw PaymentDeclinedErrorNow both Stripe and another online payment provider can implement it.
For example:
StripePaymentCollector
PayPalPaymentCollector
AdyenPaymentCollectorAs long as all of them follow the same behavior.
What About Cash on Delivery?
Cash on delivery should not implement PaymentCollector.
Because:
Online payment
→ money is collected during checkout
Cash on delivery
→ money is NOT collected during checkoutThose are different business workflows.
Trying to force both into the same abstraction would create a dishonest interface.
Easy Way to Remember LSP
Interface says:
"I promise I can do X."
Every implementation must genuinely
be able to do X.Not:
"I technically have the method,
but sometimes it throws
'not supported'."That is the core idea.
OCP vs LSP
These two principles are related but different.
OCP:
Can I add another PaymentCollector
without rewriting checkout?
LSP:
Can checkout safely use every
PaymentCollector implementation?OCP is about extension.
LSP is about trusting implementations.
I — Interface Segregation Principle
What Does ISP Mean?
Interface Segregation Principle means:
A consumer should not depend on methods it does not use.
In simpler words:
Do not create giant interfaces.The Problem
Suppose we create:
interface FullPaymentProvider {
collect(input: CollectPaymentInput): Promise<PaymentReceipt>;
refund(paymentId: string, amountMinor: number): Promise<void>;
saveCard(customerId: string, paymentMethodId: string): Promise<void>;
createPayout(accountId: string, amountMinor: number): Promise<void>;
}Our checkout flow only needs:
collect();But it depends on an interface containing:
collect
refund
saveCard
createPayoutThat means checkout knows about capabilities it does not need.
Split the Interface by Capability
Instead:
interface PaymentCollector {
collect(input: CollectPaymentInput): Promise<PaymentReceipt>;
}Refunds get their own contract:
interface RefundProcessor {
refund(paymentId: string, amountMinor: number): Promise<void>;
}Saving payment methods gets another:
interface PaymentMethodVault {
save(customerId: string, paymentMethodId: string): Promise<void>;
}Now checkout only receives:
PaymentCollector;because that is all it needs.
Can One Class Implement Multiple Interfaces?
Yes.
For example Stripe may support all three capabilities:
class StripePaymentAdapter
implements PaymentCollector, RefundProcessor, PaymentMethodVault
{
async collect(input: CollectPaymentInput): Promise<PaymentReceipt> {
return {
paymentId: "provider-payment-id",
};
}
async refund(paymentId: string, amountMinor: number): Promise<void> {
// Stripe refund API
}
async save(customerId: string, paymentMethodId: string): Promise<void> {
// Stripe payment method API
}
}The important part is what the consumer sees.
Checkout sees:
PaymentCollectorRefund service sees:
RefundProcessorCard-management service sees:
PaymentMethodVaultSame implementation.
Different contracts.
LSP vs ISP
An easy way to separate them:
LSP
↓
Can I trust every implementation
of this interface?
ISP
↓
Does this consumer really need
everything in this interface?D — Dependency Inversion Principle
DIP is especially important for backend architecture.
The simple definition is:
Business logic should not depend directly on low-level infrastructure.
The Original Problem
Our original service directly uses:
Stripe SDK
PostgreSQL client
Email SDKThe dependency graph looks like:
Checkout business logic
│
├── Stripe SDK
├── PostgreSQL
└── Email providerThis means our business logic knows implementation details.
Why Is That Bad?
Imagine switching:
Stripe
→ Adyenor:
PostgreSQL
→ MongoDBor:
Email
→ Kafka eventWe should not need to rewrite the checkout business rules every time infrastructure changes.
Define Contracts the Application Needs
Instead of making the application depend on Stripe directly, define:
interface PaymentCollector {
collect(input: CollectPaymentInput): Promise<PaymentReceipt>;
}Instead of PostgreSQL directly:
interface CheckoutStore {
savePaidOrder(order: Order): Promise<void>;
}Instead of an email SDK:
interface OrderNotifier {
orderPlaced(order: Order): Promise<void>;
}For generating IDs:
interface OrderIdGenerator {
next(): string;
}These interfaces describe what the application needs.
They do not describe the technology being used.
Final PlaceOrder Use Case
Now our business workflow becomes:
class PlaceOrder {
constructor(
private readonly totals: OrderTotalCalculator,
private readonly discounts: DiscountPolicy,
private readonly payments: PaymentCollector,
private readonly orders: CheckoutStore,
private readonly notifier: OrderNotifier,
private readonly ids: OrderIdGenerator,
) {}
async execute(input: PlaceOrderInput): Promise<{ orderId: string }> {
const orderId = this.ids.next();
const subtotalMinor = this.totals.calculateSubtotal(input.items);
const totalMinor = this.discounts.apply(subtotalMinor);
const payment = await this.payments.collect({
orderId,
amountMinor: totalMinor,
currency: "USD",
paymentMethodId: input.paymentMethodId,
});
const order: Order = {
id: orderId,
customerId: input.customerId,
email: input.email,
items: input.items,
subtotalMinor,
totalMinor,
currency: "USD",
paymentId: payment.paymentId,
status: "paid",
};
await this.orders.savePaidOrder(order);
await this.notifier.orderPlaced(order);
return {
orderId,
};
}
}Look at what is missing from this class.
There is no:
Stripe SDK
SQL query
SMTP code
PostgreSQL client
UUID libraryPlaceOrder only understands business-level capabilities.
The Dependency Direction
Before:
PlaceOrder
↓
Stripe
PostgreSQL
Email providerAfter:
StripePaymentAdapter
↓
PlaceOrder → PaymentCollector
PostgresCheckoutStore
↓
PlaceOrder → CheckoutStore
EmailOrderNotifier
↓
PlaceOrder → OrderNotifierAnother way to visualize it:
PlaceOrder
│
├── PaymentCollector
│ ↑
│ └── StripePaymentAdapter
│
├── CheckoutStore
│ ↑
│ └── PostgresCheckoutStore
│
└── OrderNotifier
↑
└── EmailOrderNotifierThe business logic owns the contracts.
Infrastructure implements them.
That is Dependency Inversion.
Composition Root: Where Do We Create Everything?
We still need somewhere to create the real objects.
For example:
const paymentAdapter = new StripePaymentAdapter(stripeClient);
const checkoutStore = new PostgresCheckoutStore(database);
const notifier = new EmailOrderNotifier(emailClient);
const ids = new UUIDOrderIdGenerator();Then construct the use case:
export function createPlaceOrder(
customerType: PlaceOrderInput["customerType"],
) {
return new PlaceOrder(
new OrderTotalCalculator(),
discountFor(customerType),
paymentAdapter,
checkoutStore,
notifier,
ids,
);
}This place is often called the:
Composition RootIts responsibility is:
Create concrete dependencies
↓
connect them together
↓
give the application a ready-to-use objectKeep the Controller or Route Thin
Our HTTP route does not need to contain business logic.
It can simply:
parse request
→ call use case
→ return responseFor example:
export async function postOrder(request: Request) {
const input = await parsePlaceOrderRequest(request);
const placeOrder = createPlaceOrder(input.customerType);
const result = await placeOrder.execute(input);
return Response.json(result, {
status: 201,
});
}This separation is useful because HTTP is only one way of calling the application.
Later the same PlaceOrder use case could theoretically be called from:
HTTP API
GraphQL resolver
CLI
background worker
message consumerwithout moving the core business logic.
DIP vs DI vs IoC
These terms are often confused.
They are related, but they are not the same thing.
Dependency Inversion
Dependency Inversion is the architectural idea:
Business logic
↓
depends on abstractions
Infrastructure
↓
implements those abstractionsFor example:
PlaceOrder;depends on:
PaymentCollector;not directly on:
Stripe;Dependency Injection
Dependency Injection is how we give dependencies to a class.
For example:
class PlaceOrder {
constructor(private readonly payments: PaymentCollector) {}
}The dependency is passed through the constructor.
That is constructor injection.
Inversion of Control
Inversion of Control means another part of the application controls object creation and execution.
For example:
Composition Root
↓
creates PlaceOrder
Framework
↓
calls controller
Controller
↓
calls PlaceOrderEasy Comparison
| Concept | Meaning |
|---|---|
| DIP | Business logic depends on abstractions instead of infrastructure |
| DI | Dependencies are passed into a class |
| IoC | Something outside the class controls construction or execution |
Important:
Using Dependency Injection does not automatically mean you are following Dependency Inversion.
For example:
class PlaceOrder {
constructor(private readonly stripe: Stripe) {}
}This uses constructor injection.
But the business use case still directly depends on Stripe.
So DI exists, but DIP is still violated.
The Clean Architecture guide shows how dependency inversion, explicit ports, adapters, and a composition root apply this principle across a backend application.
Testing Becomes Much Easier
One of the biggest benefits of this architecture is testing.
We do not need real:
Stripe
PostgreSQL
SMTPfor every unit test.
We can create simple test implementations.
Fake Database
class InMemoryCheckoutStore implements CheckoutStore {
readonly orders: Order[] = [];
async savePaidOrder(order: Order): Promise<void> {
this.orders.push(order);
}
}Fake Payment Provider
class FakePaymentCollector implements PaymentCollector {
readonly charges: CollectPaymentInput[] = [];
async collect(input: CollectPaymentInput): Promise<PaymentReceipt> {
this.charges.push(input);
return {
paymentId: "payment-123",
};
}
}Fake Notification Service
class RecordingNotifier implements OrderNotifier {
readonly notifications: Order[] = [];
async orderPlaced(order: Order): Promise<void> {
this.notifications.push(order);
}
}Now we can test PlaceOrder without starting any infrastructure.
it("places a premium order with a ten-percent discount", async () => {
const payments = new FakePaymentCollector();
const orders = new InMemoryCheckoutStore();
const notifier = new RecordingNotifier();
const placeOrder = new PlaceOrder(
new OrderTotalCalculator(),
new PercentageDiscountPolicy(10),
payments,
orders,
notifier,
{
next: () => "order-123",
},
);
const result = await placeOrder.execute({
customerId: "customer-1",
customerType: "premium",
email: "customer@example.com",
paymentMethodId: "payment-method-1",
items: [
{
productId: "book",
unitPriceMinor: 2500,
quantity: 2,
},
],
});
expect(result).toEqual({
orderId: "order-123",
});
expect(payments.charges[0]?.amountMinor).toBe(4500);
expect(orders.orders[0]?.status).toBe("paid");
expect(notifier.notifications).toHaveLength(1);
});The calculation is:
2 books × $25
↓
$50 subtotal
↓
10% premium discount
↓
$45 totalOr in minor units:
5000
↓
4500What Should Be Unit Tested?
With this design, we can separate tests clearly.
Unit tests
Test:
OrderTotalCalculator
DiscountPolicy
PlaceOrderusing fake dependencies.
These tests should be:
fast
isolated
repeatableIntegration tests
Test infrastructure separately.
For example:
StripePaymentAdapter
↓
real Stripe test environment
PostgresCheckoutStore
↓
real PostgreSQL databaseThis gives us both:
fast business tests
+
real infrastructure testsinstead of forcing every test to start the whole system.
SOLID Does Not Solve Everything
SOLID improves code structure.
But it does not automatically make a distributed system reliable.
Our workflow still looks like:
1. Charge payment
2. Save order
3. Send notificationWhat happens if step 1 succeeds but step 2 fails?
Payment charged
↓
database fails
↓
order does not existOr:
Order saved
↓
email provider failsOr the client times out and retries:
request 1
→ payment succeeds
client thinks request failed
request 2
→ payment charged againSOLID does not solve these problems.
They require other patterns.
For example:
Idempotency keys
Database transactions
Transactional outbox
Retries
Queues
Durable workflow state
Reconciliation jobsThese boundaries become especially important in retry-safe background jobs, where idempotency, transaction ownership, and external side effects must remain explicit.
When SOLID Becomes Overengineering
SOLID should solve real problems.
Do not apply it mechanically.
A common mistake is seeing this:
function calculateTotal() {
// simple calculation
}and immediately creating:
ITotalCalculator
AbstractTotalCalculator
DefaultTotalCalculator
TotalCalculatorFactory
TotalCalculatorService
TotalCalculatorAdapterThat is not good architecture.
That is unnecessary complexity.
Warning Signs
You may be overengineering if you are:
- creating an interface for every class
- creating a class for every small function
- creating factories for objects that are easy to construct
- creating extension points for requirements that do not exist
- hiding simple logic behind many abstraction layers
- replacing an easy
ifstatement with ten classes - creating wrappers that provide no useful abstraction
- using a DI container just to hide object creation
- making developers open five files to understand one simple operation
SOLID should make code easier to understand.
Not harder.
When Should You Create an Interface?
Interfaces are most useful when there is a meaningful boundary.
For example:
Business logic
|
| interface
↓
InfrastructureGood examples:
PaymentCollector;because payment providers can change.
CheckoutStore;because persistence is infrastructure.
OrderNotifier;because notifications may use email, SMS, events, or another provider.
When You Probably Do Not Need an Interface
Consider:
class OrderTotalCalculator {
calculateSubtotal() {
// ...
}
}If:
there is one implementation
+
the logic is local
+
you do not expect substitution
+
there is no infrastructure boundarythen creating:
interface IOrderTotalCalculatormay provide little value.
You can just use the class directly.
This is an important lesson:
SOLID does not mean
"interface everything."The Full Architecture
After refactoring, the system roughly looks like this:
HTTP Request
↓
Controller / Route
↓
PlaceOrder
│
├── OrderTotalCalculator
│
├── DiscountPolicy
│
├── PaymentCollector
│ ↑
│ └── StripePaymentAdapter
│
├── CheckoutStore
│ ↑
│ └── PostgresCheckoutStore
│
├── OrderNotifier
│ ↑
│ └── EmailOrderNotifier
│
└── OrderIdGenerator
↑
└── UUIDOrderIdGeneratorThe important idea is not the number of classes.
The important idea is the direction of dependencies.
Business logic stays in the center.
Infrastructure stays outside.
SOLID Principles Together
Now we can see how each principle affected the same checkout system.
SRP
Before:
CheckoutService
├── calculate price
├── discount
├── payment
├── database
└── emailAfter:
OrderTotalCalculator
→ calculates subtotal
DiscountPolicy
→ calculates discount
PaymentCollector
→ collects payment
CheckoutStore
→ saves order
OrderNotifier
→ sends notificationMain idea:
Separate unrelated responsibilities.OCP
Before:
if (premium) {
} else if (employee) {
} else if (partner) {
}After:
DiscountPolicy
↑
├── NoDiscountPolicy
├── PercentageDiscountPolicy
└── FixedDiscountPolicyMain idea:
Add new variations without repeatedly
rewriting stable business logic.LSP
Bad design:
PaymentGateway
↑
CashOnDeliveryGateway
refund()
→ throws "not supported"Better:
PaymentCollector
↑
StripePaymentCollector
AdyenPaymentCollectorCash on delivery gets a different workflow.
Main idea:
Every implementation should genuinely
honor the contract.ISP
Bad:
FullPaymentProvider
collect()
refund()
saveCard()
createPayout()Checkout only needs:
collect()Better:
PaymentCollector
RefundProcessor
PaymentMethodVaultMain idea:
Depend only on the capabilities
you actually need.DIP
Before:
PlaceOrder
↓
Stripe
PostgreSQL
EmailAfter:
PlaceOrder
↓
Application interfaces
↑
Infrastructure implementationsMain idea:
Business logic should not depend
directly on infrastructure details.A Simple Way to Remember SOLID
When reviewing your code, ask these five questions.
S — SRP
"Is this class doing several unrelated jobs?"O — OCP
"Will adding another variation force me
to keep modifying stable logic?"L — LSP
"Can every implementation really do
what this interface promises?"I — ISP
"Does this consumer depend on methods
it never uses?"D — DIP
"Does my business logic know too much
about databases, SDKs, frameworks,
or external providers?"These questions are more useful than trying to memorize textbook definitions.
Practical Refactoring Checklist
When improving an existing backend, you can follow this order:
1. Add tests around existing behavior.
2. Find code mixing business logic
and infrastructure.
3. Separate pure business calculations
from database/network operations.
4. Identify behavior that genuinely varies.
5. Extract small contracts around
important boundaries.
6. Make implementations honor the
complete contract.
7. Give consumers only the capabilities
they need.
8. Move infrastructure behind
application-owned abstractions.
9. Wire concrete implementations
in one composition root.
10. Unit test business logic with fakes.
11. Integration test real adapters.
12. Handle transactions, retries,
idempotency, and distributed failures
separately.
13. Stop refactoring when the design
becomes easier to change and understand.That last point matters.
Do not continue creating abstractions simply because you can.
Conclusion
SOLID is not about writing more classes.
It is about making change safer.
In our checkout example:
SRP
→ separated unrelated responsibilities
OCP
→ made discount behavior extendable
LSP
→ made payment contracts trustworthy
ISP
→ kept interfaces focused on what consumers need
DIP
→ separated business logic from Stripe,
PostgreSQL, and email infrastructureThe biggest improvement is not this:
"We now have more interfaces."The improvement is this:
Pricing can change
without changing payment.
Payment can change
without changing persistence.
Persistence can change
without changing pricing.
Notifications can change
without changing checkout rules.
Business logic can be tested
without real infrastructure.That is what SOLID is really trying to achieve.
When the next requirement arrives, ask:
Can I make this change without rewriting unrelated parts of the system?
If the answer is yes, your design is probably moving in the right direction.
References
Related
Written by
Faisal
Software engineer writing about backend systems, Node.js, system design, scalable applications, and modern web and mobile development.