Building Robust TypeScript Backends with SOLID Principles and Design Patterns
Daniel Hayes
Full-Stack Engineer · Leapcell

Introduction
In the rapidly evolving landscape of software development, building robust, scalable, and maintainable backend applications is paramount. As JavaScript continues its dominance on the server-side with Node.js, TypeScript has emerged as an indispensable tool, providing type safety and enhancing developer experience. However, simply using TypeScript isn't enough to guarantee high-quality code. To truly elevate our backend solutions, we must embrace established architectural guidelines and proven solutions to recurring problems. This is where SOLID principles and common design patterns come into play. They are not merely academic concepts but practical blueprints that, when applied effectively, transform chaotic codebases into elegant, resilient systems. This article will delve into the practical implementation of SOLID principles and key design patterns within TypeScript backend applications, illustrating how they contribute to building software that can adapt, scale, and endure.
Understanding the Fundamentals
Before diving into practical examples, it's crucial to grasp the core concepts that underpin our discussion.
SOLID Principles are a set of five design principles intended to make software designs more understandable, flexible, and maintainable. They were promoted by Robert C. Martin (Uncle Bob).
- Single Responsibility Principle (SRP): A class should have only one reason to change. This means a class should have one, and only one, job.
- Open/Closed Principle (OCP): Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. You should be able to add new functionality without altering existing code.
- Liskov Substitution Principle (LSP): Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program. In simpler terms, a subclass should be substitutable for its superclass.
- Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use. Rather than one large interface, many small, purpose-specific interfaces are better.
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
Design Patterns are generalized, reusable solutions to common problems that occur during software design. They are not direct solutions but templates or blueprints that can be customized to solve particular problems. We'll focus on a few common ones relevant to backend development:
- Singleton Pattern: Ensures a class has only one instance and provides a global point of access to it.
- Factory Method Pattern: Defines an interface for creating an object, but lets subclasses decide which class to instantiate. It defers instantiation to subclasses.
- Strategy Pattern: Defines a family of algorithms, encapsulates each one, and makes them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
- Repository Pattern: Mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects.
Practical Application in TypeScript Backends
Let's explore how these principles and patterns translate into actionable code within a TypeScript backend context, using a typical scenario like an e-commerce order processing system.
Single Responsibility Principle (SRP)
Consider an OrderService
class. Without SRP, it might handle order creation, payment processing, sending notifications, and logging. This makes it hard to maintain and test.
Problematic Design (Violates SRP):
// services/order.service.ts class OrderService { createOrder(userId: string, itemIds: string[]): Order { // 1. Validate input // 2. Persist order to database // 3. Process payment // 4. Send order confirmation email // 5. Log order creation event // ... many responsibilities return new Order(); // Simplified } }
SRP-Compliant Design:
We break down the OrderService
into several distinct classes, each with a single responsibility.
// interfaces/order.interface.ts interface OrderCreationRequest { userId: string; itemIds: string[]; } interface Order { id: string; userId: string; status: string; // ... } // repositories/order.repository.ts class OrderRepository { async create(orderData: OrderCreationRequest): Promise<Order> { console.log("Persisting order data to DB..."); // Simulate DB interaction return { id: 'order-123', userId: orderData.userId, status: 'pending' }; } // ... other persistence methods like find, update, delete } // services/payment.service.ts class PaymentService { async processPayment(orderId: string, amount: number): Promise<boolean> { console.log(`Processing payment for order ${orderId}, amount: ${amount}`); // Simulate payment gateway interaction return true; } } // services/notification.service.ts class NotificationService { async sendOrderConfirmationEmail(order: Order, userEmail: string): Promise<void> { console.log(`Sending confirmation email for order ${order.id} to ${userEmail}`); // Simulate email sending } } // services/logger.service.ts class LoggerService { log(message: string, context?: object): void { console.log(`LOG: ${message}`, context); } } // services/order.service.ts (now orchestrator) class OrderCreationService { constructor( private orderRepository: OrderRepository, private paymentService: PaymentService, private notificationService: NotificationService, private loggerService: LoggerService ) {} async createOrder(request: OrderCreationRequest, userEmail: string): Promise<Order> { // Input validation might be another service or a middleware/decorator const newOrder = await this.orderRepository.create(request); await this.paymentService.processPayment(newOrder.id, 100); // Assume amount await this.notificationService.sendOrderConfirmationEmail(newOrder, userEmail); this.loggerService.log('Order created successfully', { orderId: newOrder.id, userId: newOrder.userId }); return newOrder; } } // Usage in an Express route handler // const orderRepo = new OrderRepository(); // const paymentSvc = new PaymentService(); // const notificationSvc = new NotificationService(); // const loggerSvc = new LoggerService(); // const orderCreator = new OrderCreationService(orderRepo, paymentSvc, notificationSvc, loggerSvc); // app.post('/orders', async (req, res) => { // const order = await orderCreator.createOrder(req.body, req.user.email); // res.status(201).json(order); // });
This refactoring makes each class easier to understand, test, and maintain. If payment logic changes, only PaymentService
needs modification.
Open/Closed Principle (OCP) and Strategy Pattern
Let's say our payment processing needs to support multiple payment gateways (Stripe, PayPal, etc.). Modifying PaymentService
directly each time we add a new gateway violates OCP. We can use the Strategy Pattern coupled with OCP.
Violates OCP:
// services/payment.service.ts class PaymentService { async processPayment(orderId: string, amount: number, paymentMethod: 'stripe' | 'paypal'): Promise<boolean> { if (paymentMethod === 'stripe') { // Stripe specific logic } else if (paymentMethod === 'paypal') { // PayPal specific logic } else { throw new Error('Unsupported payment method'); } return true; } }
OCP and Strategy Compliant Design:
// interfaces/payment-gateway.interface.ts interface PaymentGateway { process(amount: number, orderId: string): Promise<boolean>; } // strategies/stripe-gateway.ts class StripeGateway implements PaymentGateway { async process(amount: number, orderId: string): Promise<boolean> { console.log(`Processing ${amount} via Stripe for order ${orderId}`); // Call Stripe API return true; } } // strategies/paypal-gateway.ts class PayPalGateway implements PaymentGateway { async process(amount: number, orderId: string): Promise<boolean> { console.log(`Processing ${amount} via PayPal for order ${orderId}`); // Call PayPal API return true; } } // services/payment.processor.ts class PaymentProcessor { private gateway: PaymentGateway; setPaymentGateway(gateway: PaymentGateway): void { this.gateway = gateway; } async executePayment(amount: number, orderId: string): Promise<boolean> { if (!this.gateway) { throw new Error("Payment gateway not set."); } return this.gateway.process(amount, orderId); } } // Usage // const paymentProcessor = new PaymentProcessor(); // // For Stripe payment // paymentProcessor.setPaymentGateway(new StripeGateway()); // await paymentProcessor.executePayment(50, 'order-456'); // // For PayPal payment // paymentProcessor.setPaymentGateway(new PayPalGateway()); // await paymentProcessor.executePayment(75, 'order-789');
Now, to add a new payment gateway, we simply create a new class implementing PaymentGateway
and inject it into PaymentProcessor
. No modification to PaymentProcessor
is needed, adhering to OCP.
Liskov Substitution Principle (LSP)
Consider different types of shipping. If we have StandardShipping
and ExpressShipping
classes, ExpressShipping
should be usable anywhere StandardShipping
is expected without breaking functionality.
// interfaces/shipping.interface.ts interface ShippingService { calculateCost(weight: number, distance: number): number; deliver(orderId: string, address: string): Promise<boolean>; } // services/standard-shipping.ts class StandardShipping implements ShippingService { calculateCost(weight: number, distance: number): number { return weight * 0.5 + distance * 0.1; // Simple calculation } async deliver(orderId: string, address: string): Promise<boolean> { console.log(`Delivering order ${orderId} to ${address} via Standard Shipping`); // Simulate standard delivery return true; } } // services/express-shipping.ts class ExpressShipping implements ShippingService { calculateCost(weight: number, distance: number): number { return weight * 1.5 + distance * 0.3 + 10; // Higher cost } async deliver(orderId: string, address: string): Promise<boolean> { console.log(`Delivering order ${orderId} to ${address} via Express Shipping`); // Simulate express delivery return true; } // No "superfluous" methods that might break context } // Usage function shipOrder(shippingService: ShippingService, orderId: string, address: string, weight: number, distance: number) { const cost = shippingService.calculateCost(weight, distance); console.log(`Shipping cost: $${cost}`); shippingService.deliver(orderId, address); } // shipOrder(new StandardShipping(), 'order-1', '123 Main St', 5, 100); // shipOrder(new ExpressShipping(), 'order-2', '456 Oak Ave', 3, 50);
Both StandardShipping
and ExpressShipping
can be substituted for ShippingService
without issues, upholding LSP.
Interface Segregation Principle (ISP)
Instead of a monolithic UserRepository
interface with many methods, we can break it down.
Violates ISP:
// interfaces/user.repository.ts interface IUserRepository { create(user: User): Promise<User>; findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>; update(id: string, user: Partial<User>): Promise<User | null>; delete(id: string): Promise<boolean>; // Includes methods for profile management, authentication, etc. updateProfile(userId: string, profileData: Partial<UserProfile>): Promise<UserProfile>; resetPassword(userId: string, newPasswordHash: string): Promise<boolean>; }
ISP Compliant Design:
// interfaces/user.read.repository.ts interface IUserReadRepository { findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>; } // interfaces/user.write.repository.ts interface IUserWriteRepository { create(user: User): Promise<User>; update(id: string, user: Partial<User>): Promise<User | null>; delete(id: string): Promise<boolean>; } // interfaces/user.profile.repository.ts interface IUserProfileRepository { updateProfile(userId: string, profileData: Partial<UserProfile>): Promise<UserProfile>; } // Actual implementation classes might implement multiple specific interfaces class UserMongoRepository implements IUserReadRepository, IUserWriteRepository { async findById(id: string): Promise<User | null> { /* ... */ return null; } async findByEmail(email: string): Promise<User | null> { /* ... */ return null; } async create(user: User): Promise<User> { /* ... */ return user; } async update(id: string, user: Partial<User>): Promise<User | null> { /* ... */ return null; } async delete(id: string): Promise<boolean> { /* ... */ return false; } } // A service that only needs to read user data will only depend on `IUserReadRepository`, minimizing its coupling.
This prevents clients (like services) from depending on methods they don't use, making the system more modular and easier to refactor.
Dependency Inversion Principle (DIP) and Repository Pattern
DIP suggests high-level modules shouldn't depend on low-level modules, both should depend on abstractions. The Repository Pattern naturally aligns with DIP. Instead of our OrderService
directly knowing about MongoDB
or PostgreSQL
, it should depend on an OrderRepository
abstraction.
Violates DIP:
// services/order.service.ts import { MongoClient } from 'mongodb'; // Direct dependency on low-level detail class OrderService { private db: MongoClient; constructor(mongoClient: MongoClient) { this.db = mongoClient; } async getOrderById(id: string): Promise<Order | null> { const collection = this.db.db('my_db').collection('orders'); return await collection.findOne({ _id: new ObjectId(id) }); } }
DIP and Repository Pattern Compliant Design:
// interfaces/order-repository.interface.ts interface IOrderRepository { findById(id: string): Promise<Order | null>; create(orderData: OrderCreationRequest): Promise<Order>; updateStatus(id: string, status: string): Promise<Order | null>; } // repositories/mongo-order.repository.ts (Low-level module) import { MongoClient, ObjectId } from 'mongodb'; class MongoOrderRepository implements IOrderRepository { private collection: Collection<Order>; constructor(mongoClient: MongoClient) { this.collection = mongoClient.db('my_db').collection<Order>('orders'); } async findById(id: string): Promise<Order | null> { return await this.collection.findOne({ _id: new ObjectId(id) }); } async create(orderData: OrderCreationRequest): Promise<Order> { const result = await this.collection.insertOne({ ...orderData, status: 'pending', id: new ObjectId().toHexString() }); return { ...orderData, status: 'pending', id: result.insertedId.toHexString() }; } async updateStatus(id: string, status: string): Promise<Order | null> { const result = await this.collection.findOneAndUpdate( { _id: new ObjectId(id) }, { $set: { status: status } }, { returnDocument: 'after' } ); return result.value ? { ...result.value, id: result.value._id.toHexString() } : null; } } // services/order-management.service.ts (High-level module) class OrderManagementService { constructor(private orderRepository: IOrderRepository) {} // Depends on abstraction async fulfillOrder(orderId: string): Promise<Order | null> { const order = await this.orderRepository.findById(orderId); if (!order) { throw new Error(`Order with ID ${orderId} not found.`); } // Business logic for fulfillment return this.orderRepository.updateStatus(orderId, 'fulfilled'); } } // Usage in main application setup using a simple Dependency Injection (manual) // const mongoClient = await MongoClient.connect('mongodb://localhost:27017'); // const orderRepository: IOrderRepository = new MongoOrderRepository(mongoClient); // const orderManagementService = new OrderManagementService(orderRepository); // // In a route (e.g., Express) // app.put('/orders/:id/fulfill', async (req, res) => { // const orderId = req.params.id; // try { // const fulfilledOrder = await orderManagementService.fulfillOrder(orderId); // res.json(fulfilledOrder); // } catch (error: any) { // res.status(404).json({ message: error.message }); // } // });
Here, OrderManagementService
doesn't care about MongoDB
specifics; it only interacts with the IOrderRepository
interface. This allows us to swap MongoOrderRepository
with, say, PostgresOrderRepository
or InMemoryOrderRepository
(for testing) without changing OrderManagementService
.
Singleton Pattern
While often criticized for masking dependencies, the Singleton Pattern can be useful for resources that must have only one instance, like a database connection pool or a global configuration service.
// util/database.ts import { MongoClient, Db } from 'mongodb'; class Database { private static instance: Database; private client: MongoClient; private db: Db; private constructor() { // Prevent direct instantiation const uri = process.env.MONGO_URI || 'mongodb://localhost:27017'; this.client = new MongoClient(uri); } public static getInstance(): Database { if (!Database.instance) { Database.instance = new Database(); } return Database.instance; } public async connect(): Promise<void> { if (!this.client.isConnected()) { // old way, check for actual connection not just client state await this.client.connect(); this.db = this.client.db(process.env.DB_NAME || 'mydatabase'); console.log("Connected to MongoDB"); } } public getClient(): MongoClient { return this.client; } public getDb(): Db { return this.db; } public async close(): Promise<void> { if (this.client.isConnected()) { await this.client.close(); console.log("MongoDB connection closed"); } } } // Usage: // const dbInstance = Database.getInstance(); // await dbInstance.connect(); // const db = dbInstance.getDb(); // const usersCollection = db.collection('users'); // // ... use usersCollection ...
This ensures that throughout the application, there's only one active connection to the database.
Factory Method Pattern
This pattern is great for creating objects where the exact type to be created is not known until runtime, or to centralize object creation logic.
// interfaces/product.interface.ts interface Product { id: string; name: string; price: number; getDescription(): string; } // products/digital-product.ts class DigitalProduct implements Product { constructor(public id: string, public name: string, public price: number, private downloadLink: string) {} getDescription(): string { return `${this.name} (${this.id}) - Digital Download. Price: $${this.price}`; } } // products/physical-product.ts class PhysicalProduct implements Product { constructor(public id: string, public name: string, public price: number, private weight: number) {} getDescription(): string { return `${this.name} (${this.id}) - Physical Product. Weight: ${this.weight}kg. Price: $${this.price}`; } } // factories/product-factory.ts class ProductFactory { static createProduct(type: 'digital' | 'physical', id: string, name: string, price: number, details: any): Product { switch (type) { case 'digital': return new DigitalProduct(id, name, price, details.downloadLink); case 'physical': return new PhysicalProduct(id, name, price, details.weight); default: throw new Error('Invalid product type'); } } } // Usage in an admin service or API endpoint // const digitalGame = ProductFactory.createProduct('digital', 'game-1', 'Cyberpunk 2077', 59.99, { downloadLink: 'http://cdn.game.com/cp2077' }); // console.log(digitalGame.getDescription()); // const physicalBook = ProductFactory.createProduct('physical', 'book-1', 'Clean Code', 35.00, { weight: 0.8 }); // console.log(physicalBook.getDescription());
The ProductFactory
abstracts the creation logic, allowing us to introduce new product types without modifying the client code that uses the factory.
Conclusion
The judicious application of SOLID principles and design patterns in TypeScript backend development transforms complex systems into well-structured, understandable, and adaptable applications. SOLID principles guide us in designing classes and modules that are cohesive and loosely coupled, while design patterns offer proven, reusable solutions to common recurring problems. By embracing these concepts, developers can build backend systems that are not only powerful and efficient today but also maintainable, scalable, and resilient in the face of future demands and evolving business logic. Ultimately, they empower us to write code that's easier to collaborate on, test thoroughly, and extend with confidence.