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.
Backend Clean Architecture & Domain-Driven Design
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.
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
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.
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
The fix: Repository handles only persistence. Caching is a separate adapter wrapping the repository (the Decorator pattern from Part 1):
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
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:
FraudRiskScoringHandler is a new file. Zero existing files are modified.
2.2 Express Middleware Pipeline
The fix: authentication is a middleware — applied as a reusable extension, not a modification:
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
Any use case that correctly handles the null return from IOrderRepository.findById:
...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.
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
The correct mock rejects the promise — exactly what a real async adapter does:
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
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.
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
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:
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:
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.
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.tswith 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 fragilenew X(new Y(new Z()))composition chain. Part 9: Dependency Injection & IoC →
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.