The Open-Closed Principle (OCP) represents the letter “O” in S.O.L.I.D. It teaches us to create software that is open to extension but closed to change, enhancing its maintainability and scalability.
To be honest, the OCP is one of the most challenging S.O.L.I.D. principles to understand and apply in iOS projects. Primarily, it’s not as intuitive as the Single Responsibility Principle. Almost all developers agree that classes should have as few responsibilities as possible, but being open to extension and closed to change? What does that even mean practically? Once written, should we never change the code and only add new functionalities in extensions? What about improving the codebase through refactoring and applying the Boy Scout Rule? What about bug fixing, etc.?
Fortunately, the OCP is just a useful guide that helps us avoid common traps associated with change management in software development. So are the other S.O.L.I.D. rules by the way – just guidelines.
In this blog post, we’ll look at a simple tool for printing reports. We’ll see how not following the OCP can cause problems as the tool evolves with growing business requirements. We’ll also explore an alternative: an OCP-compliant version that can be easily extended with new types of data and output file types. So, without further ado, let’s dive in.
A story of a simple tool to fix a simple problem.
Let’s imagine you’ve been tasked with creating a tool to print sprint completion reports to PDF.
Sounds simple enough. First, let’s do our homework and ask what information about a sprint we should actually print:
struct Sprint {
let sprintTitle: String
let sprintStart: Date
let sprintFinish: Date
let tasksCompleted: Int
let tasksTotal: nt
...
}
and a ReportPrinter
can look like this:
enum ReportPrinter {
func printReport(sprint: Sprint) async -> PDF {
var pdf = PDF()
pdf.titleFont = .largeTitle
pdf.title = sprint.sprintTitle
...
return pdf
}
}
Finally:
let reportPrinter = ReportPrinter()
let sprint = Sprint(
sprintTitle: "Sprint 0",
sprintStart: .init("15/06/2024"),
sprintFinish: .init("30/06/2024"),
tasksCompleted: 10,
tasksTotal: 15
)
let sprintReport = await reportPrinter.printReport(sprint: sprint)
Looks good! The tool seems simple enough. The code is concise and readable. It seems fine, but surprisingly, it might already be violating the OCP.
Making small changes to the tool.
Congratulations! Your tool is becoming more and more popular! It has already saved Project Managers and Scrum Masters in your company countless hours
It’s been so handy that it has been licensed to a similar organization. Naturally, they have a list of improvements you need to implement, along with standard maintenance work.
How do they say it?
The first feature to add is the ability to include an image background to the report. Sounds simple enough. Let’s just add an additional optional parameter to our print()
method. But wait, current users have already built their apps or processes using the existing API. You can’t change it just like that! Fortunately, Swift offers a solution in the form of parameters default values:
func printReport(sprint: Sprint, backgroundImage: Image? = nil) async -> PDF {
var pdf = PDF()
...
if let backgroundImage {
pdf.set(backgroundImage: backgroundImage)
}
...
return pdf
}
Done! The clients are happy, and you can rest now. Maybe even ask for a promotion or a raise? You’ve earned it. Not only have you implemented a new feature, but you also maintained the API for existing users.
So, is conforming to the OCP overrated? Well, let’s just say the change requests have just begun…
How about a different use case?
If your tool can print sprint completion reports, why can’t we make it accept employee evaluation data as well? Sounds farfetched? Unless you’ve just started working in IT (or are lucky enough to work in a company with top-tier processes and culture), chances are you’re not smiling right now.
But maybe it won’t be that bad? Employee reports are not that different from sprint completion ones. Let’s take a look, shall we?
struct EmployeeEvaluation {
let emloyeeName: String
let evaluationPeriodStart: Date
let evaluationPeriodFinish: Date
let overallPerformance: Double
let communication: Double
...
let engagement: Double
}
Now, how should we integrate it with our ReportPrinter
? It seems we have at least two options:
- Extend the existing
printReport(...)
method to accept both sprint completion and employee evaluation reports. - Add another method to the
ReportPrinter
to handle employee evaluation reports.
The first option is generally not a good idea. Partly, due to of the existing users, but also because the structures describing both types of data are incompatible. You would most likely have to change the parameter type to a more general one, like Equatable
or Any
, or make the function generic over the input data. Then, inside the method, you would have to cast it back to the original type, which is awkward and not scalable. Let’s not go there…
What about the second option then? Let’s take a look:
enum EvenBetterReportPrinter {
func printReport(employeeEvaluation: EmployeeEvaluation, backgroundImage: Image? = nil) async -> PDF {
var pdf = PDF()
pdf.titleFont = .largeTitle
pdf.title = "Employee evaluation report"
pdf.subtitle = employeeEvaluation.emloyeeName
...
if let backgroundImage {
pdf.set(backgroundImage: backgroundImage)
}
...
return pdf
}
func printReport(evaluation: Sprint, backgroundImage: Image? = nil) async -> PDF {
...
}
}
Nice! Not only have you ensured backward compatibility by making no changes to the original printReport()
method, but you’ve also added an optional report background! If there’s a good time to ask for a raise, it’s now!
… but the change requests have just begun
Once again, you’ve delivered new functionality, allowing your company to profit. And for that, you’ll surely be “rewarded”… by having to implement even more functionalities.
This time, the Report Printer needs to be adjusted to print quarterly financial reports. Good! We already know the drill: gather requirements, model them into a data structure, and add another function to the ReportPrinter
tool. Sounds easy enough:
struct QuaterlyFinancialData {
let quarter: Int
let year: Int
let totalIncome: Double
let totalExpenses: Double
}
and:
enum ReportPrinterExtendedEvenMore {
func printReport(financialData: QuaterlyFinancialData, backgroundImage: Image? = nil) async -> PDF {
var pdf = PDF()
pdf.titleFont = .largeTitle
pdf.title = "Report for \(financialData.quarter)/\(financialData.year)"
pdf.add(line: "Total income: \(financialData.totalIncome)")
pdf.add(line: "Total expenses: \(financialData.totalExpenses)")
...
if let backgroundImage {
pdf.set(backgroundImage: backgroundImage)
}
...
return pdf
}
}
Once again, you managed to add a new functionality to the tool while maintaining the current APIs. Great job!
However, there’s a small caveat with this change request. The client requested the reports to be printed in plain text format as well. The business not only agreed but also offered this option to all existing clients.
Now, things start getting serious. It’s relatively easy to keep adding more print()
methods to handle different report data. However, it’s much more challenging to manage variable output format, especially when users and other systems rely on your tool to produce PDF reports. You can’t change these methods without facing their “righteous wrath.”
You see, this is exactly what happens when a piece of software must incorporate a change to one of its core features. Remember when I pointed out that even the simplest variant of the ReportPrinter
can potentially violate the OCP? This is what I was referring to.
So, what can we do about the situation? An obvious choice would be to duplicate all the methods that produce PDF reports and change the type of file they return. Although tempting, this is a temporary solution at best. Sooner or later, you’ll be asked to add another report format, and then another, and so on. And what if users request an option to personalize the reports beyond choosing the background image?
So, is there any hope to tame the situation? Or is it time to refresh your CV, change the profile photo overlay to green on LinkedIn and become “open to new challenges”?
Make Report Printer open to extension and closed to change.
So far, we’ve been trying to add new features to our tool by changing it: adding new parameters or new methods. It seemed to have worked… for a while.
To find a long-lasting solution, we need to go back to the basics and answer some fundamental questions about our tool:
- What actually is our tool doing?
The answer seems obvious: printing reports, right? Not exactly. You might be surprised to hear this, but the tool is actually a converter. Yes, printing a report is nothing more than converting one set of data into another, e.g., sprint statistics into a PDF. - What kind of data does the tool accept?
Virtually everything that follows the structure of a report: title, subtitle, etc. Sounds simple enough. Let’s make it official:
protocol PrintableInput {}
extension Sprint: PrintableInput {}
extension QuarterlyFinancialData: PrintableInput {}
Shouldn’t we constrain PrintableInput a bit? Define some properties providing data we could actually print: title, subtitle, report sections, etc.? Good question! Initially, let’s try not to be too restrictive. If we need to impose some constraints on the data we put into our tool, we can always do that later.
- What kind of print outputs does the tool offer?
We need to support at least PDF and plain text formats. But what if other formats are requested later? We need to consider that:
protocol PrintOutputFormat {}
extension String: PrintOutputFormat {}
extension PDF: PrintOutputFormat {}
Again, no constraints on the protocol? Not really. At least, not for now.
- What level of customization can the tool offer?
The tool can customize virtually anything that can be visualized by the supported output formats. Enlarged and bold headers? Sure. Quotes in italic? Granted. Charts with subscripts, etc.
struct PrintStyle {
let titleFont: Font
let backgroundImage: Image?
...
}
The only important thing to remember is to clearly separate the style from the content.
Good job, we have the basics set up! Now, let’s look at how the physical process of printing a document is done, specifically printing a sprint completion report into a PDF.
Ideally, we think of such a printer as a “black box” that accepts input (in the form of PrintableInput
) and a style description (PrintStyle
), returning the desired output (PrintOutputFormat
):
protocol ReportPrinter {
func print(report: PrintableInput, style: PrintStyle) async throws -> PrintOutputFormat
}
And let’s implement a PDF sprint completion report printer:
struct SprintCompletionPDFReportPrinter: ReportPrinter {
func print(report: any PrintableInput, style: PrintStyle) async throws -> any PrintOutputFormat {
guard let report = report as? Sprint else {
throw PrintError.unsupportedReportTypeOrPrintFormat
}
var pdf = PDF()
pdf.titleFont = style.titleFont
pdf.title = report.sprintTitle
if let backgroundImage = style.backgroundImage {
pdf.set(backgroundImage: backgroundImage)
}
// Adding the rest of the elementes to the report.
return pdf
}
}
where:
enum PrintError: Error {
case unsupportedReportTypeOrPrintFormat
case incompatiblePrintFormat
}
As you can see, we start by determining if we received the correct input type. Internally, the printer is very specialized. It operates only on a specific type of input data. It would not know how to handle different input data. Therefore, we must ensure the provided data matches. If it does not, we cannot proceed with report creation and have to throw an error.
How about a plain text printer? Let’s take a look:
struct SprintCompletionPlainTextReportPrinter: ReportPrinter {
func print(report: any PrintableInput, style: PrintStyle) async throws -> any PrintOutputFormat {
guard let report = report as? Sprint else {
throw PrintError.unsupportedReportTypeOrPrintFormat
}
var printout = ""
printout.append(report.sprintTitle.applying(style))
// Adding the rest of the elementes to the report.
return printout
}
}
Again, we need to make sure we’ve received the correct input before we start composing the report, but… that’s it. The rest is just implementation details…
Designing a user-friendly API
You might be asking right now: That’s it? What about user experience and ease of integration? Can you imagine users having to switch through all the available reports and output types to initialize the correct ReportPrinter
implementation? Nobody would buy such a tool! There must be something we can do!
And indeed, there is. Let’s begin by making sure each ReportPrinter
implementation can “tell” us if it can print a given report in a provided file format before it attempts to do the actual printing.
protocol ReportPrinter {
func canPrint(report: PrintableInput, into printFormat: PrintOutputFormat.Type) -> Bool
func print(report: PrintableInput, style: PrintStyle) async throws -> PrintOutputFormat
}
and a sample implementation:
struct SprintCompletionPlainTextReportPrinter: ReportPrinter {
func canPrint(report: any PrintableInput, into printFormat: any PrintOutputFormat.Type) -> Bool {
report is Sprint && printFormat == String.self
}
...
}
That makes sense, right? If the input data is a Sprint and the expected output is a String, then SprintCompletionPlainTextReportPrinter
is the correct choice. But it still does not solve our problem, does it? The developers using our tool should not even be aware that individual ReportPrinter
implementations exist. They should have a clear, simple, and convenient API that accepts all supported input types and provides a printed document. If the printing process fails for some reason, an appropriate, descriptive error should be thrown.
Let’s try to define such an API:
protocol OCPReportPrinter {
public func print<Output: PrintOutputFormat>(
report: any PrintableInput,
inStyle style: PrintStyle,
intoFormat printFormat: Output.Type
) async throws -> Output
}
Looks good, but why do we need to provide the output format type as a parameter? Can’t we just detect the type the method is supposed to return and use it internally within the method itself? The short answer is: no. Swift, unlike Kotlin, offers only limited reflection capabilities. To my knowledge, a method returning a generic type cannot simply perform introspection to a degree that detects the actual type it is supposed to return. Until such a feature is added to Swift, we must explicitly provide the type we intend to return as a parameter.
It’ll make much more sense when we dive into the implementation:
struct ReportPrintersAggregator: OCPReportPrinter {
let reportPrinters: [any ReportPrinter]
func print<Output: PrintOutputFormat>(report: any PrintableInput, inStyle style: PrintStyle, intoFormat printFormat: Output.Type) async throws -> PrintOutputFormat {
guard let printer = fetchReportPrinter(toPrint: report, into: printFormat) else {
throw PrintError.unsupportedReportTypeOrPrintFormat
}
do {
if let printedReport = try await printer.print(report: report, style: style) as? Output {
return printedReport
} else {
throw PrintError.unsupportedReportTypeOrPrintFormat
}
} catch {
throw error
}
}
}
private extension ReportPrintersAggregator {
func fetchReportPrinter(
toPrint report: any PrintableInput,
into printFormat: any PrintOutputFormat.Type
) -> (any ReportPrinter)? {
reportPrinters.filter({ $0.canPrint(report: report, into: printFormat) }).first
}
}
As you have surely predicted, the first task is to determine if the tool can handle a given combination of input and output file formats. To do this, we iterate through all the provided printers (passed to the ReportPrintersAggregator
in the constructor) and select one capable of handling the requested combination.
When we find the correct printer, we use it to produce the report in the correct format, applying the provided style. This process could fail, and if it does, the method will throw an error. Finally, to be extra safe, we attempt to cast the produced Output
into the expected type. If that fails (which it shouldn’t), we throw another error.
Finally, this is how the developers could use the tool:
let printStyle = PrintStyle(...)
let aggregator = ReportPrintersAggregator(
reportPrinters: [
SprintCompletionPDFReportPrinter(),
SprintCompletionPlainTextReportPrinter(),
...
]
)
do {
let plainTextSprintReport: String = try await aggregator.print(report: sprint, inStyle: printStyle, intoFormat: String.self)
// Use the printed report.
} catch {
print("Print error: \(error)")
}
So, every time we’re asked to add another file format or handle a different set of data to print a report, all we need to do is:
- Create a new
ReportPrinter
implementation for the requested duo. - Add it to the
ReportPrintersAggregator
.
The API for the end users (the developers) will not change, only the list of accepted input data types and output formats.
Neat, simple, open to extension (in a form of new input-output pairs) and closed to change (existing printers will not be affected). OCP in it’s finest.
When OCP can be broken?
As we discussed in the post about Single Responsibility Principle, it’s often justified to break the S.O.L.I.D. rules.
The first thing worth mentioning is that there’s no such thing as “change-proof” code. It’s impossible to design code that can handle all future changes requested by the business without breaking the OCP. So why bother in the first place? Because software development is all about spotting patterns. If you correctly identify the type of problem you are dealing with, you can confidently predict what changes the business will most likely request.
In the case of the document printer, it’s relatively easy to guess that three things might change over time: input data, output format, and styling. A bit of planning before writing the first line of code could save us a lot of trouble later in development.
But what if the business asks us to implement showing a preview of the printed document? Then we’ll have to go back to the drawing board and work the requested feature into an existing tool, or create a new one. What stops us from reusing the code we already have in multiple tools?
Essentially, whenever the business requests a change affecting the core functionality of the tool we implement, the OCP may need to be broken. Realistically, the best we can do is create a “scaffold” around such core features of the tool (or app). In our case, we designed ReportPrinter
and ReportPrintersAggregator
, allowing us to “painlessly” extend the tool with new report types and output formats. Unless our humble tool evolves into a fully-featured word processor, our “scaffold” should be a good base.
So are there other cases where we can safely disregard the OCP? Let’s take a look:
- Over-engineering:
It’s surprisingly easy to fall into this trap. How likely is it that your logging tool will suddenly start accepting images? How likely is it that the class to strip logs of private information will suddenly have to handle raw data as well? We could go on like this forever. My personal rule of thumb here is to determine how often the code may change and what other code relies on the APIs it provides. So I deliberately change the API to see which parts of the app stop compiling. That gives me an idea of whether it’s worth making the code more OCP-compliant or not. - An experimental code:
Unsurprisingly, a proof of concept, test code, etc. is all about speed and getting feedback from the users. We can safely disregard most of the S.O.L.I.D. rules in such a code under one condition: it must never be deployed to production! - Legacy code or part of the controlled tech debt:
Every codebase has legacy parts. If this code is well-isolated, rarely changes, and does not require maintenance, we can safely leave it as is. Alternatively, you might need to release a feature quickly, intending to replace it ASAP with a proper solution. It’s also okay to disregard the OCP in this case, as long as the final solution adheres to it.
Summary
The Open-Closed Principle is a useful guideline for designing maintainable and scalable software. Ignoring it often leads to trouble. In this blog post, we discussed how even a simple tool can evolve into a rigid, inextensible piece of software and how that process can unfold imperceptibly over time.
At the same time, following the OCP rigorously does not guarantee our codebase will be “change-proof”. We can’t realistically predict all the types of change requests the business may throw at us. Sooner or later, our software will need to undergo some changes. But this is expected. Each tool or app has core functionalities that, when evolving, often necessitate some form of a rebuild. The OCP cannot protect us from that. Only a reasonable product roadmap can.
However, in most cases, investing a bit of time in naming these core features will be enough to predict how the app might change in the future. For example, with the report printer, it’s easy to guess that users might soon request another output format, right?
If there’s one thing I’d like you to take away from this blog post, it’s that it’s very difficult to change the API once other systems or apps start relying on it. Taking some time to design the API, define proper abstractions, etc., can go a long way in ensuring peace of mind for the development team and keeping the business able to react to market changes.
And that’s all that can reasonably be expected of us as developers. Try predicting the most obvious ways the software can evolve while making that software as flexible and robust as your time and skills allow. The OCP can be a great guide in this quest!
Don’t miss any new blogposts!
By subscribing, you agree with our privacy policy and our terms and conditions.
Disclaimer: All memes used in the post were made for fun and educational purposes only
They were not meant to offend anyone
All copyrights belong to their respective owners
Some images were generated using Leonardo AI
Audio version generated with TTS Maker
The post How to unlock code flexibility applying S.O.L.I.D. Open-Closed Principle? first appeared on Swift and Memes.