Software Design

[SOLID #2] SOLID Principles in Practice (Part 2): ISP · DIP and Why Dependency Injection Exists

In the previous article, we covered the first three letters of SOLID—SRP, OCP, and LSP. Today, we’ll wrap up the remaining two.

3 min read
Cover image for [SOLID #2] SOLID Principles in Practice (Part 2): ISP · DIP and Why Dependency Injection Exists

In the previous article, we covered the first three letters of SOLID—SRP, OCP, and LSP. Today, we’ll wrap up the remaining two.

They are ISP (the Interface Segregation Principle) and DIP (the Dependency Inversion Principle).

DIP, in particular, is the theoretical foundation of dependency injection (DI)—something you inevitably encounter when using DI libraries in Swift, whether Swinject or Factory. By the end of this article, you’ll see why those libraries are designed the way they are.


ISP: Interface Segregation Principle

Let’s start with the definition.

Clients should not be forced to depend on methods they do not use.

That sounds abstract, but a concrete failure makes it immediately clear. Suppose we define a multifunction-printer protocol.

protocol Machine {
    func print(_ doc: Document)
    func scan(_ doc: Document)
    func fax(_ doc: Document)
}

// An old printer can only print...
class OldPrinter: Machine {
    func print(_ doc: Document) { /* Works correctly */ }
    func scan(_ doc: Document) { fatalError("Cannot scan") }  // Forced implementation
    func fax(_ doc: Document) { fatalError("Cannot fax") }   // Forced implementation
}

We’re forcing a printer that only prints to implement scanning and faxing too. Eventually, fake methods that throw exceptions appear—and that is also an LSP violation from the previous article. A fat interface breaks two principles in succession.

The solution is simple: split the interface by role.

protocol Printer { func print(_ doc: Document) }
protocol Scanner { func scan(_ doc: Document) }
protocol Fax { func fax(_ doc: Document) }

class OldPrinter: Printer { ... }                      // Print only
class MultiFunction: Printer, Scanner, Fax { ... }     // Full multifunction printer

Each type promises only what it can do. In practice, the warning sign looks like this: if adopting a protocol requires an empty implementation accompanied by “This method has nothing to do with us…”, it’s time to split that protocol.


DIP: Dependency Inversion Principle

This principle is often misunderstood because of its name. Its definition takes two lines.

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.

Let’s look at some code. Suppose an order service directly uses an email-sending library.

// The high-level module (business logic) directly depends on the low-level module (implementation details)
import SendGrid

class OrderService {
    private let mailer = SendGridClient(apiKey: apiKey)

    func completeOrder(_ order: Order) {
        // Order processing...
        mailer.send(to: order.email, message: "Order completed")  // SendGridis tightly coupled
    }
}

This structure has two problems. Replacing SendGrid with another service requires opening up the business logic, and real emails are sent every time you run a test.

Applying DIP reverses the direction of the arrows.

// The abstraction is defined by the high-level module (the order domain)
protocol NotificationSender {
    func send(to: String, message: String) async throws
}

class OrderService {
    private let sender: NotificationSender

    init(sender: NotificationSender) {  // Depends only on the abstraction
        self.sender = sender
    }

    func completeOrder(_ order: Order) async throws {
        try await sender.send(to: order.email, message: "Order completed")
    }
}

// Details(SendGrid)follow the abstraction
class SendGridSender: NotificationSender { ... }
class SlackSender: NotificationSender { ... }
class FakeSender: NotificationSender { ... }  // For testing

The dependency that originally pointed from “Order service → SendGrid” is now “Order service → Protocol ← SendGrid.” Since the details bow toward the domain, this is called “inversion.”

The point where the arrow reverses is the whole of DIP
The point where the arrow reverses is the whole of DIP
The arrow changing direction is what “inversion” means
The arrow changing direction is what “inversion” means

That’s Why DI Frameworks Exist

A natural question follows: “Then who creates SendGridSender()?”

The component that performs this assembly is a dependency injection (DI) container. DI libraries in the Swift ecosystem, such as Swinject and Factory, do exactly this. Classes depend only on protocols, while the library supplies the actual implementations.

DIP is a principle (direction), while DI is a technique (tool) for realizing it. Understanding this distinction alone can take your interview answers to the next level.

Implementations are plugged in and out; the framework handles the assembly
Implementations are plugged in and out; the framework handles the assembly

A Complete Summary of SOLID

Compressing SOLID from both articles into five lines gives us this.

Principle One-line summary
SRP Separate code when the people requesting changes are different
OCP Respond to frequent changes by adding rather than modifying
LSP A child must not break the parent’s contract
ISP Do not force implementations of methods they will never use
DIP Do not make business logic depend on details

All five ultimately point in the same direction: reduce the blast radius of change.

However, applying these principles mechanically to all code can instead violate KISS and YAGNI. Principles are tools to apply selectively where change actually happens frequently. I believe that sense of balance is the real skill.

Continue reading