DEV Community

Cover image for SOLID Principles in Functional Programming (FP) with examples
Flavio Rosselli
Flavio Rosselli

Posted on

SOLID Principles in Functional Programming (FP) with examples

The SOLID principles are five design principles that aim to create software easy-to-maintain, flexible, and scalable. They were introduced by Robert C. Martin (Uncle Bob) and are widely adopted in software development.

While the SOLID principles are traditionally applied to Object-Oriented Programming (OOP), they can also be adapted to Functional Programming (FP). Here's an overview of each SOLID principle with real-world examples in Typescript using functional programming concepts.

1. Single Responsibility Principle (SRP)

A function should have one, and only one, reason to change. It should do one thing (responsibility) and do it well.

In FP, functions should focus on solving a single problem or performing one task. By adhering to SRP, you can easily reason about code, test it, and change individual behaviours without affecting other parts of the system.

When to use: Whenever you need to ensure modularity and separation of concerns. It’s easier to maintain, test, and refactor.

When to refactor:

  • When a function has multiple unrelated responsibilities.
  • When a function is becoming too large and complex.
  • When a function is difficult to understand or test.

Example: Let’s say we have an app that handles user registration, validation, and email sending. Each function should focus on one task:

const validateEmail = (email: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);

const sendEmail = (email: string, content: string): void => {/* logic to send email */};

const registerUser = (email: string, password: string): void => {
  if (!validateEmail(email)) throw new Error('Invalid email');
  // logic to register user
  sendEmail(email, 'Welcome to the platform!');
};
Enter fullscreen mode Exit fullscreen mode

2. Open/Closed Principle (OCP)

Software entities (functions, modules, etc.) should be open for extension but closed for modification. This means you can add new functionality without changing existing code.

You should build functions in a way that new behavior can be added without changing existing code. In FP, higher-order functions and function composition can help achieve this.

When to use: When you want to add new features (e.g., different notification methods) without modifying the core logic.

When to refactor:

  • When you need to add new functionality to an existing system without modifying the existing code.
  • When you want to make your code more extensible and maintainable.

Example: Suppose we want to extend our user registration system to support different types of notifications (email, SMS).

type Notify = (message: string) => void;

const emailNotify: Notify = (message) => {/* send email logic */ };
const smsNotify: Notify = (message) => {/* send SMS logic */ };

const registerUser = (email: string, password: string, notify: Notify): void => {
  if (!validateEmail(email)) throw new Error('Invalid email');
  // logic to register user
  notify('Welcome to the platform!');
};

// extend functionality without modifying registerUser
registerUser('user@example.com', 'password123', emailNotify);
registerUser('user@example.com', 'password123', smsNotify);
Enter fullscreen mode Exit fullscreen mode

3. Liskov Substitution Principle (LSP)

Objects in a program should be replaceable with instances of their subtypes without altering the correctness of the program.

In FP, this translates to ensuring that different implementations of a function or data transformation behave consistently. If you replace one function with another that has the same signature, the program should work as expected.

When to use (or refactor): When you need interchangeable behaviors or formatters. It helps keep your code flexible and open to new use cases.

Example: Let’s assume we have a function that formats user data for export in different formats (JSON, CSV).

type Formatter = (data: any) => string;

const jsonFormatter: Formatter = (data) => JSON.stringify(data);
const csvFormatter: Formatter = (data) => {
  // logic to convert data to CSV
  return "CSV format";
};

const exportUserData = (data: any, formatter: Formatter): string => formatter(data);

console.log(exportUserData({ name: 'John Doe' }, jsonFormatter)); // JSON output
console.log(exportUserData({ name: 'John Doe' }, csvFormatter)); // CSV output
Enter fullscreen mode Exit fullscreen mode

4. Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they do not use. Instead of one large interface, multiple smaller interfaces are preferred.

This principle suggests that functions should only accept the parameters they need. In FP, this can be thought of as creating smaller, more specific functions rather than large ones that do too much or rely on too many inputs.

When to use: When you want to avoid functions taking large input structures or unnecessary dependencies.

When to refactor:

  • When you have a large interface that is used by multiple clients.
  • When you want to create more reusable and modular components.

Example:
Creating smaller, focused functions instead of large interfaces allows clients to only depend on what they need.

const sendMessage = (message: string): void => {
    // Logic to send message
    console.log(`Message sent: ${message}`);
};

const receiveMessage = (): string => {
    // Logic to receive message
    const message = "Hello!";
    console.log(`Message received: ${message}`);
    return message;
};

// each function serves a specific purpose without unnecessary dependencies.
Enter fullscreen mode Exit fullscreen mode

5. 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.

In FP, this can be applied by having high-level functions depend on abstractions (like higher-order functions) rather than concrete implementations. We decouple the implementation of a function from its usage by passing behaviour as a function.

When to use: When you want to keep your code loosely coupled, allowing easy swapping of different behaviours without changing the high-level logic, enhancing flexibility and testability.

When to refactor:

  • When you want to decouple high-level modules from low-level modules.
  • When you want to make your code more flexible and testable.

Example: Using higher-order functions to inject dependencies allows us to decouple high-level logic from low-level implementations.

interface NotificationSender {
    send(message: string): void;
}

const notifyUser = (notificationSender: NotificationSender, message: string): void => notificationSender.send(message);

// Low-level module implementation
const emailSender: NotificationSender = {
    send(message) { console.log(`Email sent with message: ${message}`) }
};

// High-level module using dependency injection
notifyUser(emailSender, "Notification sent!");
Enter fullscreen mode Exit fullscreen mode

Example: Suppose we want a notification system that can support multiple notification methods (email, SMS, etc.) but without hardcoding them.

type Notify = (message: string) => void;

const notifyUser = (message: string, notify: Notify): void => notify(message);

const emailNotify: Notify = (message) => {/* send email logic */ };
const smsNotify: Notify = (message) => {/* send SMS logic */ };

// High-level notifyUser depends on the abstraction (Notify), not the concrete implementation
notifyUser('Welcome!', emailNotify);
notifyUser('Your order is confirmed!', smsNotify);
Enter fullscreen mode Exit fullscreen mode

Key Points:

  • SRP: Each function has a single, well-defined responsibility.
  • OCP: New functionality can be added by creating new functions without modifying existing ones.
  • LSP: Functions that take interfaces as arguments can be used with any implementation of those interfaces.
  • ISP: Functions are designed with small, focused interfaces.
  • DIP: Functions depend on abstractions (interfaces) rather than concrete implementations.

Top comments (0)