Applying the Open-Closed Principle in Swift: Best Practices and Examples

Bhavin Bhadani
6 min readDec 22, 2022

--

The Open-Closed Principle (OCP) is a fundamental principle of software design that states that software entities (such as classes, modules, or functions) should be open for extension but closed for modification. In other words, you should be able to add new functionality to a class or module without changing its existing code.

But why is the OCP so important? And how can you apply it in your own Swift code? In this blog post, we’ll answer these questions and provide tips for following the OCP in your own projects.

What is the Open-Closed Principle?

To understand the OCP, it’s helpful to first understand what is meant by “open” and “closed” in the context of software design.

An entity that is “open” is one that can be extended or modified in some way. For example, a class that has an open method can have additional behavior added to it through inheritance or by adding a new method.

On the other hand, an entity that is “closed” is one that cannot be modified or extended. For example, a closed class cannot have any new behavior added to it, and its existing behavior cannot be changed.

The Open-Closed Principle states that software entities should be open for extension but closed for modification. In other words, you should be able to add new functionality to a class or module without changing its existing code.

Here’s an example of a class that violates the OCP:

class User {
var name: String
var email: String

init(name: String, email: String) {
self.name = name
self.email = email
}

func register() {
// Save user to database
}
}

In this example, the register method is responsible for saving a user to the database. However, let's say we want to add the ability to send a confirmation email to the user after registration. To do this, we might be tempted to modify the register method like this:

class User {
var name: String
var email: String

init(name: String, email: String) {
self.name = name
self.email = email
}

func register() {
// Save user to database
sendConfirmationEmail(to: email)
}
}

However, this violates the OCP, as we have modified the register method to add new behavior (sending a confirmation email). Instead, a better way to design this would be to separate the responsibility of sending the confirmation email from the register method and create a separate function for handling this behavior.

Why is the Open-Closed Principle Important?

Now that we’ve seen an example of the OCP in action, let’s talk about why it’s important to follow this principle in your software design.

The main benefit of following the OCP is that it helps to create code that is more maintainable and easier to extend over time. By allowing you to add new functionality to a class or module without changing its existing code, the OCP helps to reduce the risk of breaking existing functionality when adding new features.

On the other hand, if you violate the OCP and constantly modify existing code to add new functionality, your code can become hard to maintain and prone to bugs. This can be time-consuming and costly to fix, and can lead to a maintenance nightmare as your codebase grows and evolves over time.

In addition to making your code more maintainable, following the OCP also makes it easier to understand and reason about. By keeping each class or function focused on a single, well-defined responsibility, you can make your code more predictable and easier to understand. This can save you time and resources in the long run, as you’ll be able to add new features and functionality to your codebase more quickly and with fewer errors.

How to Apply the Open-Closed Principle in Swift

Now that we’ve covered the basics of the OCP and why it’s important, let’s talk about how you can apply this principle in your own Swift code. Here are some examples of good, better, and best practices for following the OCP:

Good practice:

class User {
var name: String
var email: String

init(name: String, email: String) {
self.name = name
self.email = email
}

func register() {
// Save user to database
}
}

class UserService {
func registerUser(user: User) {
user.register()
}

func sendConfirmationEmail(to email: String) {
// Send email
}
}

In this example, the User class has a single responsibility: to represent a user with a name and an email. The register method has a single responsibility: to save the user to the database. The UserService class has a single responsibility: to handle user registration by calling the register method on the user object.

This is a good example of following the OCP, as we have separated the responsibility of sending the confirmation email from the register method and created a separate function for this purpose.

Better practice:

protocol UserPersistence {
func save(_ user: User)
}

class User {
var name: String
var email: String
init(name: String, email: String) {
self.name = name
self.email = email
}
}

class UserService {
let persistence: UserPersistence

init(persistence: UserPersistence) {
self.persistence = persistence
}

func registerUser(user: User) {
persistence.save(user)
}

func sendConfirmationEmail(to email: String) {
// Send email
}
}

In this example, we have introduced an additional level of abstraction by using a protocol to separate the responsibility of saving the user to the database from the UserService class. This allows us to easily swap out different persistence implementations without changing the UserService class.

By following this design, we can add new functionality to the UserService class without modifying its existing code. For example, we could add a new method for sending a welcome email to the user without changing the registerUser method.

Best practice:

protocol UserPersistence {
func save(_ user: User)
}

protocol EmailService {
func sendEmail(to: String, subject: String, body: String)
}

class User {
var name: String
var email: String

init(name: String, email: String) {
self.name = name
self.email = email
}
}

struct RegisterUserRequest {
let name: String
let email: String
}

struct RegisterUserResponse {
let user: User
}

protocol UserService {
func registerUser(request: RegisterUserRequest) -> RegisterUserResponse
}

class UserServiceImpl: UserService {
let persistence: UserPersistence
let emailService: EmailService

init(persistence: UserPersistence, emailService: EmailService) {
self.persistence = persistence
self.emailService = emailService
}

func registerUser(request: RegisterUserRequest) -> RegisterUserResponse {
let user = User(name: request.name, email: request.email)
persistence.save(user)
emailService.sendEmail(to: request.email, subject: "Welcome!", body: "Thank you for registering!")
return RegisterUserResponse(user: user)
}
}

In this example, we have separated the responsibilities of the UserService class into distinct, single-purpose units using protocols. The User class still has a single responsibility: to represent a user with a name and an email. And the UserService class has a single responsibility: to handle user registration by creating a new User object, saving it to the database, and sending a confirmation email. However, the responsibilities of saving the user and sending the email are now delegated to the UserPersistence and EmailService protocols, respectively, which allows for flexibility in terms of how these responsibilities are implemented.

By following this design, we have achieved the ultimate goal of the OCP: we can add new functionality to the UserService class without modifying its existing code. For example, we could add a new method for sending a welcome email to the user without changing the registerUser method.

In addition, this design is highly maintainable and scalable. By separating the responsibilities of the UserService class into distinct, single-purpose units, we can easily swap out different implementations of the UserPersistence and EmailService protocols without changing the UserService class. This allows us to easily add new features and functionality to our codebase without introducing complex, hard-to-maintain code.

Conclusion:

The Open-Closed Principle (OCP) is a software design principle that states that software entities should be open for extension but closed for modification. In other words, you should be able to add new functionality to a class or module without changing its existing code.

Following the OCP helps to create code that is more maintainable and easier to extend over time. By separating responsibilities into distinct, single-purpose units, you can add new features and functionality to your codebase without introducing complex, hard-to-maintain code.

In addition to making your code more maintainable, following the OCP also makes it easier to understand and reason about. By keeping each class or function focused on a single, well-defined responsibility, you can make your code more predictable and easier to understand for yourself and other developers.

Overall, the OCP is an important principle to follow in software design, as it helps to create code that is more maintainable, scalable, and flexible over time.

--

--