Siddhant Deval
Siddhant Deval
backend5 min read

SOLID Principles in Node.js & Express.js: A Backend-First Dissection

SOLID principles are most consequential in the backend — where a Single Responsibility violation causes a 2,000-line service class, an Liskov violation breaks contract enforcement across microservices, and a Dependency Inversion violation makes the entire application impossible to test without a live database. This article dissects every SOLID violation in an Express.js billing service and derives the correct architecture for each.

SOLID Principles in Node.js & Express.js: A Backend-First Dissection

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. Parts 1 through 7 built a billing engine where this is architecturally true. But the five SOLID principles that make it possible have never been examined from the perspective of the backend engineer writing Express middleware, Prisma repositories, and Kafka consumers. In a frontend context, SOLID manifests in React components and hooks. In a backend context, it manifests in middleware chains, repository adapters, event handler registration, and use case composition.

This article dissects all five SOLID principles through backend-specific examples — real violations from production Node.js services, the mechanics of why each violation compounds over time, and the concrete refactoring that resolves it. Every example is grounded in the billing engine built in Parts 1–7.

Architectural Note

Prerequisites: Part 1 (SOLID definitions) and Part 7 (Ports & Adapters) — this article takes the abstract definitions from Part 1 and applies them to the concrete Infrastructure and Interface layers introduced in Parts 2–7. No new domain objects are introduced; all examples extend existing classes.


1. Single Responsibility Principle: One Axis of Change Per Class

SRP in a backend context is violated most commonly at the Express middleware level and at the repository level — both tend to accumulate responsibilities as requirements change.

1.1 The Express Router as a God Object

TYPESCRIPT
// ❌ SRP Violation: Express router that owns validation, business logic, persistence, and response formatting
const orderRouter = Router();

orderRouter.post('/orders', async (req: Request, res: Response) => {
  // Responsibility 1: Input validation
  const { customerId, items } = req.body;
  if (!customerId) return res.status(400).json({ error: 'customerId required' });
  if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items required' });
  for (const item of items) {
    if (typeof item.quantity !== 'number' || item.quantity <= 0)
      return res.status(400).json({ error: `Invalid quantity for item ${item.productId}` });
  }

  // Responsibility 2: Business rule (belongs in domain)
  const customer = await prisma.customer.findUnique({ where: { id: customerId } });
  if (!customer?.active) return res.status(403).json({ error: 'Customer account suspended' });

  // Responsibility 3: Discount calculation (belongs in domain service)
  let discount = 0;
  if (customer.tier === 'VIP') discount = 0.20;
  else if (items.reduce((s, i) => s + i.quantity * i.unitPrice, 0) > 500) discount = 0.10;

  // Responsibility 4: Persistence (belongs in repository)
  const order = await prisma.order.create({
    data: {
      customerId,
      status: 'PENDING',
      items: { create: items.map(i => ({ ...i, discount })) },
    },
  });

  // Responsibility 5: Side effect (belongs in event handler)
  await stripe.paymentIntents.create({
    amount: Math.round(order.totalAmount * (1 - discount) * 100),
    currency: 'usd',
    metadata: { orderId: order.id },
  });

  // Responsibility 6: Response shaping
  res.status(201).json({ orderId: order.id, discount, total: order.totalAmount * (1 - discount) });
});

Six responsibilities. Six reasons this route handler changes: when the validation rules change, when the discount policy changes, when Prisma schema changes, when Stripe API changes, when the response contract changes, when the business rule changes.

The fix: decompose by responsibility. Each class gets one reason to change.

TYPESCRIPT
// ✅ SRP: Each class has a single, named responsibility

// 1. Validation schema (changes when HTTP contract changes)
const placeOrderSchema = z.object({
  customerId: z.string().min(1),
  items: z.array(z.object({
    productId: z.string(),
    quantity: z.number().int().positive(),
    unitPrice: z.number().positive(),
    currency: z.enum(['USD', 'EUR', 'GBP']),
  })).min(1),
  shippingAddress: shippingAddressSchema,
});

// 2. Validation middleware (changes when validation library changes)
export function validateBody<T>(schema: z.ZodSchema<T>): RequestHandler {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) return res.status(400).json({ errors: result.error.flatten() });
    req.body = result.data;
    next();
  };
}

// 3. Controller (changes when HTTP response contract changes)
export class OrderController {
  constructor(private readonly placeOrder: PlaceOrderUseCase) {}

  async create(req: Request, res: Response): Promise<void> {
    try {
      const orderId = await this.placeOrder.execute(this.toCommand(req.body));
      res.status(201).json({ orderId });
    } catch (err) {
      if (err instanceof DomainError) res.status(422).json({ error: err.message });
      else res.status(500).json({ error: 'Internal server error' });
    }
  }
}

// 4. Router (changes only when route paths change)
const orderRouter = Router();
orderRouter.post('/orders',
  validateBody(placeOrderSchema),
  (req, res) => container.get(OrderController).create(req, res)
);

Four classes, four single responsibilities. When Stripe changes their API, only StripePaymentProcessor changes. When the validation schema gains a field, only the Zod schema changes. When the HTTP response format is redesigned, only OrderController.create() changes.

1.2 SRP at the Repository Level

TYPESCRIPT
// ❌ SRP Violation: Repository handles persistence AND caching AND transformation
class PrismaOrderRepository implements IOrderRepository {
  async findById(id: OrderId): Promise<Order | null> {
    // Responsibility 1: Cache lookup
    const cached = await redis.get(`order:${id}`);
    if (cached) return JSON.parse(cached) as Order;

    // Responsibility 2: Database query
    const raw = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    });
    if (!raw) return null;

    // Responsibility 3: Domain mapping (anti-corruption translation)
    const order = this.toDomain(raw);

    // Responsibility 4: Cache write (side effect of a read operation)
    await redis.set(`order:${id}`, JSON.stringify(order), 'EX', 3600);

    return order;
  }
}

The fix: Repository handles only persistence. Caching is a separate adapter wrapping the repository (the Decorator pattern from Part 1):

TYPESCRIPT
// ✅ SRP: CachingOrderRepository decorates IOrderRepository — adds cache without modifying persistence
export class CachingOrderRepository implements IOrderRepository {
  constructor(
    private readonly inner: IOrderRepository,  // Wraps the real Prisma repo
    private readonly redis: RedisClient,
    private readonly ttlSeconds = 3600,
  ) {}

  async findById(id: OrderId): Promise<Order | null> {
    const key = `order:${id}`;
    const cached = await this.redis.get(key);
    if (cached) return Order.reconstitute(JSON.parse(cached));

    const order = await this.inner.findById(id); // Delegates to PrismaOrderRepository
    if (order) await this.redis.set(key, JSON.stringify(order.toSnapshot()), 'EX', this.ttlSeconds);
    return order;
  }

  async save(order: Order): Promise<void> {
    await this.inner.save(order);
    // Invalidate on write — ensure next read sees fresh data
    await this.redis.del(`order:${order.id}`);
  }

  // ... other methods delegate to this.inner
}

Now PrismaOrderRepository has zero Redis code. CachingOrderRepository has zero Prisma code. Swapping from Redis to Memcached changes only CachingOrderRepository.


2. Open/Closed Principle: Extend by Adding, Not by Modifying

OCP in a backend context is violated most commonly at the event handler registration and middleware pipeline levels.

2.1 Event Handler Registration

TYPESCRIPT
// ❌ OCP Violation: Adding a new order event handler requires modifying this function
async function handleOrderPlaced(event: OrderPlaced): Promise<void> {
  // Handler 1: Inventory
  await inventoryService.reserve(event.orderId);

  // Handler 2: Email
  const customer = await customerRepo.findById(event.customerId);
  await emailService.sendConfirmation(customer!.email, event.orderId);

  // ← Adding fraud check requires opening this function
  // ← Adding loyalty points requires opening this function
  // ← Adding analytics tracking requires opening this function
}

Every new business requirement that reacts to OrderPlaced requires a code review and deployment of this function, which may be tested independently by 10 unit tests.

The fix: OCP via the Mediator/Event-Bus pattern from Part 5 — each handler is registered independently:

TYPESCRIPT
// ✅ OCP: Adding a new OrderPlaced handler = add one line, change zero existing files
eventBus.subscribe('order.placed', new InventoryReservationHandler(inventoryRepo));
eventBus.subscribe('order.placed', new OrderConfirmationEmailHandler(customerRepo, emailPort));

// New requirement: fraud check
eventBus.subscribe('order.placed', new FraudRiskScoringHandler(fraudService));

// New requirement: loyalty points
eventBus.subscribe('order.placed', new LoyaltyPointsAccrualHandler(loyaltyRepo));

FraudRiskScoringHandler is a new file. Zero existing files are modified.

2.2 Express Middleware Pipeline

TYPESCRIPT
// ❌ OCP Violation: Adding authentication to a route modifies the route definition
orderRouter.post('/orders', async (req, res) => {
  // Manually check auth every time (modification required per route)
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Unauthorized' });
  const user = await verifyToken(token);
  if (!user) return res.status(401).json({ error: 'Invalid token' });

  // ... rest of handler
});

The fix: authentication is a middleware — applied as a reusable extension, not a modification:

TYPESCRIPT
// ✅ OCP: Middleware applied compositionally — route handler unchanged
const authenticate: RequestHandler = async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Unauthorized' });
  const user = await verifyToken(token);
  if (!user) return res.status(401).json({ error: 'Invalid token' });
  req.user = user;
  next();
};

const authorize = (role: string): RequestHandler => (req, res, next) => {
  if (!req.user?.roles.includes(role)) return res.status(403).json({ error: 'Forbidden' });
  next();
};

// The route handler is closed for modification — authentication is added, not embedded
orderRouter.post('/orders',
  authenticate,
  authorize('customer'),
  validateBody(placeOrderSchema),
  (req, res) => controller.create(req, res),
);

Adding a new route with different authorization requires zero changes to existing routes or middleware.


3. Liskov Substitution Principle: Behavioral Compatibility at Adapter Boundaries

LSP in a backend context is most dangerous at repository adapter and payment processor adapter boundaries — exactly where substitution is intended to happen.

3.1 The Silent Return Value Violation

TYPESCRIPT
// ❌ LSP Violation: InMemoryOrderRepository narrows the contract
// IOrderRepository.findById contract: returns Order | null
// The InMemory implementation changes the behavior for not-found:
class InMemoryOrderRepository implements IOrderRepository {
  async findById(id: OrderId): Promise<Order | null> {
    const order = this.store.get(id);
    if (!order) throw new Error(`Order ${id} not found`); // ❌ THROWS instead of returning null
    return order;
  }
}

Any use case that correctly handles the null return from IOrderRepository.findById:

TYPESCRIPT
const order = await this.orders.findById(id);
if (!order) throw new DomainError('Order not found'); // ← never reached in tests

...silently bypasses its null-check in tests because InMemoryOrderRepository throws instead of returning null. The production PrismaOrderRepository returns null → the use case's null-check runs. The unit test with InMemoryOrderRepository throws an unexpected error → the test fails for the wrong reason, or the null-check is never exercised.

This is a contract violation: the subtype (InMemoryOrderRepository) does not honor the postcondition of the parent contract (IOrderRepository) — returning null for not-found.

TYPESCRIPT
// ✅ LSP: Both InMemory and Prisma return null for not-found — identical postcondition
class InMemoryOrderRepository implements IOrderRepository {
  async findById(id: OrderId): Promise<Order | null> {
    return this.store.get(id) ?? null; // Returns null — honors the contract
  }
}

The contract test suite from Part 4 (orderRepositoryContract) automatically catches this: it includes a test asserting that findById returns null for a non-existent ID. If either implementation throws instead, the contract test fails.

3.2 The async Exception Contract

TYPESCRIPT
// ❌ LSP Violation: MockPaymentProcessor throws synchronously
class MockPaymentProcessor implements IPaymentProcessor {
  charge(amount: Money, orderId: OrderId): Promise<PaymentReceipt> {
    throw new Error('Not implemented'); // Synchronous throw from an async method
  }
}

// In the use case — async/await handles async rejections, not synchronous throws:
try {
  const receipt = await this.payment.charge(amount, orderId);
} catch (err) {
  // This catch DOES catch synchronous throws in async functions — but only because
  // the async wrapper converts them. The point is: the mock's behavior is not equivalent
  // to a real async rejection, which could expose timing differences in sophisticated handlers.
}

The correct mock rejects the promise — exactly what a real async adapter does:

TYPESCRIPT
// ✅ LSP: MockPaymentProcessor rejects with a Promise — same async contract as Stripe
class MockPaymentProcessor implements IPaymentProcessor {
  async charge(amount: Money, orderId: OrderId): Promise<PaymentReceipt> {
    // Can be configured per-test:
    if (this.shouldFail) {
      throw new DomainError('Card declined: insufficient funds'); // async rejection
    }
    return PaymentReceipt.createMock(amount, orderId);
  }

  shouldFail = false; // Configurable per-test scenario
}

4. Interface Segregation Principle: Role-Based Interface Decomposition

ISP in a backend context is violated most commonly at the repository interface level when CQRS is not yet implemented.

4.1 The Fat Repository Interface

TYPESCRIPT
// ❌ ISP Violation: IOrderRepository serves both reads and writes — forces all consumers to depend on all methods

interface IOrderRepository {
  // Write methods
  save(order: Order): Promise<void>;
  delete(id: OrderId): Promise<void>;

  // Read methods
  findById(id: OrderId): Promise<Order | null>;
  findByCustomerId(customerId: CustomerId): Promise<Order[]>;
  findByStatus(status: OrderStatus, page: number, limit: number): Promise<Order[]>;
  countByCustomer(customerId: CustomerId): Promise<number>;

  // Reporting methods (very different concern)
  findOrdersWithRevenueAbove(threshold: Money): Promise<Order[]>;
  getRevenueByPeriod(from: Date, to: Date): Promise<RevenueReport>;
  exportOrdersToCsv(filter: ExportFilter): Promise<string>;
}

PlaceOrderUseCase must declare a dependency on IOrderRepository even though it only uses save() and nextId(). GetOrderSummaryUseCase must declare the same dependency even though it only uses findById(). A test double for PlaceOrderUseCase must implement 9 methods even though only 2 are called.

TYPESCRIPT
// ✅ ISP: Segregated by role — each consumer depends only on what it calls

interface IOrderWriter {
  save(order: Order): Promise<void>;
  nextId(): OrderId;
}

interface IOrderReader {
  findById(id: OrderId): Promise<Order | null>;
  findByCustomerId(customerId: CustomerId): Promise<Order[]>;
  findByStatus(status: OrderStatus, page: number, limit: number): Promise<Order[]>;
}

interface IOrderReporting {
  getRevenueByPeriod(from: Date, to: Date): Promise<RevenueReport>;
  exportOrdersToCsv(filter: ExportFilter): Promise<string>;
}

// PlaceOrderUseCase depends only on what it uses:
class PlaceOrderUseCase {
  constructor(private readonly orders: IOrderWriter, /* ... */) {}
}

// GetOrderSummaryUseCase depends only on what it reads:
class GetOrderSummaryUseCase {
  constructor(private readonly orders: IOrderReader, /* ... */) {}
}

// The PrismaOrderRepository still implements all three — the interfaces are narrow, not the implementation:
class PrismaOrderRepository implements IOrderWriter, IOrderReader, IOrderReporting {
  // All 9 methods implemented here — but consumers never see the fat interface
}

The InMemoryOrderRepository used in PlaceOrderUseCase tests only needs to implement IOrderWriter — 2 methods, not 9. The test setup is dramatically simpler.


5. Dependency Inversion Principle: The Direction of the Interface Matters

DIP in a backend context is most commonly violated at the infrastructure wiring level — specifically, where interfaces are defined alongside their implementations.

5.1 The Interface-In-The-Wrong-Layer Violation

TYPESCRIPT
// ❌ DIP Violation: IOrderRepository defined in the Infrastructure layer alongside PrismaOrderRepository

// src/infrastructure/persistence/IOrderRepository.ts  ← WRONG LOCATION
export interface IOrderRepository {
  findById(id: string): Promise<PrismaOrder | null>; // ← Uses Prisma type!
  save(order: PrismaOrder): Promise<void>;           // ← Coupled to Prisma
}

// src/infrastructure/persistence/PrismaOrderRepository.ts
export class PrismaOrderRepository implements IOrderRepository {
  /* ... */
}

Two violations: the interface is defined in Infrastructure (the wrong layer), and it uses PrismaOrder (an Infrastructure type) instead of the Domain Order. Any use case that imports this interface now transitively imports Prisma — the dependency arrow has been reversed.

The fix: the interface is defined in the Domain layer, using Domain types:

TYPESCRIPT
// ✅ DIP: IOrderRepository in src/domain/order/ports/ — uses only Domain types

// src/domain/order/ports/IOrderRepository.ts ← CORRECT LOCATION
import { Order } from '../Order';
import { OrderId } from '../OrderId';
import { CustomerId } from '../../customer/CustomerId';

export interface IOrderRepository {
  findById(id: OrderId): Promise<Order | null>;    // ← Domain type
  save(order: Order): Promise<void>;               // ← Domain type
  nextId(): OrderId;
}

// src/infrastructure/persistence/PrismaOrderRepository.ts ← Implementation in Infrastructure
import { IOrderRepository } from '../../domain/order/ports/IOrderRepository'; // ← Imports from Domain
export class PrismaOrderRepository implements IOrderRepository { /* ... */ }

The dependency arrow: PrismaOrderRepository → IOrderRepository (Domain). The Domain layer has no arrow pointing outward. DIP is satisfied.

5.2 express.Request in a Use Case (Common Node.js DIP Violation)

This bears repeating from Part 1 because it is genuinely ubiquitous in Node.js codebases:

TYPESCRIPT
// ❌ DIP Violation: Use Case imports from Express — couples application to HTTP
import { Request } from 'express';

class PlaceOrderUseCase {
  async execute(req: Request): Promise<{ orderId: string }> {
    const { customerId, items } = req.body; // ← Use Case parses HTTP
    // ... business logic
  }
}

Why this matters beyond the obvious: a Kafka consumer message handler has no express.Request. A CLI command handler has no express.Request. A test for this use case must construct a mock Request object — with body, headers, params, query, and socket properties — just to pass customerId and items.

TYPESCRIPT
// ✅ DIP: Use Case depends on a plain Command DTO — agnostic of delivery mechanism
class PlaceOrderUseCase {
  async execute(command: PlaceOrderCommand): Promise<OrderId> {
    const { customerId, items, shippingAddress } = command; // ← plain interface
    // ... business logic
  }
}

// In tests — no Request needed:
const orderId = await useCase.execute({ customerId, items, shippingAddress });

// In HTTP controller — constructs the Command from Request:
const command: PlaceOrderCommand = {
  customerId: req.body.customerId,
  items: req.body.items.map(/* ... */),
  shippingAddress: req.body.shippingAddress,
};
const orderId = await useCase.execute(command);

Summary

Principle Backend-Specific Violation Correct Pattern
SRP Route handler owns validation + business logic + persistence + email Decompose into middleware, controller, use case, repository, event handler
SRP Repository handles caching + persistence + domain mapping Caching Decorator wraps repository; toDomain() is a private adapter method
OCP handleOrderPlaced() function accumulates new handlers Mediator event bus — new handler = new class + one subscribe() call
OCP Auth check embedded in route handler Authentication middleware applied compositionally
LSP InMemoryOrderRepository.findById() throws instead of returning null Contract test suite enforces identical postconditions for all implementations
ISP IOrderRepository exposes write, read, and reporting methods to all consumers Segregate into IOrderWriter, IOrderReader, IOrderReporting by consumer role
DIP IOrderRepository defined in Infrastructure with PrismaOrder types Interface in src/domain/order/ports/; uses only Domain types; arrow points inward
DIP execute(req: Request) in a use case execute(command: PlaceOrderCommand) — plain DTO, no HTTP type

What's Next

In Part 9, we replace the manual constructor wiring from Part 7's main.ts with an IoC container — using InversifyJS to bind interfaces to implementations declaratively, enabling scope management (singleton vs. transient), conditional bindings for test vs. production, and eliminating the fragile new X(new Y(new Z())) composition chain. Part 9: Dependency Injection & IoC →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#SOLID#Node.js#Express.js#OOP#TypeScript#Backend Architecture#Clean Code
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.