Introduction to Open Close Principle
Open-Closed Principle (OCP) is one of the principles in SOLID that was popularized by Uncle Bob. The idea originally comes from Bertrand Meyer in Object-Oriented Software Construction (1988). In that book, Meyer defines this principle as:
“Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.”
What’s confusing about OCP
The first thing I got from the title of this principle was
we should try to predict how the code may grow from the start, so later it can be extended without modifying the existing code.
So in my head, open sounded like “prepare the code so it can be extended later,” and closed sounded like “once the code is written, it should not be modified anymore.” From that interpretation, I ended up thinking that we should predicting future changes from the beginning, creating extension points early (hole), and then extending the code through those “holes” instead of modifying the existing implementation.
I wrote an article about OCP that was published in 2023. At that time, I also did some research while writing it, and that made me think this interpretation was correct. Many articles and tutorials out there also explain it in a very similar way, so the misunderstanding can feel valid at first. But the more I code, the more confusing that way of thinking became. It is really hard to predict those things when we are just starting to write code. And when I failed to predict it correctly, I got that bad feeling, like I was breaking the principle just because I needed to modify the code. On the other hand, trying to make the right prediction from the start also gave me anxiety for making it wrong.
That confusion stayed with me for quite a while, until I started this series and tried to relearn this principle. And that is where I found an article with a comment that criticized the way the writer explained OCP. The writer basically explained it like how the old me used to understand it. The person in the comment then referenced another article written more than a decade ago, in 2013, by Jon Skeet. Here is the article: https://codeblog.jonskeet.uk/2013/03/15/the-open-closed-principle-in-review
Jon Skeet’s view about OCP
In his article, Jon Skeet explains that his issue is not really with the low-level design idea behind OCP. In fact, he says the lower-level explanation of what is good and bad in the design example is actually fine. His problem is more with the name and the high-level wording of the principle, because they do not explain the real idea clearly enough.
What I think is important from his article is, the more useful way to understand OCP is not to focus too much on the words open and closed, but to focus on how change in one part should not force other parts to keep changing too. Skeet highlights Craig Larman’s explanation that being “closed with respect to X” means that clients are not affected if X changes. Here, clients does not mean customers or end users. It means the other parts of the code that depend on that module, class, or interface.
He also points out that OCP is easier to understand when we stop thinking about just one module alone. In practice, this is usually about a relationship between pieces of code. There is one part that may change, and there is another part that depends on it. The goal is to design the boundary between them so that when the first part changes, the second part does not need to change every time as well.
One side contains the variation, and the other side depends on a stable contract so it does not need to know every concrete case.
That is also why Skeet finds another principle called Protected Variation is much clearer. The phrase he quotes is:
“Identify points of predicted variation and create a stable interface around them.”
I think this captures the idea much better, because it gives a more practical angle. First, try to see where variation is already visible, repeatedly requested, or naturally expected by the domain. Then, create a stable boundary around that part, so the rest of the code does not need to be affected by that change again and again.
But Skeet also doesn't make it sound easy. He points out that predicting variation is hard. If we predict too much, we can end up over-engineering. If we predict too little, we may still need to change a lot of code later or add a new interface after the fact.
So, for me, the most useful takeaway from his article is this:
a better way to see OCP is not as “never modify code again”, but as “find the parts that are likely to change, then design things so that those changes do not keep spreading to other parts of the system.”
Better Angle to See OCP / The Mental Model
From that perspective, the way I look at OCP now is slightly different from how I understood it before. It is not really about never modifying code again. In real software development, code will always change. Requirements change, new features appear, and old assumptions turn out to be wrong. Trying to completely avoid modification is unrealistic.
A more useful way to see OCP is to focus on the areas where variation is visible, likely, or meaningful enough to justify a stable boundary, and then design the system so that those changes do not keep forcing other parts of the system to change as well.
This does not mean we must predict every future change from the beginning. That would be hard and can easily lead to over-engineering. A more practical approach is to notice which part of the system is likely to keep changing, then place a stable boundary around that part so the rest of the code does not need to change every time too.
In many systems, some behaviors are more likely to evolve than others. For example, business rules may change, new payment methods may be added, or new output formats may be required. These are places where variation naturally appears.
Instead of letting those changes spread across many parts of the code, we try to isolate that variation behind a stable boundary. The parts we want to keep stable can then depend on that interface instead of depending directly on the concrete implementations behind it.
In many designs, one common way variation first appears is as repeatedly edited branching logic in one place. After refactoring, that variation can be moved behind a stable abstraction so new behavior is added without repeatedly editing that same part.
OCP is most useful when variation is meaningful and recurring, not when we are only imagining many future possibilities without evidence.
The more stable parts of the design stay relatively unchanged, while new behavior is added behind the same boundary.
So the mental model I use for OCP now is this:
Identify where change is likely to happen, isolate that variation, and design a stable boundary so the rest of the system does not need to change because of it.
Or in simpler questions:
- Which behavior in this system is likely to vary?
- Which parts of the system depend on that behavior?
- Can I introduce a stable interface so those parts do not need to change every time the behavior evolves?
In the next section, we will look at a small example to see how this idea appears in actual code.
How OCP Looks in Code
Imagine we are building a checkout feature. The checkout flow needs to request a payment for an order. At first, the system only supports one payment provider, for example Stripe. Later, the business wants to add Xendit. After that, maybe another provider is added.
We can model the basic data like this:
enum PaymentProvider {
case stripe
case xendit
}
struct Order {
let id: String
let totalAmount: Decimal
let currency: String
}
struct PaymentRequest {
let orderId: String
let amount: Decimal
let currency: String
}
enum PaymentStatus {
case success
case failed
}
struct PaymentResult {
let transactionId: String?
let status: PaymentStatus
}
A common early implementation is to place both the provider selection and the provider-specific behavior inside the same checkout service
final class CheckoutService {
func requestPayment(for order: Order, provider: PaymentProvider) async throws -> PaymentResult {
let request = PaymentRequest(
orderId: order.id,
amount: order.totalAmount,
currency: order.currency
)
switch provider {
case .stripe:
// Build Stripe-specific payload
let providerResponse = /* call Stripe API */
return PaymentResult(
transactionId: providerResponse.transactionId,
status: .success
)
case .xendit:
// Build Xendit-specific payload
let providerResponse = /* call Xendit API */
return PaymentResult(
transactionId: providerResponse.transactionId,
status: .success
)
}
}
}
Writer Note:
The problem is not the switch itself, but that CheckoutService becomes the place where provider variation accumulates
For now, this may still look good or acceptable. The service receives the order, checks the selected provider, and performs the payment. The problem appears when the system grows. Every time a new provider is added, the same CheckoutService must be modified again.
That means the checkout flow is doing two jobs at once:
- coordinating the payment request flow
- holding provider-specific integration logic
So the same class becomes the place where all provider variation keeps accumulating. This is the part that starts to violate the spirit of OCP.
The issue is not that the business requirement is wrong. The system still truly needs to use the selected provider. The issue is that the variation and the stable flow are both living inside the same class.
This is where OCP gives us a better direction. We still keep the same requirement, but we move the provider-specific behavior behind a stable abstraction.
First, we define a contract/abstraction for payment gateways
protocol PaymentGateway {
var provider: PaymentProvider { get }
func requestPayment(_ request: PaymentRequest) async throws -> PaymentResult
}
Then each provider can implement that contract in its own class:
final class StripePaymentGateway: PaymentGateway {
let provider: PaymentProvider = .stripe
func requestPayment(_ request: PaymentRequest) async throws -> PaymentResult {
// Build Stripe-specific payload
let providerResponse = /* call Stripe API */
return PaymentResult(
transactionId: providerResponse.transactionId,
status: .success
)
}
}
final class XenditPaymentGateway: PaymentGateway {
let provider: PaymentProvider = .xendit
func requestPayment(_ request: PaymentRequest) async throws -> PaymentResult {
// Build Xendit-specific payload
let providerResponse = /* call Xendit API */
return PaymentResult(
transactionId: providerResponse.transactionId,
status: .success
)
}
}
At this point, we still have the same business requirement as before
given an order and a selected provider, the system must use the correct provider to request payment.
So the goal is not to remove provider selection completely. The goal is to stop putting the selection and all provider-specific implementation details into one growing checkout service.
The Orchestration
After separating the provider-specific logic behind focused classes, we still need a way to coordinate them into one complete checkout flow. This is where orchestration comes in.
In this case, orchestration means one part of the code is responsible for:
- receiving the order and selected provider
- preparing the payment request
- resolving the correct payment gateway
- delegating the payment work
It does not take over the provider-specific responsibilities. Instead, it coordinates the flow while each concrete gateway handles its own integration details.
To support that flow, we first define a small abstraction for resolving the correct gateway.
protocol PaymentGatewayResolving {
func resolve(for provider: PaymentProvider) throws -> PaymentGateway
}
This protocol gives the checkout flow a stable way to ask for the correct payment gateway. The checkout flow does not need to know whether the gateway comes from a dictionary, a factory, or some other setup. It only needs to know that, given a provider, it can get the correct PaymentGateway. After that we create implementation of it using registry.
enum PaymentGatewayResolverError: Error {
case gatewayNotFound
}
final class PaymentGatewayRegistry: PaymentGatewayResolving {
private let gateways: [PaymentProvider: PaymentGateway]
init(gateways: [PaymentGateway]) {
self.gateways = Dictionary(
uniqueKeysWithValues: gateways.map { ($0.provider, $0) }
)
}
func resolve(for provider: PaymentProvider) throws -> PaymentGateway {
guard let gateway = gateways[provider] else {
throw PaymentGatewayResolverError.gatewayNotFound
}
return gateway
}
}
The responsibility of choosing the correct gateway is still present, but it is no longer mixed with provider-specific payment behavior inside the same service. PaymentGatewayRegistry stores the configured gateways and returns the correct one based on the provider. Its responsibility is to manage gateway lookup over the registered implementations. It doesn't build payment requests, it doesn't call external APIs, and it doesn't coordinate checkout. That is why this class stays focused.
The resolver needs to know which gateway implementations are available. A simplified setup can register them like this:
let gateways: [PaymentGateway] = [
StripePaymentGateway(),
XenditPaymentGateway()
]
let gatewayRegistry = PaymentGatewayRegistry(gateways: gateways)
This setup shows that the available gateway implementations are registered once, then made available through the registry. If a new provider is added later, we add a new PaymentGateway implementation and register it here. In this simplified example, the PaymentProvider enum also still needs to be updated. The important point is that the checkout flow itself no longer needs to accumulate provider-specific branches.
So in this example, OCP does not eliminate modification from the whole system. It mainly prevents CheckoutService from being the place that keeps changing.
With that in place, CheckoutService can focus on the business flow itself.
final class CheckoutService {
private let gatewayResolver: PaymentGatewayResolving
init(gatewayResolver: PaymentGatewayResolving) {
self.gatewayResolver = gatewayResolver
}
func requestPayment(for order: Order, provider: PaymentProvider) async throws -> PaymentResult {
let request = PaymentRequest(
orderId: order.id,
amount: order.totalAmount,
currency: order.currency
)
let gateway = try gatewayResolver.resolve(for: provider)
return try await gateway.requestPayment(request)
}
}
Now the checkout flow no longer depends directly on concrete provider-specific integration logic. Instead, it depends on abstractions that separate the stable checkout flow from provider-specific behavior.
This is still the same use case as the original version.
The checkout flow still:
- receives the order
- receives the selected provider
- builds the payment request
- uses the correct provider
- returns a payment result
The difference is in where the responsibilities now live.
Before
CheckoutService:
- selected the provider
- contained Stripe logic
- contained Xendit logic
- kept growing as variation increased
After
CheckoutService:
- coordinates the checkout payment flow
- asks another component to resolve the correct gateway
- delegates provider-specific work to the gateway
- remains relatively stable as new providers are added
StripePaymentGateway and XenditPaymentGateway:
- own their own integration logic
PaymentGatewayRegistry:
- knows how to find the correct gateway for a provider
That is the key improvement.
In the original version, the switch both selected the provider and contained the provider-specific implementation logic. In the refactored version, the checkout flow still works with the same provider selection requirement, but the implementation logic has been moved behind stable abstractions.
In this example, the OCP improvement is this:
instead of handling variation by repeatedly modifying the same branching logic, we handle it by adding new implementations behind a stable abstraction.
In other cases, OCP may look different. The variation might be moved into configuration, separate rule objects, plugins, handlers, factories, or another module boundary. So the point is not that OCP always means replacing switch with protocols. The point is to separate the part that changes, so the stable part does not need to be modified again and again.
What is being protected here?
In this example, the variation is the payment provider integration. The more stable part is the checkout flow that needs to request a payment for an order.
By introducing PaymentGateway, the checkout flow no longer needs to know how each provider behaves internally. By introducing PaymentGatewayResolving, CheckoutService no longer needs to know how the provider lookup is implemented.
So the business flow stays the same, but the provider-specific variation no longer keeps spreading into the same service.
Is this “true OCP”?
In my POV, this is a good practical OCP improvement.
It does not mean nothing in the whole system ever changes again. For example:
- a new provider may still require a new PaymentGateway implementation
- the PaymentProvider enum may still need to be updated
- the list of registered gateways may still need to be updated That is okay.
The point of OCP here is that the stable checkout flow is no longer the place that must keep accumulating provider-specific branches and logic every time the variation grows. The modifications are reduced and pushed toward the variation side of the design instead.
This is only a simplified example to show the OCP boundary clearly. In a real system, the resolver may be backed by a registry, dependency injection setup, or some other composition mechanism. Those details are omitted here so the example can stay focused on the main design change, the provider variation has been moved behind a stable boundary.
Final note:
OCP here does not remove modification completely. It moves most of the modification pressure away from the stable checkout flow and toward the variation side of the design.
From the Writer
Hello, allow me to introduce myself. I’m Cakoko. We’ve reached the end of this article, and I sincerely thank you for taking the time to read it.
If you have any questions or feedback, feel free to reach out to me directly via email at cakoko.dev@gmail.com. I’m more than happy to hear your thoughts, whether they are about my English writing, the technical ideas in this article, or anything I may have misunderstood. Your feedback will help me grow.
I look forward to connecting with you in future articles. Btw, I’m a mobile developer, final year Computer Science student, and an Apple Developer Academy @ IL graduate. I’m also open to various opportunities such as collaborations, internships, or full-time positions. It would make me very happy to explore those possibilities.
Until next time, stay curious and keep learning.
Open for Feedback
This article is part of my personal learning journey. It may not be completely accurate or perfect, and that is okay. I’m sharing what I’ve learned so far in the hope that it can also help others who are exploring similar topics.
If you have any feedback, suggestions, or corrections, I would truly appreciate them. I’m always open to learning more and improving along the way.
Top comments (0)