As the name suggests, these patterns address problems related to the creation of objects.
The Factory pattern provides an interface for creating objects in a superclass but allows subclasses to alter the type of objects that will be created.
interface Shape {
draw(): void;
}
class Circle implements Shape {
draw(): void {
console.log("Drawing a Circle");
}
}
class Rectangle implements Shape {
draw(): void {
console.log("Drawing a Rectangle");
}
}
class ShapeFactory {
static createShape(shapeType: string): Shape | null {
if (shapeType === "circle") {
return new Circle();
} else if (shapeType === "rectangle") {
return new Rectangle();
}
return null; // Return null for unsupported shapes
}
}
const circle: Shape | null = ShapeFactory.createShape("circle");
circle?.draw(); // Output: Drawing a Circle
const rectangle: Shape | null = ShapeFactory.createShape("rectangle");
rectangle?.draw(); // Output: Drawing a Rectangle
The Builder pattern allows step-by-step creation of complex objects. It helps construct objects with multiple optional parameters in a readable and organized way.
class Car {
model: string;
engine: string;
wheels: number;
color?: string;
sunroof?: boolean;
constructor(
model: string,
engine: string,
wheels: number,
color?: string,
sunroof?: boolean
) {
this.model = model;
this.engine = engine;
this.wheels = wheels;
this.color = color;
this.sunroof = sunroof;
}
showDetails(): void {
console.log(`Car Model: ${this.model}, Engine: ${this.engine}, Wheels: ${this.wheels}, Color: ${this.color || "Not specified"}, Sunroof: ${this.sunroof ? "Yes" : "No"}`);
}
}
class CarBuilder {
private model: string;
private engine: string;
private wheels: number;
private color?: string;
private sunroof?: boolean;
constructor(model: string, engine: string, wheels: number) {
this.model = model;
this.engine = engine;
this.wheels = wheels;
}
setColor(color: string): CarBuilder {
this.color = color;
return this; // Return this for method chaining
}
setSunroof(sunroof: boolean): CarBuilder {
this.sunroof = sunroof;
return this;
}
build(): Car {
return new Car(this.model, this.engine, this.wheels, this.color, this.sunroof);
}
}
const car1 = new CarBuilder("Tesla Model S", "Electric", 4)
.setColor("Red")
.setSunroof(true)
.build();
car1.showDetails();
// Output: Car Model: Tesla Model S, Engine: Electric, Wheels: 4, Color: Red, Sunroof: Yes
const car2 = new CarBuilder("Ford Mustang", "V8", 4)
.setColor("Blue")
.build();
car2.showDetails();
// Output: Car Model: Ford Mustang, Engine: V8, Wheels: 4, Color: Blue, Sunroof: No
The Named Parameter pattern uses an object parameter in the constructor, thus providing a readable way to create objects with multiple parameters.
class Car {
model: string;
engine: string;
wheels: number;
color: string;
sunroof: boolean;
constructor({
model,
engine,
wheels,
color = "Black",
sunroof = false
}: {
model: string;
engine: string;
wheels: number;
color?: string;
sunroof?: boolean
}) {
this.model = model;
this.engine = engine;
this.wheels = wheels;
this.color = color;
this.sunroof = sunroof;
}
}
const car = new Car({ model: "Tesla Model S", engine: "Electric", wheels: 4 });
console.log(car);
// Output: { model: 'Tesla Model S', engine: 'Electric', wheels: 4, color: 'Black', sunroof: false }
For simple objects, using an object parameter in the constructor is a great approach. However, for more complex scenarios where method chaining and stepwise construction are beneficial, the Builder Pattern is the better choice.
The Singleton pattern ensures that a class has only one instance and provides a global access point to it. This is useful for managing shared resources like database connections, logging, or configuration settings.
/* logger.ts */
class Logger {
log(message: string): void {
console.log(`[LOG]: ${message}`);
}
}
// Exporting a single instance (Singleton)
export default new Logger();
/* index.ts */
import Logger1 from "./logger";
import Logger2 from "./logger";
Logger1.log('this is a log message'); // [LOG]: this is a log message
Logger1.log('this is another log message'); // [LOG]: this is another log message
console.log(Logger1 === Logger2); // true (both references point to the same instance)
Using ES Modules to implement Singleton is a great approach because when a module is imported, its exports are cached, meaning new Logger() is only executed once.
global.Logger = new Logger();
Instead of exporting the instance with export 'default new Logger();' we can also utilise a global variable to store it, so it's available from everywhere in the application.
Dependency Injection (DI) is a very simple pattern in which the dependencies of a component are provided as input by an external entity, often referred to as the injector. The main advantage of this approach is improved decoupling, especially for modules depending on stateful instances (for example, a database connection). Using DI, each dependency, instead of being hardcoded into the module, is received from the outside. This means that the dependent module can be configured to use any compatible dependency, and therefore the module itself can be reused in different contexts with minimal effort.
// Logger Service - responsible for logging messages
class Logger {
log(message: string): void {
console.log(`[LOG]: ${message}`);
}
}
// UserService - responsible for managing users
class UserService {
private logger: Logger;
// Dependency Injection through the constructor
constructor(logger: Logger) {
this.logger = logger;
}
// Use the injected Logger to log user creation
createUser(name: string): void {
this.logger.log(`Creating user: ${name}`);
console.log(`User ${name} created.`);
}
}
// Create an instance of Logger
const logger = new Logger();
// Inject the logger instance into the UserService
const userService = new UserService(logger);
// Use the userService to create a user
userService.createUser("John Doe");
Structural design patterns are focused on providing ways to realize relationships between entities.
A proxy is an object that controls access to another object, called the subject. The proxy and the subject have an identical interface, and this allows us to swap one for the other transparently; in fact, the alternative name for this pattern is surrogate.
// Define the interface (common to both Real and Proxy)
interface Database {
query(sql: string): void;
}
// Create the Real Database Service
class RealDatabase implements Database {
query(sql: string): void {
console.log(`Executing SQL Query: ${sql}`);
}
}
// Create the Proxy that wraps the Real Database
class DatabaseProxy implements Database {
private realDatabase: RealDatabase;
private logger: Logger;
constructor(realDatabase: RealDatabase, logger: Logger) {
this.realDatabase = realDatabase;
this.logger = logger;
}
query(sql: string): void {
// Add logging before executing the query
this.logger.log(`Query requested: ${sql}`);
// Forward the query to the real database
this.realDatabase.query(sql);
// Add logging after execution
this.logger.log(`Query executed: ${sql}`);
}
}
// Logger Class (to log messages)
class Logger {
log(message: string): void {
console.log(`[LOG]: ${message}`);
}
}
// Use the Proxy Instead of the Real Database
const realDatabase = new RealDatabase();
const logger = new Logger();
const databaseProxy = new DatabaseProxy(realDatabase, logger);
// Use the proxy instead of the real database
databaseProxy.query("SELECT * FROM users");
The Decorator pattern consists in dynamically augmenting the behavior of an existing object. It's different from classical inheritance, because the behavior is not added to all the objects of the same class, but only to the instances that are explicitly decorated. Implementation-wise, it is very similar to the Proxy pattern, but instead of enhancing or modifying the behavior of the existing interface of an object, it augments it with new functionalities.
// Define a common interface
interface DataService {
fetchData(): string;
}
// Create the Concrete Component (Real Data Service)
class RealDataService implements DataService {
fetchData(): string {
return "Data from API";
}
}
// Create the Base Decorator (Implements DataService)
class DataServiceDecorator implements DataService {
protected dataService: DataService;
constructor(dataService: DataService) {
this.dataService = dataService;
}
fetchData(): string {
return this.dataService.fetchData(); // Default behavior
}
}
// Create a Logging Decorator (Extends DataServiceDecorator)
class LoggingDecorator extends DataServiceDecorator {
fetchData(): string {
console.log("[LOG] Fetching data...");
const result = this.dataService.fetchData();
console.log("[LOG] Data fetched: " + result);
return result;
}
}
// Create a Caching Decorator (Another Extension)
class CachingDecorator extends DataServiceDecorator {
private cache: string | null = null;
fetchData(): string {
if (!this.cache) {
console.log("[CACHE MISS] Fetching fresh data...");
this.cache = this.dataService.fetchData();
} else {
console.log("[CACHE HIT] Returning cached data...");
}
return this.cache;
}
}
// Use Decorators Dynamically
const realService = new RealDataService();
// Wrap it with logging functionality
const loggedService = new LoggingDecorator(realService);
// Wrap it with caching functionality
const cachedService = new CachingDecorator(loggedService);
// Call the decorated service
console.log(cachedService.fetchData());
console.log(cachedService.fetchData()); // This should return cached data
The Adapter pattern allows us to access the functionality of an object using a different interface. A real-life example of an adapter would be a device that allows you to plug a USB Type-A cable into a USB Type-C port. In a generic sense, an adapter converts an object with a given interface so that it can be used in a context where a different interface is expected.
// New payment system expects this interface
interface NewPaymentProcessor {
processPayment(amount: number): void;
}
// Old payment system with a different method signature
class OldPaymentService {
makePayment(money: number): void {
console.log(`Processing payment of $${money} using the OLD system.`);
}
}
// The Adapter makes the old system compatible with the new interface
class PaymentAdapter implements NewPaymentProcessor {
private oldPaymentService: OldPaymentService;
constructor(oldPaymentService: OldPaymentService) {
this.oldPaymentService = oldPaymentService;
}
// Convert the new method call to the old system's method
processPayment(amount: number): void {
console.log("Adapter converting request...");
this.oldPaymentService.makePayment(amount);
}
}
const oldService = new OldPaymentService();
const adapter = new PaymentAdapter(oldService);
// Now we can use the old payment system with the new interface
adapter.processPayment(100);
The Facade pattern provides a simplified interface to a complex system. It acts as a high-level wrapper around a set of subsystems, making them easier to use.
// Subsystem 1: TV
class TV {
turnOn(): void {
console.log("TV is turned ON.");
}
turnOff(): void {
console.log("TV is turned OFF.");
}
setInput(source: string): void {
console.log(`TV input set to ${source}.`);
}
}
// Subsystem 2: Sound System
class SoundSystem {
turnOn(): void {
console.log("Sound system is turned ON.");
}
turnOff(): void {
console.log("Sound system is turned OFF.");
}
setVolume(level: number): void {
console.log(`Sound system volume set to ${level}.`);
}
}
// Subsystem 3: Streaming Service
class StreamingService {
login(): void {
console.log("Logged into the streaming service.");
}
playMovie(title: string): void {
console.log(`Now playing: ${title}`);
}
}
// The Facade provides a single entry point to the subsystems
class HomeTheaterFacade {
private tv: TV;
private soundSystem: SoundSystem;
private streamingService: StreamingService;
constructor(tv: TV, soundSystem: SoundSystem, streamingService: StreamingService) {
this.tv = tv;
this.soundSystem = soundSystem;
this.streamingService = streamingService;
}
// Simplified method to start watching a movie
watchMovie(title: string): void {
console.log("\n[Starting Movie Mode...]");
this.tv.turnOn();
this.tv.setInput("HDMI 1");
this.soundSystem.turnOn();
this.soundSystem.setVolume(10);
this.streamingService.login();
this.streamingService.playMovie(title);
console.log("[Enjoy your movie!]\n");
}
// Simplified method to turn everything off
endMovie(): void {
console.log("\n[Shutting Down Home Theater...]");
this.tv.turnOff();
this.soundSystem.turnOff();
console.log("[Home Theater is now OFF.]\n");
}
}
const tv = new TV();
const soundSystem = new SoundSystem();
const streamingService = new StreamingService();
// Create the facade
const homeTheater = new HomeTheaterFacade(tv, soundSystem, streamingService);
// Use the facade to simplify complex operations
homeTheater.watchMovie("Inception");
homeTheater.endMovie();
Behavioral design patterns focus on how objects communicate and interact with each other.
The Strategy pattern enables an object, called the context, to support variations in its logic by extracting the variable parts into separate, interchangeable objects called strategies. The context implements the common logic of a family of algorithms, while a strategy implements the mutable parts, allowing the context to adapt its behavior depending on different factors, such as an input value, a system configuration, or user preferences.
// Common interface for all payment strategies
interface PaymentStrategy {
pay(amount: number): void;
}
// Concrete Strategy 1: Credit Card Payment
class CreditCardPayment implements PaymentStrategy {
private cardNumber: string;
constructor(cardNumber: string) {
this.cardNumber = cardNumber;
}
pay(amount: number): void {
console.log(`Paid $${amount} using Credit Card: ${this.cardNumber}`);
}
}
// Concrete Strategy 2: PayPal Payment
class PayPalPayment implements PaymentStrategy {
private email: string;
constructor(email: string) {
this.email = email;
}
pay(amount: number): void {
console.log(`Paid $${amount} using PayPal (Email: ${this.email})`);
}
}
// Concrete Strategy 3: Bitcoin Payment
class BitcoinPayment implements PaymentStrategy {
private walletAddress: string;
constructor(walletAddress: string) {
this.walletAddress = walletAddress;
}
pay(amount: number): void {
console.log(`Paid $${amount} using Bitcoin (Wallet: ${this.walletAddress})`);
}
}
// Context class that uses a PaymentStrategy
class PaymentProcessor {
private strategy: PaymentStrategy;
constructor(strategy: PaymentStrategy) {
this.strategy = strategy;
}
// Set a different payment method dynamically
setPaymentStrategy(strategy: PaymentStrategy): void {
this.strategy = strategy;
}
// Process the payment using the current strategy
processPayment(amount: number): void {
this.strategy.pay(amount);
}
}
// Choose a payment method dynamically
const creditCard = new CreditCardPayment("1234-5678-9876-5432");
const payPal = new PayPalPayment("user@example.com");
const bitcoin = new BitcoinPayment("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa");
// Use the PaymentProcessor with different strategies
const paymentProcessor = new PaymentProcessor(creditCard);
paymentProcessor.processPayment(100); // Pay with Credit Card
paymentProcessor.setPaymentStrategy(payPal);
paymentProcessor.processPayment(200); // Pay with PayPal
paymentProcessor.setPaymentStrategy(bitcoin);
paymentProcessor.processPayment(300); // Pay with Bitcoin
The State pattern is a specialization of the Strategy pattern where the strategy changes depending on the state of the context. It allows an object to change its behavior when its internal state changes.
// Common interface for all order states
interface OrderState {
processOrder(): void;
shipOrder(): void;
deliverOrder(): void;
}
class PendingState implements OrderState {
private order: OrderContext;
constructor(order: OrderContext) {
this.order = order;
}
processOrder(): void {
console.log("Order is being processed...");
this.order.setState(new ShippedState(this.order)); // Move to the next state
}
shipOrder(): void {
console.log("Order can't be shipped yet! It's still pending.");
}
deliverOrder(): void {
console.log("Order can't be delivered! It's still pending.");
}
}
class ShippedState implements OrderState {
private order: OrderContext;
constructor(order: OrderContext) {
this.order = order;
}
processOrder(): void {
console.log("Order is already processed and shipped.");
}
shipOrder(): void {
console.log("Order is being shipped...");
this.order.setState(new DeliveredState(this.order)); // Move to the next state
}
deliverOrder(): void {
console.log("Order can't be delivered directly from shipped state.");
}
}
class DeliveredState implements OrderState {
private order: OrderContext;
constructor(order: OrderContext) {
this.order = order;
}
processOrder(): void {
console.log("Order is already delivered. Can't process again.");
}
shipOrder(): void {
console.log("Order is already delivered. Can't ship again.");
}
deliverOrder(): void {
console.log("Order has been delivered successfully!");
}
}
// Context class that holds the current state
class OrderContext {
private state: OrderState;
constructor() {
// Initial state is Pending
this.state = new PendingState(this);
}
// Method to change the state
setState(state: OrderState): void {
this.state = state;
}
// Delegate methods to the current state
processOrder(): void {
this.state.processOrder();
}
shipOrder(): void {
this.state.shipOrder();
}
deliverOrder(): void {
this.state.deliverOrder();
}
}
const order = new OrderContext();
order.processOrder(); // Moves from Pending to Shipped
order.shipOrder(); // Moves from Shipped to Delivered
order.deliverOrder(); // Delivered successfully
The Template pattern defines the skeleton of an algorithm in a base class but allows subclasses to override specific steps without changing the overall structure. The parent class defines the high-level algorithm while subclasses provide custom implementations for specific steps. The purpose of Template and Strategy is very similar, but the main difference between the two lies in their structure and implementation. Both allow us to change the variable parts of a component while reusing the common parts. However, while Strategy allows us to do it dynamically at runtime, with Template, the complete component is determined the moment the concrete class is defined.
abstract class Beverage {
// Template method: Defines the skeleton of the algorithm
prepare(): void {
this.boilWater();
this.brew();
this.pourInCup();
if (this.customerWantsCondiments()) {
this.addCondiments();
}
}
private boilWater(): void {
console.log("Boiling water...");
}
private pourInCup(): void {
console.log("Pouring into cup...");
}
// Abstract methods: Must be implemented by subclasses
protected abstract brew(): void;
protected abstract addCondiments(): void;
// Hook method: Can be overridden by subclasses (default: true)
protected customerWantsCondiments(): boolean {
return true;
}
}
class Tea extends Beverage {
protected brew(): void {
console.log("Steeping the tea...");
}
protected addCondiments(): void {
console.log("Adding lemon...");
}
}
class Coffee extends Beverage {
protected brew(): void {
console.log("Dripping coffee through filter...");
}
protected addCondiments(): void {
// Will never be called, but TypeScript still requires it to be defined.
}
protected customerWantsCondiments(): boolean {
return false; // Skip condiments
}
}
console.log("Making tea:");
const tea = new Tea();
tea.prepare();
console.log("\nMaking coffee:");
const coffee = new Coffee();
coffee.prepare();
The Middleware pattern is a behavioral pattern used to process requests sequentially through a pipeline of handlers. Each middleware can modify, pass, or stop the request before it reaches its destination.
type Middleware = (request: Custom.Request, next: () => void) => void;
namespace Custom {
export class Request {
user?: string;
isAuthenticated: boolean = false;
data: Record<string, unknown> = {};
}
}
// Logging middleware
const logger: Middleware = (request, next) => {
console.log("Logging request:", request);
next();
};
// Authentication middleware
const authenticate: Middleware = (request, next) => {
if (request.user) {
request.isAuthenticated = true;
console.log("User authenticated:", request.user);
} else {
console.log("User not authenticated");
}
next();
};
// Data processing middleware
const processData: Middleware = (request, next) => {
if (request.isAuthenticated) {
request.data = { message: "Welcome, " + request.user };
} else {
request.data = { error: "Unauthorized access" };
}
next();
};
class MiddlewarePipeline {
private middlewares: Middleware[] = [];
use(middleware: Middleware): void {
this.middlewares.push(middleware);
}
handle(request: Custom.Request): void {
let index = 0;
const next = () => {
if (index < this.middlewares.length) {
const middleware = this.middlewares[index++];
middleware(request, next);
}
};
next(); // Start execution
}
}
const pipeline = new MiddlewarePipeline();
pipeline.use(logger);
pipeline.use(authenticate);
pipeline.use(processData);
// Test with an authenticated user
const request1 = new Custom.Request();
request1.user = "Alice";
console.log("\nProcessing request for Alice:");
pipeline.handle(request1);
console.log("Final Request Object:", request1);
// Test with an unauthenticated user
const request2 = new Custom.Request();
console.log("\nProcessing request for anonymous user:");
pipeline.handle(request2);
console.log("Final Request Object:", request2);
The Command pattern turns a request into a standalone object, allowing it to be parameterized, queued, logged, and undone.
interface Command {
execute(): void;
}
// Receiver: Light
class Light {
turnOn() {
console.log("Light is ON");
}
turnOff() {
console.log("Light is OFF");
}
}
// Concrete Command: Turn Light On
class LightOnCommand implements Command {
constructor(private light: Light) {}
execute(): void {
this.light.turnOn();
}
}
// Concrete Command: Turn Light Off
class LightOffCommand implements Command {
constructor(private light: Light) {}
execute(): void {
this.light.turnOff();
}
}
class RemoteControl {
private command?: Command;
setCommand(command: Command) {
this.command = command;
}
pressButton() {
if (this.command) {
this.command.execute();
}
}
}
// Create devices (receivers)
const light = new Light();
// Create commands
const lightOn = new LightOnCommand(light);
const lightOff = new LightOffCommand(light);
// Create remote control (invoker)
const remote = new RemoteControl();
console.log("\nPressing the ON button:");
remote.setCommand(lightOn);
remote.pressButton();
console.log("\nPressing the OFF button:");
remote.setCommand(lightOff);
remote.pressButton();
The Observer pattern is where an object (Subject) maintains a list of observers that get notified whenever its state changes. In Node.js, it is commonly implemented using the EventEmitter class from the built-in events module.
import { EventEmitter } from "events";
class NewsAgency extends EventEmitter {
publishNews(article: string): void {
console.log(`Publishing: ${article}`);
this.emit("news", article); // Notify all subscribers
}
}
class Subscriber {
constructor(private name: string, private agency: NewsAgency) {
// Subscribe to news updates
instead of `this.update.bind(this)` we could have also used `(article) => this.update(article)`
agency.on("news", this.update.bind(this));
}
update(article: string): void {
console.log(`${this.name} received: ${article}`);
}
}
// Create a news agency (Publisher)
const agency = new NewsAgency();
// Create subscribers (Observers)
const alice = new Subscriber("Alice", agency);
const bob = new Subscriber("Bob", agency);
console.log("\nPublishing first article:");
agency.publishNews("Node.js 20 Released!");
console.log("\nPublishing second article:");
agency.publishNews("Observer Pattern in Node.js!");
SOLID is a set of five principles that help developers creaate flexible, and scalable software that is easier to understand, to maintain, to extend and to modify.
A class should have only one reason to change. Each class should focus on a single responsibility or function. If a class has multiple responsibilities, changes in one area might affect others, leading to unintended consequences. For example; instead of having a Report class that generates, prints, and saves reports, you should split it into separate classes like ReportGenerator, ReportPrinter, and ReportSaver.
// ❌ Violates SRP: The class handles report generation and saving
class Report {
generate(): string {
return "Report Content";
}
saveToFile(content: string): void {
console.log(`Saving report: ${content}`);
}
}
// ✅ SRP Applied: Separate concerns into different classes
class ReportGenerator {
generate(): string {
return "Report Content";
}
}
class ReportSaver {
save(content: string): void {
console.log(`Saving report: ${content}`);
}
}
// Usage
const generator = new ReportGenerator();
const saver = new ReportSaver();
const content = generator.generate();
saver.save(content);
A class should have only one reason to change. Separate ReportGenerator and ReportSaver instead of one Report class.
Software entities (classes, modules, functions) should be open for extension but closed for modification. You should be able to add new functionality without changing existing code. This is typically achieved using polymorphism and abstraction. Instead of modifying a class every time a new feature is needed, use interfaces or abstract classes so new features can be added without altering existing code.
// ❌ Violates OCP: Modifying the class every time a new discount type is added
class Invoice {
calculateTotal(amount: number, discountType: string): number {
if (discountType === "percentage") return amount * 0.9;
if (discountType === "fixed") return amount - 10;
return amount;
}
}
// ✅ OCP Applied: Use an interface to extend behavior without modifying existing code
interface Discount {
apply(amount: number): number;
}
class PercentageDiscount implements Discount {
apply(amount: number): number {
return amount * 0.9;
}
}
class FixedDiscount implements Discount {
apply(amount: number): number {
return amount - 10;
}
}
class InvoiceProcessor {
constructor(private discount: Discount) {}
calculateTotal(amount: number): number {
return this.discount.apply(amount);
}
}
// Usage
const invoice = new InvoiceProcessor(new PercentageDiscount());
console.log(invoice.calculateTotal(100)); // 90
Extend behavior via interfaces instead of modifying existing code. Use Discount interface instead of modifying Invoice directly.
Subtypes must be substitutable for their base types without altering the correctness of the program. If a class B is a subclass of class A, then objects of type A should be replaceable with objects of type B without causing unexpected behavior. If you have a Bird class with a Fly() method and create a subclass Penguin, which cannot fly, then Penguin violates LSP. A better design would be to have a FlyingBird and NonFlyingBird class.
// ❌ Violates LSP: The Penguin subclass breaks expectations of the Bird class
class Bird {
fly(): void {
console.log("Flying...");
}
}
class Penguin extends Bird {
fly(): void {
throw new Error("Penguins cannot fly!");
}
}
// ✅ LSP Applied: Separate behaviors properly
abstract class BirdLSP {
abstract move(): void;
}
class FlyingBird extends BirdLSP {
move(): void {
console.log("Flying...");
}
}
class NonFlyingBird extends BirdLSP {
move(): void {
console.log("Walking...");
}
}
// Usage
const eagle = new FlyingBird();
eagle.move(); // "Flying..."
const penguin = new NonFlyingBird();
penguin.move(); // "Walking..."
Subclasses should not break the behavior of their base classes. Separate FlyingBird and NonFlyingBird instead of forcing Penguin into Bird.
Clients should not be forced to depend on interfaces they do not use. Instead of creating large, all-purpose interfaces, break them down into smaller, more specific ones so classes only implement what they actually need. Instead of having a Worker interface with methods like work(), eat(), and sleep(), separate it into Workable and Eatable interfaces. This way, a robot class can implement only Workable without unnecessary methods.
// ❌ Violates ISP: Robot is forced to implement eat() when it doesn't need to
interface Worker {
work(): void;
eat(): void;
}
class HumanWorker implements Worker {
work(): void {
console.log("Working...");
}
eat(): void {
console.log("Eating...");
}
}
class RobotWorker implements Worker {
work(): void {
console.log("Working...");
}
eat(): void {
throw new Error("Robots don't eat!");
}
}
// ✅ ISP Applied: Separate interfaces
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
class Human implements Workable, Eatable {
work(): void {
console.log("Working...");
}
eat(): void {
console.log("Eating...");
}
}
class Robot implements Workable {
work(): void {
console.log("Working...");
}
}
// Usage
const human = new Human();
human.work();
human.eat();
const robot = new Robot();
robot.work();
Interfaces should be small and specific. Separate Workable and Eatable instead of a bloated Worker interface.
High-level modules should not depend on low-level modules. Both should depend on abstractions. Instead of tightly coupling classes, depend on abstractions (interfaces or abstract classes) to make the system more flexible and maintainable. A DatabaseService class should depend on an IDatabase interface rather than a concrete MySQLDatabase class. This allows swapping the database implementation without modifying the service.
// ❌ Violates DIP: High-level class is tightly coupled to a specific implementation
class MySQLDatabase {
save(data: string): void {
console.log(`Saving "${data}" to MySQL`);
}
}
class UserService {
private db: MySQLDatabase = new MySQLDatabase();
saveUser(name: string): void {
this.db.save(name);
}
}
// ✅ DIP Applied: Use an abstraction (interface) to decouple dependencies
interface Database {
save(data: string): void;
}
class MySQL implements Database {
save(data: string): void {
console.log(`Saving "${data}" to MySQL`);
}
}
class MongoDB implements Database {
save(data: string): void {
console.log(`Saving "${data}" to MongoDB`);
}
}
class UserServiceDIP {
constructor(private db: Database) {}
saveUser(name: string): void {
this.db.save(name);
}
}
// Usage
const userService = new UserServiceDIP(new MongoDB());
userService.saveUser("Alice"); // Saving "Alice" to MongoDB
High-level modules should depend on abstractions, not concrete implementations. UserServiceDIP depends on a Database interface instead of a concrete MySQLDatabase.
This document's home with other cheat sheets you might be interested in:
https://gitlab.com/davidvarga/it-cheat-sheets
License:
GNU General Public License v3.0 or later