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.
I've refactored enough production TypeScript to know which violations hurt the most. Here they are, 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
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.
Here's an AuthService I see all the time. It handles login, sends a welcome email, and logs every attempt, all in one place.
// 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. The Figma design system guide on Art of Styleframe covers how to structure tokens so a Color or Spacing value stays single-responsibility on the design side; 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.
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 violation I've seen most often 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 my experience, this pattern cuts the blast radius of interface changes by more than half in mid-size codebases.
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);
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."
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.