Robuste TypeScript-Backends mit SOLID-Prinzipien und Entwurfsmustern aufbauen
Daniel Hayes
Full-Stack Engineer · Leapcell

Einleitung
In der sich rasant entwickelnden Softwarelandschaft ist der Aufbau robuster, skalierbarer und wartbarer Backend-Anwendungen von größter Bedeutung. Da JavaScript mit Node.js seine Dominanz auf der Serverseite fortsetzt, hat sich TypeScript als unverzichtbares Werkzeug etabliert, das Typsicherheit bietet und die Entwicklererfahrung verbessert. Doch die reine Verwendung von TypeScript reicht nicht aus, um qualitativ hochwertigen Code zu gewährleisten. Um unsere Backend-Lösungen wirklich aufzuwerten, müssen wir etablierte Architekturgrundsätze und bewährte Lösungen für wiederkehrende Probleme anwenden. Hier kommen die SOLID-Prinzipien und gängige Entwurfsmuster ins Spiel. Sie sind keine bloß akademischen Konzepte, sondern praktische Baupläne, die, wenn sie effektiv angewendet werden, chaotische Codebasen in elegante, widerstandsfähige Systeme verwandeln. Dieser Artikel wird sich mit der praktischen Implementierung von SOLID-Prinzipien und wichtigen Entwurfsmustern in TypeScript-Backend-Anwendungen befassen und veranschaulichen, wie sie zum Aufbau von Software beitragen, die sich anpassen, skalieren und bestehen kann.
Grundlagen verstehen
Bevor wir uns mit praktischen Beispielen befassen, ist es entscheidend, die Kernkonzepte zu verstehen, die unserer Diskussion zugrunde liegen.
SOLID-Prinzipien sind eine Reihe von fünf Designprinzipien, die darauf abzielen, Softwaredesigns verständlicher, flexibler und wartbarer zu machen. Sie wurden von Robert C. Martin (Uncle Bob) propagiert.
- Single Responsibility Principle (SRP): Eine Klasse sollte nur einen Grund zur Änderung haben. Das bedeutet, dass eine Klasse nur eine einzige Aufgabe haben sollte.
- Open/Closed Principle (OCP): Softwareentitäten (Klassen, Module, Funktionen usw.) sollten offen für Erweiterungen, aber geschlossen für Änderungen sein. Sie sollten in der Lage sein, neue Funktionalität hinzuzufügen, ohne bestehenden Code zu verändern.
- Liskov Substitution Principle (LSP): Objekte in einem Programm sollten durch Instanzen ihrer Subtypen ersetzbar sein, ohne die Korrektheit des Programms zu verändern. Vereinfacht ausgedrückt sollte eine Unterklasse für ihre Oberklasse substituierbar sein.
- Interface Segregation Principle (ISP): Clients sollten nicht gezwungen werden, von Schnittstellen abzuhängen, die sie nicht verwenden. Anstelle einer großen Schnittstelle sind viele kleine, zweckspezifische Schnittstellen besser.
- Dependency Inversion Principle (DIP): Hochrangige Module sollten nicht von niedrigrangigen Modulen abhängen. Beide sollten von Abstraktionen abhängen. Abstraktionen sollten nicht von Details abhängen. Details sollten von Abstraktionen abhängen.
Entwurfsmuster sind verallgemeinerte, wiederverwendbare Lösungen für häufige Probleme, die während des Softwaredesigns auftreten. Sie sind keine direkten Lösungen, sondern Vorlagen oder Baupläne, die angepasst werden können, um spezifische Probleme zu lösen. Wir werden uns auf einige gängige Muster konzentrieren, die für die Backend-Entwicklung relevant sind:
- Singleton-Muster: Stellt sicher, dass eine Klasse nur eine Instanz hat und stellt einen globalen Zugriffspunkt darauf bereit.
- Factory-Methoden-Muster: Definiert eine Schnittstelle zur Erstellung eines Objekts, erlaubt es aber den Unterklassen zu entscheiden, welche Klasse instanziiert werden soll. Es verschiebt die Instanziierung auf Unterklassen.
- Strategy-Muster: Definiert eine Familie von Algorithmen, kapselt jeden einzelnen und macht sie austauschbar. Strategy ermöglicht es dem Algorithmus, unabhängig von den Clients, die ihn verwenden, zu variieren.
- Repository-Muster: Vermittelt zwischen den Domänen- und Datenabbildungsschichten und verwendet eine sammlungsähnliche Schnittstelle für den Zugriff auf Domänenobjekte.
Praktische Anwendung in TypeScript-Backends
Lassen Sie uns untersuchen, wie sich diese Prinzipien und Muster in einem TypeScript-Backend-Kontext in umsetzbaren Code übersetzen lassen, wobei ein typisches Szenario wie die Auftragsabwicklung in einem E-Commerce-System verwendet wird.
Single Responsibility Principle (SRP)
Betrachten Sie eine OrderService
-Klasse. Ohne SRP könnte sie sich um die Auftragserstellung, Zahlungsabwicklung, Benachrichtigungen und Protokollierung kümmern. Dies macht sie schwer zu warten und zu testen.
Problematisches Design (verstößt gegen SRP):
// services/order.service.ts class OrderService { createOrder(userId: string, itemIds: string[]): Order { // 1. Eingabe validieren // 2. Auftrag in der Datenbank speichern // 3. Zahlung abwickeln // 4. E-Mail zur Bestellbestätigung senden // 5. Ereignis zur Auftragserstellung protokollieren // ... viele Verantwortlichkeiten return new Order(); // Vereinfacht } }
SRP-konformes Design:
Wir zerlegen den OrderService
in mehrere separate Klassen, jede mit einer einzelnen Verantwortung.
// 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..."); // DB-Interaktion simulieren return { id: 'order-123', userId: orderData.userId, status: 'pending' }; } // ... andere Persistenzmethoden wie 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}`); // Zahlungs-Gateway-Interaktion simulieren 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}`); // E-Mail-Versand simulieren } } // services/logger.service.ts class LoggerService { log(message: string, context?: object): void { console.log(`LOG: ${message}`, context); } } // services/order.service.ts (jetzt Orchestrator) class OrderCreationService { constructor( private orderRepository: OrderRepository, private paymentService: PaymentService, private notificationService: NotificationService, private loggerService: LoggerService ) {} async createOrder(request: OrderCreationRequest, userEmail: string): Promise<Order> { // Eingabevalidierung kann ein weiterer Dienst oder eine Middleware/ein Dekorator sein const newOrder = await this.orderRepository.create(request); await this.paymentService.processPayment(newOrder.id, 100); // Betrag annehmen await this.notificationService.sendOrderConfirmationEmail(newOrder, userEmail); this.loggerService.log('Order created successfully', { orderId: newOrder.id, userId: newOrder.userId }); return newOrder; } } // Verwendung in einem Express-Routen-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); // });
Diese Refaktorierung macht jede Klasse leichter verständlich, testbar und wartbar. Wenn sich die Zahlungslogik ändert, muss nur PaymentService
geändert werden.
Open/Closed Principle (OCP) und Strategy-Muster
Angenommen, unsere Zahlungsabwicklung muss mehrere Zahlungs-Gateways unterstützen (Stripe, PayPal usw.). Die direkte Änderung von PaymentService
jedes Mal, wenn wir ein neues Gateway hinzufügen, verstößt gegen OCP. Wir können das Strategy-Muster in Verbindung mit OCP verwenden.
Verstößt gegen OCP:
// services/payment.service.ts class PaymentService { async processPayment(orderId: string, amount: number, paymentMethod: 'stripe' | 'paypal'): Promise<boolean> { if (paymentMethod === 'stripe') { // Stripe-spezifische Logik } else if (paymentMethod === 'paypal') { // PayPal-spezifische Logik } else { throw new Error('Unsupported payment method'); } return true; } }
OCP- und Strategy-konformes 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}`); // Stripe API aufrufen 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}`); // PayPal API aufrufen 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); } } // Verwendung // const paymentProcessor = new PaymentProcessor(); // // Für Stripe-Zahlung // paymentProcessor.setPaymentGateway(new StripeGateway()); // await paymentProcessor.executePayment(50, 'order-456'); // // Für PayPal-Zahlung // paymentProcessor.setPaymentGateway(new PayPalGateway()); // await paymentProcessor.executePayment(75, 'order-789');
Nun können wir, um ein neues Zahlungs-Gateway hinzuzufügen, einfach eine neue Klasse erstellen, die PaymentGateway
implementiert, und sie in PaymentProcessor
injizieren. Es sind keine Änderungen an PaymentProcessor
erforderlich, was OCP entspricht.
Liskov Substitution Principle (LSP)
Betrachten wir verschiedene Arten des Versands. Wenn wir StandardShipping
und ExpressShipping
Klassen haben, sollte ExpressShipping
überall dort verwendet werden können, wo StandardShipping
erwartet wird, ohne die Funktionalität zu beeinträchtigen.
// 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; // Einfache Berechnung } async deliver(orderId: string, address: string): Promise<boolean> { console.log(`Delivering order ${orderId} to ${address} via Standard Shipping`); // Standardlieferung simulieren return true; } } // services/express-shipping.ts class ExpressShipping implements ShippingService { calculateCost(weight: number, distance: number): number { return weight * 1.5 + distance * 0.3 + 10; // Höhere Kosten } async deliver(orderId: string, address: string): Promise<boolean> { console.log(`Delivering order ${orderId} to ${address} via Express Shipping`); // Expresslieferung simulieren return true; } // Keine "überflüssigen" Methoden, die den Kontext stören könnten } // Verwendung 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);
Sowohl StandardShipping
als auch ExpressShipping
können ohne Probleme für ShippingService
substituiert werden, wodurch LSP eingehalten wird.
Interface Segregation Principle (ISP)
Anstatt einer monolithischen UserRepository
-Schnittstelle mit vielen Methoden können wir sie aufteilen.
Verstößt gegen 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>; // Enthält Methoden für Profilverwaltung, Authentifizierung usw. updateProfile(userId: string, profileData: Partial<UserProfile>): Promise<UserProfile>; resetPassword(userId: string, newPasswordHash: string): Promise<boolean>; }
ISP-konformes 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>; } // Tatsächliche Implementierungsklassen implementieren möglicherweise mehrere spezifische Schnittstellen 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; } } // Ein Dienst, der nur Benutzerdaten lesen muss, hängt nur von `IUserReadRepository` ab und minimiert so seine Kopplung.
Dies verhindert, dass Clients (wie Dienste) von Methoden abhängen, die sie nicht verwenden, wodurch das System modularer und leichter zu refaktorieren ist.
Dependency Inversion Principle (DIP) und Repository-Muster
DIP schlägt vor, dass hochrangige Module nicht von niedrigrangigen Modulen abhängen sollten; beide sollten von Abstraktionen abhängen. Das Repository-Muster passt natürlich zu DIP. Anstatt dass unser OrderService
direkt MongoDB
oder PostgreSQL
kennt, sollte er von einer OrderRepository
-Abstraktion abhängen.
Verstößt gegen DIP:
// services/order.service.ts import { MongoClient } from 'mongodb'; // Direkte Abhängigkeit von niedrigrangigen Details 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- und Repository-Muster-konformes 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 (Niedrigrangiges Modul) 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 (Hochrangiges Modul) class OrderManagementService { constructor(private orderRepository: IOrderRepository) {} // Abhängig von Abstraktion 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.`); } // Geschäftslogik für die Erfüllung return this.orderRepository.updateStatus(orderId, 'fulfilled'); } } // Verwendung im Hauptanwendungs-Setup mit einfacher Abhängigkeitsinjektion (manuell) // const mongoClient = await MongoClient.connect('mongodb://localhost:27017'); // const orderRepository: IOrderRepository = new MongoOrderRepository(mongoClient); // const orderManagementService = new OrderManagementService(orderRepository); // // In einer Route (z. B. 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 }); // } // });
Hier kümmert sich OrderManagementService
nicht um die spezifischen Details von MongoDB
; er interagiert nur mit der IOrderRepository
-Schnittstelle. Dies ermöglicht es uns, MongoOrderRepository
mit z. B. PostgresOrderRepository
oder InMemoryOrderRepository
(für Tests) auszutauschen, ohne OrderManagementService
zu ändern.
Singleton-Muster
Obwohl es oft kritisiert wird, weil es Abhängigkeiten verschleiert, kann das Singleton-Muster nützlich sein für Ressourcen, die nur eine Instanz haben dürfen, wie ein Datenbankverbindungs-Pool oder ein globaler Konfigurationsdienst.
// util/database.ts import { MongoClient, Db } from 'mongodb'; class Database { private static instance: Database; private client: MongoClient; private db: Db; private constructor() { // Direkte Instanziierung verhindern 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()) { // Alte Methode, Prüfung auf tatsächliche Verbindung, nicht nur auf Clientstatus 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"); } } } // Verwendung: // const dbInstance = Database.getInstance(); // await dbInstance.connect(); // const db = dbInstance.getDb(); // const usersCollection = db.collection('users'); // // ... usersCollection verwenden ...
Dies stellt sicher, dass während der gesamten Anwendung nur eine einzige aktive Verbindung zur Datenbank besteht.
Factory-Methoden-Muster
Dieses Muster eignet sich hervorragend für die Erstellung von Objekten, deren genauer Typ erst zur Laufzeit bekannt ist, oder zur Zentralisierung der Objekterstellung.
// 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'); } } } // Verwendung in einem Admin-Dienst oder API-Endpunkt // 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());
Die ProductFactory
abstrahiert die Erstellungslogik und ermöglicht es uns, neue Produkttypen einzuführen, ohne den Client-Code, der die Factory verwendet, zu ändern.
Fazit
Die umsichtige Anwendung von SOLID-Prinzipien und Entwurfsmustern in der TypeScript-Backend-Entwicklung verwandelt komplexe Systeme in gut strukturierte, verständliche und anpassungsfähige Anwendungen. SOLID-Prinzipien leiten uns beim Entwurf von Klassen und Modulen, die kohäsiv und lose gekoppelt sind, während Entwurfsmuster bewährte, wiederverwendbare Lösungen für häufige wiederkehrende Probleme bieten. Durch die Übernahme dieser Konzepte können Entwickler Backend-Systeme aufbauen, die nicht nur heute leistungsfähig und effizient sind, sondern auch in Bezug auf Wartbarkeit, Skalierbarkeit und Widerstandsfähigkeit gegenüber zukünftigen Anforderungen und sich entwickelnder Geschäftslogik bestehen können. Letztendlich ermöglichen sie uns, Code zu schreiben, der für die Zusammenarbeit, gründliche Tests und selbstbewusste Erweiterungen einfacher ist.