SOLID-Prinzipien in NestJS-Backends implementieren
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Eine vollständige Übersicht über alle SOLID-Prinzipien ist nicht enthalten
Einleitung
In der sich ständig weiterentwickelnden Landschaft der Softwareentwicklung ist der Aufbau robuster, wartbarer und skalierbarer Systeme von größter Bedeutung. Da JavaScript weiterhin seine Dominanz behauptet, insbesondere mit dem Aufkommen von TypeScript, das statische Typisierung und verbesserte Vorhersagbarkeit mit sich bringt, hat die Backend-Entwicklung bedeutende Fortschritte gemacht. Frameworks wie NestJS mit seiner meinungsbildenden und modularen Architektur bieten eine hervorragende Grundlage für den Aufbau von Enterprise-Anwendungen. Einfach nur ein leistungsfähiges Framework zu verwenden, reicht jedoch nicht aus; wahre Qualität entsteht durch die Einhaltung grundlegender Designprinzipien. Hier kommen die SOLID-Prinzipien ins Spiel. Diese fünf Prinzipien – Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation und Dependency Inversion – bieten eine Roadmap für das Schreiben von saubererem, flexiblerem und widerstandsfähigerem Code. In einem TypeScript-Backend-Projekt, insbesondere einem, das NestJS nutzt, kann das Verständnis und die Anwendung der SOLID-Prinzipien die Codeorganisation drastisch verbessern, technische Schulden reduzieren und die Zusammenarbeit erleichtern. Dieser Artikel untersucht jedes SOLID-Prinzip im Detail und demonstriert seine praktische Anwendung in einem NestJS-Kontext anhand konkreter Codebeispiele.
Kernkonzepte erklärt
Bevor wir uns mit den praktischen Implementierungen befassen, definieren wir kurz die SOLID-Prinzipien:
- Single Responsibility Principle (SRP): Eine Klasse oder ein Modul sollte nur einen einzigen Grund zur Änderung haben. Das bedeutet, dass sie für eine einzige, klar definierte Funktionalität verantwortlich sein sollte.
- Open/Closed Principle (OCP): Software-Entitä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 vorhandenen, funktionierenden Code zu ändern.
- Liskov Substitution Principle (LSP): Subtypen müssen ohne Änderung der Korrektheit des Programms für ihre Basistypen austauschbar sein. Das bedeutet, dass, wenn
Sein Subtyp vonTist, Objekte vom TypTdurch Objekte vom TypSersetzt werden können, ohne die Anwendung zu stören. - Interface Segregation Principle (ISP): Clients sollten nicht gezwungen werden, von Schnittstellen abzuhängen, die sie nicht verwenden. Bevorzuge stattdessen viele kleinere, rollenspezifische Schnittstellen anstelle einer großen, allgemeinen Schnittstelle.
- Dependency Inversion Principle (DIP): High-Level-Module sollten nicht von Low-Level-Modulen abhängen; beide sollten von Abstraktionen abhängen. Abstraktionen sollten nicht von Details abhängen; Details sollten von Abstraktionen abhängen.
NestJS mit seinem starken Fokus auf Module, Services und Dependency Injection bietet eine ausgezeichnete Umgebung für die Praxis dieser Prinzipien.
Implementierung von SOLID-Prinzipien in NestJS
Lassen Sie uns diese Prinzipien anhand praktischer Beispiele in einer NestJS-Anwendung veranschaulichen, unter Berücksichtigung eines typischen E-Commerce-Szenarios.
Single Responsibility Principle (SRP)
In NestJS orchestrieren Controller oft Anfragen, Services verwalten die Geschäftslogik und Repositories kümmern sich um den Datenzugriff. Die Einhaltung von SRP bedeutet, diese Zuständigkeiten klar zu trennen.
Schlechtes Beispiel (Verletzt SRP):
// user.controller.ts (Beispielhaft - tut zu viel) import { Controller, Get, Post, Body } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './user.entity'; @Controller('users') export class UserController { constructor( @InjectRepository(User) private usersRepository: Repository<User>, ) {} @Post() async createUser(@Body() userData: any): Promise<User> { // Logik zum Erstellen von Benutzern, Hashing von Passwörtern, Senden von Willkommens-E-Mails usw. const newUser = this.usersRepository.create(userData); await this.usersRepository.save(newUser); // Stellen Sie sich vor, hier eine E-Mail-Logik zu senden console.log('Willkommens-E-Mail wird gesendet...'); return newUser; } @Get(':id') async getUser(@Param('id') id: string): Promise<User> { return this.usersRepository.findOne(id); } }
Dieser UserController ist dafür verantwortlich, HTTP-Anfragen zu bearbeiten, Benutzer zu erstellen, sie in der Datenbank zu speichern und potenziell E-Mails zu senden. Wenn sich eine dieser Zuständigkeiten ändert, müsste der UserController geändert werden.
Gutes Beispiel (Hält sich an SRP):
// user.entity.ts import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column({ unique: true }) email: string; @Column() passwordHash: string; // Speichern des gehashten Passworts } // user.repository.ts (Verwaltet den Datenzugriff für Benutzer) import { EntityRepository, Repository } from 'typeorm'; import { User } from './user.entity'; @EntityRepository(User) export class UserRepository extends Repository<User> { // Benutzerdefinierte Datenzugriffsmethoden können hier hinzugefügt werden } // email.service.ts (Verwaltet die Zuständigkeit des E-Mail-Versands) import { Injectable } from '@nestjs/common'; @Injectable() export class EmailService { async sendWelcomeEmail(to: string, username: string): Promise<void> { console.log(`Willkommens-E-Mail wird an ${username} an ${to} gesendet`); // Tatsächliche E-Mail-Versandlogik hier (z. B. mit nodemailer) } } // user.service.ts (Verwaltet die Geschäftslogik für Benutzer) import { Injectable } from '@nestjs/common'; import { UserRepository } from './user.repository'; import { EmailService } from '../email/email.service'; import * as bcrypt from 'bcrypt'; // Zum Hashing von Passwörtern @Injectable() export class UserService { constructor( private readonly userRepository: UserRepository, private readonly emailService: EmailService, ) {} async createUser(email: string, passwordPlain: string): Promise<User> { const passwordHash = await bcrypt.hash(passwordPlain, 10); const newUser = this.userRepository.create({ email, passwordHash }); await this.userRepository.save(newUser); await this.emailService.sendWelcomeEmail(email, email); // Einfachheit halber wird die E-Mail als Benutzername verwendet return newUser; } async findUserById(id: number): Promise<User> { return this.userRepository.findOne(id); } } // user.controller.ts (Verwaltet HTTP-Anfragen, delegiert an den Service) import { Controller, Get, Post, Body, Param } from '@nestjs/common'; import { UserService } from './user.service'; import { User } from './user.entity'; @Controller('users') export class UserController { constructor(private readonly userService: UserService) {} @Post() async createUser(@Body() userData: { email: string; passwordPlain: string }): Promise<User> { return this.userService.createUser(userData.email, userData.passwordPlain); } @Get(':id') async getUser(@Param('id') id: string): Promise<User> { return this.userService.findUserById(parseInt(id, 10)); } }
Jetzt ist UserController nur für HTTP-Anfragen zuständig, UserService verwaltet die benutzerspezifische Geschäftslogik, UserRepository kümmert sich um die Datenpersistenz und EmailService ist allein für das Senden von E-Mails verantwortlich. Jede Klasse hat nur einen Grund zur Änderung.
Open/Closed Principle (OCP)
Um OCP zu veranschaulichen, betrachten wir ein Szenario mit verschiedenen Zahlungsmethoden in einer E-Commerce-Anwendung.
Schlechtes Beispiel (Verletzt OCP):
// payment.service.ts import { Injectable } from '@nestjs/common'; @Injectable() export class PaymentService { processPayment(amount: number, method: 'credit_card' | 'paypal' | 'stripe'): string { if (method === 'credit_card') { // Logik für Kreditkartenzahlung return `Verarbeitete Kreditkartenzahlung von $${amount}`; } else if (method === 'paypal') { // Logik für PayPal-Zahlung return `Verarbeitete PayPal-Zahlung von $${amount}`; } else if (method === 'stripe') { // Logik für Stripe-Zahlung return `Verarbeitete Stripe-Zahlung von $${amount}`; } else { throw new Error('Nicht unterstützte Zahlungsmethode'); } } }
Das Hinzufügen einer neuen Zahlungsmethode (z. B. "Google Pay") würde eine Änderung der Methode processPayment erfordern und somit OCP verletzen.
Gutes Beispiel (Hält sich an OCP):
// payment-method.interface.ts export interface IPaymentMethod { processPayment(amount: number): string; } // credit-card.payment.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class CreditCardPayment implements IPaymentMethod { processPayment(amount: number): string { // Kreditkartenspezifische Logik return `Verarbeitete Kreditkarten-Zahlung von $${amount}`; } } // paypal.payment.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class PayPalPayment implements IPaymentMethod { processPayment(amount: number): string { // PayPal-spezifische Logik return `Verarbeitete PayPal-Zahlung von $${amount}`; } } // stripe.payment.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class StripePayment implements IPaymentMethod { processPayment(amount: number): string { // Stripe-spezifische Logik return `Verarbeitete Stripe-Zahlung von $${amount}`; } } // payment.service.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class PaymentService { // NestJS Dependency Injection erlaubt uns, konkrete Implementierungen einzuspritzen // basierend auf einem Token oder im Modul bereitgestellt. // Wir können hier auch eine Fabrik oder ein Strategie-Muster haben. processPaymentWithStrategy(paymentMethod: IPaymentMethod, amount: number): string { return paymentMethod.processPayment(amount); } } // payment.module.ts (Beispiel, wie verschiedene Strategien bereitgestellt werden) import { Module } from '@nestjs/common'; import { PaymentService } from './payment.service'; import { CreditCardPayment } from './credit-card.payment'; import { PayPalPayment } from './paypal.payment'; import { StripePayment } from './stripe.payment'; import { IPaymentMethod } from './payment-method.interface'; const paymentMethodProviders = [ { provide: 'CREDIT_CARD_PAYMENT', useClass: CreditCardPayment, }, { provide: 'PAYPAL_PAYMENT', useClass: PayPalPayment, }, { provide: 'STRIPE_PAYMENT', useClass: StripePayment, }, ]; @Module({ providers: [PaymentService, ...paymentMethodProviders], exports: [PaymentService, ...paymentMethodProviders], }) export class PaymentModule {} // payment.controller.ts import { Controller, Post, Body, Inject } from '@nestjs/common'; import { PaymentService } from './payment.service'; import { IPaymentMethod } from './payment-method.interface'; @Controller('payments') export class PaymentController { constructor( private readonly paymentService: PaymentService, @Inject('CREDIT_CARD_PAYMENT') private readonly creditCardPayment: IPaymentMethod, @Inject('PAYPAL_PAYMENT') private readonly payPalPayment: IPaymentMethod, @Inject('STRIPE_PAYMENT') private readonly stripePayment: IPaymentMethod, ) {} @Post('process') processPayment(@Body() data: { amount: number; method: string }): string { let paymentStrategy: IPaymentMethod; switch (data.method) { case 'credit_card': paymentStrategy = this.creditCardPayment; break; case 'paypal': paymentStrategy = this.payPalPayment; break; case 'stripe': paymentStrategy = this.stripePayment; break; default: throw new Error('Nicht unterstützte Zahlungsmethode'); } return this.paymentService.processPaymentWithStrategy(paymentStrategy, data.amount); } }
Nun können Sie eine neue Zahlungsmethode hinzufügen, indem Sie einfach eine neue Klasse erstellen, die IPaymentMethod implementiert, und sie im Modul registrieren. Der vorhandene PaymentService und PaymentController bleiben unverändert und halten sich an OCP.
Liskov Substitution Principle (LSP)
LSP stellt sicher, dass neue abgeleitete Klassen die Basisklasse erweitern, ohne deren Verhalten zu ändern. Betrachten wir eine Product-Entität und ihre Untertypen.
Schlechtes Beispiel (Verletzt LSP):
// product.interface.ts export interface IProduct { getId(): string; getPrice(): number; // Eine Methode, die einen Rabatt für alle Produkte anwendet applyDiscount(percentage: number): number; } // tangible-product.class.ts export class TangibleProduct implements IProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } applyDiscount(percentage: number): number { return this.price * (1 - percentage / 100); } } // digital-product.class.ts (LSP-Verletzung) export class DigitalProduct implements IProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } // Digitale Produkte können aufgrund von Lizenzgebühren oder Fixkosten keine Rabatte erhalten applyDiscount(percentage: number): number { throw new Error('Digitale Produkte können nicht rabattiert werden.'); } } // Irgendwo in der Bestellverarbeitungslogik function calculateTotal(products: IProduct[]): number { let total = 0; for (const product of products) { // Diese Funktion erwartet, dass applyDiscount für alle Produkte funktioniert // Wenn ein DigitalProduct übergeben wird, wird ein Fehler ausgelöst, was den Vertrag bricht. total += product.applyDiscount(10); // Anwendung eines 10% Rabatts } return total; }
Hier löst die Methode applyDiscount von DigitalProduct einen Fehler aus und ändert das erwartete Verhalten von IProduct.applyDiscount. Wenn ein DigitalProduct dort verwendet wird, wo ein IProduct erwartet wird, kann das Programm abstürzen.
Gutes Beispiel (Hält sich an LSP):
Um dies zu beheben, können wir entweder die Schnittstelle neu bewerten oder DigitalProduct die Rabattanwendung auf eine Weise erlauben, die keine Ausnahmen auslöst (z. B. den ursprünglichen Preis zurückgeben). Ein besserer Ansatz für LSP könnte darin bestehen, Zuständigkeiten zu trennen oder Verhaltensweisen genauer zu definieren.
// discountable-product.interface.ts export interface IDiscountableProduct { applyDiscount(percentage: number): number; } // product.interface.ts (Basisprodukt ohne Rabattlogik) export interface IProduct { getId(): string; getPrice(): number; } // tangible-product.class.ts export class TangibleProduct implements IProduct, IDiscountableProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } applyDiscount(percentage: number): number { return this.price * (1 - percentage / 100); } } // digital-product.class.ts (Standardmäßig keine Rabattfunktionalität) export class DigitalProduct implements IProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } // Keine applyDiscount-Methode oder sie gibt den ursprünglichen Preis zurück } // Irgendwo in der Bestellverarbeitungslogik function calculateDiscountedTotal(products: IDiscountableProduct[]): number { let total = 0; for (const product of products) { total += product.applyDiscount(10); // Jetzt ist es sicher, da nur rabattierbare Produkte übergeben werden } return total; } function calculateTotal(products: IProduct[]): number { let total = 0; for (const product of products) { total += product.getPrice(); } return total; }
Jetzt erhalten Funktionen, die rabattierbare Produkte erwarten, nur diese, wodurch sichergestellt wird, dass sich das Verhalten nicht unerwartet ändert. Dies berührt auch ISP.
Interface Segregation Principle (ISP)
ISP legt nahe, dass Clients nicht gezwungen werden sollten, von Schnittstellen abzuhängen, die sie nicht verwenden.
Schlechtes Beispiel (Verletzt ISP):
// user-management.interface.ts (Fat Interface) export interface IUserManager { createUser(data: any): Promise<any>; updateUser(id: string, data: any): Promise<any>; deleteUser(id: string): Promise<void>; getUsers(): Promise<any[]>; resetPassword(userId: string): Promise<void>; assignRole(userId: string, role: string): Promise<void>; // ... und andere nicht verwandte Methoden wie sendWelcomeEmail, generateReport usw. } // Ein Dienst, der nur Benutzer erstellen und abrufen muss, aber die gesamte Schnittstelle implementiert export class UserDisplayService implements IUserManager { createUser(data: any): Promise<any> { /* ... */ } updateUser(id: string, data: any): Promise<any> { throw new Error('Nicht implementiert'); } // Nicht benötigt deleteUser(id: string): Promise<void> { throw new Error('Nicht implementiert'); } // Nicht benötigt getUsers(): Promise<any[]> { /* ... */ } resetPassword(userId: string): Promise<void> { throw new Error('Nicht implementiert'); } // Nicht benötigt assignRole(userId: string, role: string): Promise<void> { throw new Error('Nicht implementiert'); } // Nicht benötigt }
UserDisplayService ist gezwungen, Methoden zu implementieren, die es nicht benötigt, was zu unnötiger Komplexität und Fehlern führen kann (z. B. Auslösen von Not implemented-Fehlern).
Gutes Beispiel (Hält sich an ISP):
// user-creator.interface.ts export interface IUserCreator { createUser(data: any): Promise<any>; } // user-reader.interface.ts export interface IUserReader { getUsers(): Promise<any[]>; getUserById(id: string): Promise<any>; } // user-updater.interface.ts export interface IUserUpdater { updateUser(id: string, data: any): Promise<any>; resetPassword(userId: string): Promise<void>; } // user-deleter.interface.ts export interface IUserDeleter { deleteUser(id: string): Promise<void>; } // Ein Dienst zur Anzeige von Benutzern benötigt nur IUserReader export class UserDisplayService implements IUserReader { constructor(/* Abhängigkeiten */) {} getUsers(): Promise<any[]> { /* ... */ } getUserById(id: string): Promise<any> { /* ... */ } } // Ein Dienst für die vollständige Benutzerverwaltung kann mehrere kleinere Schnittstellen implementieren export class FullUserService implements IUserCreator, IUserReader, IUserUpdater, IUserDeleter { constructor(/* Abhängigkeiten */) {} createUser(data: any): Promise<any> { /* ... */ } getUsers(): Promise<any[]> { /* ... */ } getUserById(id: string): Promise<any> { /* ... */ } updateUser(id: string, data: any): Promise<any> { /* ... */ } resetPassword(userId: string): Promise<void> { /* ... */ } deleteUser(id: string): Promise<void> { /* ... */ } }
Jetzt hängt UserDisplayService nur von IUserReader ab und eliminiert die Notwendigkeit, nicht verwendete Methoden zu implementieren. Dies macht den Code sauberer, fokussierter und leichter zu testen.
Dependency Inversion Principle (DIP)
DIP betont die Abhängigkeit von Abstraktionen anstelle von konkreten Implementierungen. Der Dependency Injection Container von NestJS unterstützt DIP stark.
Schlechtes Beispiel (Verletzt DIP):
// email-sender.class.ts (Konkretes Low-Level-Modul) export class SMSEmailSender { send(to: string, subject: string, body: string): void { console.log(`Senden von SMS-E-Mail an ${to}: ${subject} - ${body}`); // Simulation des Sendens über ein bestimmtes SMS-zu-E-Mail-Gateway } } // user.service.ts import { Injectable } from '@nestjs/common'; import { SMSEmailSender } from './email-sender.class'; // Direkte Abhängigkeit von konkreter Klasse @Injectable() export class UserService { private emailSender: SMSEmailSender; constructor() { this.emailSender = new SMSEmailSender(); // High-Level-Modul erstellt Low-Level-Modul } async registerUser(email: string, passwordHash: string): Promise<void> { // ... Logik zur Benutzerregistrierung ... this.emailSender.send(email, 'Willkommen!', 'Vielen Dank für Ihre Registrierung.'); } }
UserService (High-Level-Modul) hängt direkt von SMSEmailSender (Low-Level-Modul) ab. Wenn wir zu einem anderen E-Mail-Versandmechanismus wechseln möchten (z. B. über SendGrid oder Mailgun), müsste UserService geändert werden.
Gutes Beispiel (Hält sich an DIP):
// email-sender.interface.ts (Abstraktion) export interface IEmailSender { send(to: string, subject: string, body: string): Promise<void>; } // sms-email-sender.class.ts (Konkretes Low-Level-Modul, hängt von der Abstraktion ab) import { Injectable } from '@nestjs/common'; import { IEmailSender } from './email-sender.interface'; @Injectable() export class SMSEmailSender implements IEmailSender { async send(to: string, subject: string, body: string): Promise<void> { console.log(`Senden von SMS-E-Mail an ${to}: ${subject} - ${body}`); // Tatsächliche SMS-zu-E-Mail-Gateway-Integration } } // sendgrid-email-sender.class.ts (Ein weiteres konkretes Low-Level-Modul) import { Injectable } from '@nestjs/common'; import { IEmailSender } from './email-sender.interface'; @Injectable() export class SendGridEmailSender implements IEmailSender { async send(to: string, subject: string, body: string): Promise<void> { console.log(`Senden über SendGrid an ${to}: ${subject} - ${body}`); // Tatsächliche SendGrid API-Integration } } // user.service.ts (High-Level-Modul, hängt von der Abstraktion ab) import { Injectable, Inject } from '@nestjs/common'; import { IEmailSender } from './email-sender.interface'; @Injectable() export class UserService { constructor( @Inject('EMAIL_SENDER') private readonly emailSender: IEmailSender, // Hängt von Abstraktion ab ) {} async registerUser(email: string, passwordHash: string): Promise<void> { // ... Logik zur Benutzerregistrierung ... await this.emailSender.send(email, 'Willkommen!', 'Vielen Dank für Ihre Registrierung.'); } } // user.module.ts (Verdrahtung von Abhängigkeiten mithilfe des NestJS DI-Systems) import { Module } from '@nestjs/common'; import { UserService } from './user.service'; import { SMSEmailSender } from './sms-email-sender.class'; import { SendGridEmailSender } from './sendgrid-email-sender.class'; import { IEmailSender } from './email-sender.interface'; @Module({ providers: [ UserService, // Wählen Sie die bereitzustellende Implementierung basierend auf Feature-Flags, Umgebung usw. { provide: 'EMAIL_SENDER', // Token für die Abhängigkeit useClass: SMSEmailSender, // Die zu verwendende konkrete Implementierung // In einem echten Projekt könnten Sie dies wechseln: // useClass: process.env.EMAIL_PROVIDER === 'SENDGRID' ? SendGridEmailSender : SMSEmailSender, }, ], exports: [UserService], }) export class UserModule {}
Jetzt hängt UserService von der IEmailSender-Abstraktion ab. Die tatsächliche Implementierung (SMSEmailSender oder SendGridEmailSender) wird auf Modulebene (oder sogar zur Laufzeit/Konfiguration) bestimmt, ohne dass UserService geändert werden muss. Dies bietet hervorragende Flexibilität und Testbarkeit.
Fazit
Die Übernahme der SOLID-Prinzipien in einem TypeScript-Backend-Projekt mit NestJS ist nicht nur eine Frage der Befolgung von Regeln; es geht darum, eine Denkweise zu kultivieren, die zur Erstellung flexibler, robuster und wartbarer Software führt. Von der Sicherstellung, dass jede Komponente einen klaren, einzelnen Zweck hat (SRP), über das Entwerfen von Systemen, die problemlos neue Funktionen aufnehmen können, ohne bestehenden Code zu stören (OCP), über den Aufbau zuverlässiger Vererbungshierarchien (LSP) bis hin zur Erstellung agiler und fokussierter Schnittstellen (ISP) und schließlich zur Entkopplung von High-Level-Richtlinien von Low-Level-Details durch Abstraktionen (DIP) – SOLID-Prinzipien bieten einen unschätzbaren Rahmen. Die Architekturmuster von NestJS – Module, Services, Repositories und sein leistungsstarkes Dependency-Injection-System – stimmen natürlich mit der Anwendung dieser Prinzipien überein und erleichtern sie, wodurch Entwickler komplexe, skalierbare Anwendungen mit Zuversicht und Leichtigkeit erstellen können. Durch die bewusste Anwendung der SOLID-Prinzipien während des gesamten Entwicklungszyklus heben Sie Ihre Codebasis von funktional zu beispielhaft.

