Software Architecture

Reconciling OOP and FP: Applying SOLID Principles in Functional Programming

At first glance, the Object-Oriented Programming (OOP) and Functional Programming (FP) paradigms appear to be polar opposites. OOP relies on mutable state, inheritance, and encapsulation, while FP champions immutability, pure functions, and data transformations. Yet, the core goal of both is the same: building maintainable, scalable, and testable software. The SOLID principles, originally formulated for OOP, provide a robust framework for this goal. When applied thoughtfully, they align surprisingly well with functional concepts, offering a hybrid approach that maximizes the strengths of both worlds.

Single Responsibility Principle (SRP): One Function, One Job

In OOP, SRP dictates that a class should have only one reason to change. In functional programming, this translates to keeping functions small and focused on a single transformation. Instead of creating a large class with multiple methods that might change for different reasons, we decompose logic into small, pure functions.

// Bad: A function doing too much
function processOrder(order) {
    validateOrder(order);
    calculateTax(order);
    sendEmail(order.customer);
    updateDatabase(order);
}

// Good: Small, focused, pure functions
const validateOrder = (order) => order.items.length > 0;
const calculateTax = (amount) => amount * 0.08;
const createInvoice = (order, tax) => ({ ...order, tax });

function processOrder(order) {
    if (!validateOrder(order)) throw new Error("Invalid Order");
    const tax = calculateTax(order.total);
    const invoice = createInvoice(order, tax);
    // Side effects handled separately or via monads
    return invoice;
}

By ensuring each function does one thing well, we ensure that changes to tax logic don't break validation logic. This modularity is a functional interpretation of SRP.

Open/Closed Principle (OCP): Extending Behavior Without Modification

OCP states that entities should be open for extension but closed for modification. In FP, we achieve this through higher-order functions and strategy patterns implemented as function parameters. Instead of subclassing a `Shape` class to add a new `Square`, we pass a calculation function as an argument.

// OOP approach requires modifying base class or creating subclasses
// FP approach: Use composition and function parameters

const areaCalculator = (shape) => {
    switch (shape.type) {
        case 'circle':
            return Math.PI * shape.radius ** 2;
        case 'square':
            return shape.side ** 2;
        case 'rectangle':
            return shape.width * shape.height;
        default:
            throw new Error("Unknown shape");
    }
};

// To add a 'triangle', you modify the calculator function.
// To adhere strictly to OCP, we can use a lookup table:

const areaStrategies = {
    circle: (s) => Math.PI * s.radius ** 2,
    square: (s) => s.side ** 2,
    triangle: (s) => 0.5 * s.base * s.height // New extension
};

const getArea = (shape) => areaStrategies[shape.type](shape);

While the lookup table still requires modification to add keys, it isolates the change to the data definition rather than the core logic. More advanced FP techniques involve passing the strategy function itself as a dependency, making the `getArea` function completely agnostic to the specific shapes it processes.

Liskov Substitution Principle (LSP): Type Safety and Contracts

LSP ensures that if `S` is a subtype of `T`, then `T` can be replaced by `S` without altering the correctness of the program. In functional languages, LSP is often satisfied through strong type systems and algebraic data types. If a function expects an `Animal` and you pass a `Dog`, the function must still behave correctly because `Dog` satisfies the structural or nominal requirements of `Animal`.

// TypeScript example illustrating LSP via structural typing
interface Animal {
    name: string;
    eat(): void;
}

function feed(animal: Animal) {
    console.log(`Feeding ${animal.name}`);
    animal.eat();
}

class Dog implements Animal {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    eat(): void {
        console.log(`${this.name} is eating kibble`);
    }
    // Dog can be passed to feed() safely
}

In pure FP, we avoid inheritance pitfalls by relying on composition and explicit type constraints. If a function `f: A -> B`, any function that accepts `A` and returns `B` is a valid substitute, provided it adheres to the same side-effect constraints.

Interface Segregation Principle (ISP): Fine-Grained Interfaces

ISP suggests that no client should be forced to depend on methods it does not use. In OOP, this is achieved by creating smaller interfaces. In FP, since we don't use interfaces in the same way, we achieve ISP through function decomposition and dependency injection of specific functions rather than large objects.

Instead of passing a large `UserManager` object with 20 methods where only 2 are needed, we pass the two specific functions required:

// Bad: Coupling to a large object
function renderProfile(userManager: UserManager) {
    const user = userManager.getUser();
    const photos = userManager.getPhotos();
    // ...
}

// Good: Loose coupling via specific functions
function renderProfile(
    getUser: () => User,
    getPhotos: (userId: string) => Photo[]
) {
    const user = getUser();
    const photos = getPhotos(user.id);
    // ...
}

This approach ensures that `renderProfile` is only dependent on the exact behavior it requires, making it easier to test and modify.

Dependency Inversion Principle (DIP): Abstraction Over Implementation

DIP states that high-level modules should not depend on low-level modules; both should depend on abstractions. In FP, we achieve this by depending on types (abstractions) and injecting concrete implementations (functions) at runtime. This is the core of Test-Driven Development (TDD) in functional languages.

// High-level module depending on an abstraction (type)
type DataStore = {
    save: (item: Item) => Promise;
    fetch: (id: string) => Promise;
};

function processItem(store: DataStore, item: Item) {
    // Logic that depends only on the DataStore interface
    return store.save(item);
}

// Low-level modules (implementations)
const memoryStore: DataStore = {
    save: async (item) => { /* ... */ },
    fetch: async (id) => { /* ... */ }
};

const dbStore: DataStore = {
    save: async (item) => { /* ... */ },
    fetch: async (id) => { /* ... */ }
};

// Using the high-level function with different abstractions
processItem(memoryStore, item1);
processItem(dbStore, item2);

By defining `DataStore` as a type alias or interface, we create an abstraction. The `processItem` function depends on this abstraction, not on the specific implementation. This allows us to swap out the data store for testing or production without modifying the business logic.

Conclusion: A Hybrid Approach to Robust Architecture

Applying SOLID principles in functional programming is not about forcing OOP patterns into FP code, but rather recognizing the underlying design goals: modularity, testability, and maintainability. By leveraging pure functions, immutability, and strong type systems, we can satisfy the spirit of SOLID while enjoying the benefits of functional code. The result is software that is easier to reason about, test, and extend, regardless of the paradigm we choose to emphasize.

As you architect your next system, consider these principles not as rigid rules, but as guiding lights that help you build components that work together harmoniously. Whether you are using JavaScript, TypeScript, Scala, or Rust, the core idea remains: design for change, and your code will remain resilient in the face of evolving requirements.

Share: