SOLID is a set of five object-oriented design principles, Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion, that keep TypeScript classes and modules easy to change without breaking the rest of the codebase. SOLID principles appear in every clean code list. Most tutorials explain them with Circle, Rectangle, and Animal classes. It works as a teaching device, but it's hard to carry back to real work, with practical examples.
TypeScript changes the equation. Interfaces aren't just documentation, they're enforceable contracts. When you violate the Interface Segregation Principle, the compiler complains. When you wire up Dependency Inversion correctly, tests write themselves. The type system becomes your architecture guardrail.
Some SOLID violations hurt far more in production TypeScript than others. Here are the worst offenders, with real examples from clean code patterns you can actually use.
TL;DR:
- S, Single Responsibility: one class, one reason to change
- O, Open/Closed: extend behavior without editing existing code
- L, Liskov Substitution: subtypes must behave like their base type
- I, Interface Segregation: small focused interfaces beat one fat one
- D, Dependency Inversion: depend on abstractions, inject concretions
Quick take: SOLID isn't abstract theory in TypeScript, the compiler enforces every violation you'd otherwise catch in code review. Start with Dependency Inversion: extract an interface for a hardcoded logger or database client, inject it through the constructor, and you're usually done in 10-15 lines. Interface Segregation matters too, splitting one fat UserService interface into UserReader, UserWriter, and UserNotifier cuts the blast radius of changes by more than half in mid-size codebases. Single Responsibility takes more design thinking, so it's not where I'd begin. Pick one principle and apply it to a real class this week.
What Is Single Responsibility, and Why Do Auth Services Break It?
Single Responsibility means one class has one reason to change. According to Robert C. Martin's Clean Architecture, a module should be responsible to exactly one actor, one business function that could demand a change. When a class changes for two different reasons, those reasons will eventually conflict.
A god class is a class that accumulates unrelated responsibilities until it becomes the single most-edited file in the repo, and it's the most common Single Responsibility violation in production TypeScript. Here's an AuthService shape that keeps showing up in production code. It handles login, sends a welcome email, and logs every attempt, all in one place. In three separate codebases I've audited over the past two years, this exact shape showed up within the first hour of review, usually with six or seven unrelated methods bolted onto the same class by the time anyone noticed.
// Bad: AuthService is doing three different jobs
class AuthService {
async login(email: string, password: string) {
const user = await this.db.findUser(email);
if (!user || !this.verifyPassword(password, user.hash)) {
throw new Error("Invalid credentials");
}
// Why is email logic inside an auth service?
await this.mailer.send(user.email, "You logged in");
// Why is logging interleaved with business logic?
this.logger.info(`Login attempt: ${email}`);
return this.generateToken(user);
}
}
If the email template changes, you edit AuthService. If the log format changes, you edit AuthService. That's two unrelated reasons to touch authentication logic.
The fix splits responsibilities cleanly.
// Good: each service has one job
class AuthService {
constructor(
private db: UserRepository,
private tokenService: TokenService
) {}
async login(email: string, password: string): Promise<string> {
const user = await this.db.findUser(email);
if (!user || !this.verifyPassword(password, user.hash)) {
throw new Error("Invalid credentials");
}
return this.tokenService.generate(user);
}
}
class LoginEventHandler {
async onLogin(user: User): Promise<void> {
await this.mailer.sendLoginNotification(user);
this.logger.info(`Login: ${user.email}`);
}
}
Each class now changes for exactly one reason. The TypeScript strict mode flags missing injected dependencies at compile time, another free guardrail.
For more on this style of small, focused class design, our TypeScript clean code patterns collects 15 of the patterns SRP tends to push you toward in practice.
How Does Open/Closed Prevent If-Else Hell in Payment Processing?
Open/Closed means your code is open for extension but closed for modification. Every time you add a payment method by editing a switch statement, you're one typo away from breaking existing payment paths. The TypeScript Handbook's section on interfaces shows exactly how polymorphism solves this.
The same principle holds for design tokens - every time you hard-code a color value in a component, you've closed it for theme extension. Structuring tokens so a Color or Spacing value keeps a single responsibility is the design-side version of the idea; the TypeScript patterns below carry that same intent into code.
The bad version looks like this.
// Bad: adding a new payment method requires editing this function
async function processPayment(type: string, amount: number) {
if (type === "stripe") {
return await stripe.charge(amount);
} else if (type === "paypal") {
return await paypal.pay(amount);
} else if (type === "crypto") {
return await crypto.transfer(amount);
}
throw new Error("Unknown payment type");
}
Adding Apple Pay means editing this function. Every edit risks a regression. Every merge conflict touches business logic.
The interface approach extends without touching existing code.
// Good: new payment methods are additive, not modifications
interface PaymentMethod {
charge(amount: number): Promise<PaymentResult>;
}
class StripePayment implements PaymentMethod {
async charge(amount: number): Promise<PaymentResult> {
return await stripe.charge(amount);
}
}
class PayPalPayment implements PaymentMethod {
async charge(amount: number): Promise<PaymentResult> {
return await paypal.pay(amount);
}
}
// The processor never changes when you add a new method
async function processPayment(method: PaymentMethod, amount: number) {
return await method.charge(amount);
}
Adding Apple Pay? Write a new class. Don't touch processPayment. Ever.
The payoff shows up the next time requirements shift. With the if/else approach, every new payment method means editing processPayment and risking the branches that already work. With the interface approach, you add a class and touch nothing that was already shipping.
How do you check whether a function is actually closed for modification? Run through this quick list before merging a new feature branch.
- Search the function for a
switchor chainedif/elsekeyed on a string or enum, that's the first warning sign. - Check whether adding a new case requires editing an existing file rather than adding a new one.
- Confirm the existing branches have test coverage, since an interface refactor is only safe if you can prove the old paths still work.
- Extract an interface for the varying behavior and move each branch into its own implementing class.
Why Does Liskov Substitution Matter for Repository Patterns?
Liskov Substitution says a subtype must be fully substitutable for its base type. Anywhere you use a Repository, you should be able to drop in a ReadOnlyRepository without things breaking. If you can't, your inheritance hierarchy is wrong.
The most common violation is a read-only repo that extends a writable one.
// Bad: ReadOnlyRepository breaks the contract of Repository
class Repository<T> {
async findById(id: string): Promise<T> { ... }
async save(entity: T): Promise<void> { ... }
}
class ReadOnlyRepository<T> extends Repository<T> {
async save(entity: T): Promise<void> {
// This blows up at runtime, violates LSP
throw new Error("This repository is read-only");
}
}
Code that accepts a Repository<T> will explode when handed a ReadOnlyRepository<T>. That's a runtime surprise buried in a type-safe-looking codebase.
The fix is separate interfaces, not inheritance.
// Good: separate interfaces, no inherited broken contracts
interface Readable<T> {
findById(id: string): Promise<T>;
findAll(): Promise<T[]>;
}
interface Writable<T> {
save(entity: T): Promise<void>;
delete(id: string): Promise<void>;
}
interface Repository<T> extends Readable<T>, Writable<T> {}
// ReadOnlyRepository only implements what it can actually do
class ReadOnlyUserRepository implements Readable<User> {
async findById(id: string): Promise<User> { ... }
async findAll(): Promise<User[]> { ... }
}
Now the compiler enforces the contract. Pass a Readable<User> where a Readable<User> is expected, TypeScript won't let you accidentally call .save() on it.
Sweeping a fat interface into a single Readable/Writable pair is one of the cleanest fixes for the TypeScript anti-patterns you tend to see in long-lived service code.
Does Interface Segregation Really Matter for a UserService?
It matters more than most developers expect. A fat UserService interface with 8 methods means every consumer, even one that only reads user data, depends on methods it never calls. When the email-sending method changes, the read-only report generator recompiles for no reason.
// Bad: every consumer must implement all 8 methods
interface UserService {
findById(id: string): Promise<User>;
findAll(): Promise<User[]>;
create(data: CreateUserDto): Promise<User>;
update(id: string, data: UpdateUserDto): Promise<User>;
delete(id: string): Promise<void>;
sendWelcomeEmail(user: User): Promise<void>;
resetPassword(email: string): Promise<void>;
exportToCsv(): Promise<string>;
}
Split it by what callers actually need.
// Good: focused interfaces, callers depend only on what they use
interface UserReader {
findById(id: string): Promise<User>;
findAll(): Promise<User[]>;
}
interface UserWriter {
create(data: CreateUserDto): Promise<User>;
update(id: string, data: UpdateUserDto): Promise<User>;
delete(id: string): Promise<void>;
}
interface UserNotifier {
sendWelcomeEmail(user: User): Promise<void>;
resetPassword(email: string): Promise<void>;
}
Your report service accepts UserReader. Your admin panel accepts UserWriter. Your email handler accepts UserNotifier. Changes to one don't ripple through the others.
In mid-size codebases, this pattern cuts the blast radius of interface changes by more than half.
How Does Dependency Inversion Make TypeScript Services Testable?
Dependency Inversion says high-level modules shouldn't depend on low-level ones, both should depend on abstractions. In practice this means: don't import a concrete logger or database client directly. Accept an interface in the constructor.
The violation is everywhere.
// Bad: hard dependency on a specific logger, untestable and inflexible
import { WinstonLogger } from "./winston-logger";
class OrderService {
private logger = new WinstonLogger();
async placeOrder(order: Order): Promise<void> {
this.logger.info(`Placing order ${order.id}`);
// ...
}
}
To test OrderService, you must deal with Winston. To swap to a different logger, you edit every service that uses it.
Constructor injection with an interface fixes both problems.
// Good: depend on the abstraction, inject the concretion
interface Logger {
info(message: string): void;
error(message: string, err?: Error): void;
}
class OrderService {
constructor(private logger: Logger) {}
async placeOrder(order: Order): Promise<void> {
this.logger.info(`Placing order ${order.id}`);
// ...
}
}
// Production: inject Winston
const service = new OrderService(new WinstonLogger());
// Tests: inject a spy, zero config
const mockLogger: Logger = { info: jest.fn(), error: jest.fn() };
const testService = new OrderService(mockLogger);
Constructor injection is the pattern of passing a class's dependencies in through its constructor instead of instantiating them internally, and it's what makes the Logger interface swappable above. This is the principle I'd apply first in any legacy TypeScript codebase. It's the fastest path from "tests are a nightmare" to "tests write themselves." According to the TypeScript Handbook's guidance on interfaces, structural typing means any object shaped like Logger satisfies the contract, so a Jest mock with two jest.fn() methods passes type-checking without extending a base class.
SOLID in TypeScript isn't textbook theory, it's enforced by interfaces and structural typing. The compiler catches the violations that code reviews miss. Each principle reduces the blast radius when something changes: fewer files touched, fewer regressions, fewer surprises.
Start with one. I'd pick Dependency Inversion. Then look at your TypeScript anti-patterns and you'll spot the rest naturally.
SOLID at a Glance
| Principle | Common violation | What it fixes |
|---|---|---|
| Single Responsibility | An auth service handling login, tokens, and emails at once | One reason per class to change |
| Open/Closed | If-else chains for every new payment method | Extend via new classes, not edited conditionals |
| Liskov Substitution | A repository subclass that breaks the base contract | Subtypes stay swappable without surprises |
| Interface Segregation | A bloated UserService interface no caller fully needs | Smaller, role-specific interfaces |
| Dependency Inversion | Hardcoded logger instantiation inside a service | Inject interfaces, making tests trivial |