Practical SOLID in Golang: Open/Closed Principle
- 7 minutes read - 1364 wordsWe continue our journey through the SOLID principles by presenting the one that enhances the flexibility of applications: The Open/Closed Principle.

Many different approaches and principles can lead to long-term improvements in our code. Some of them are well-known in the software development community, while others remain somewhat under the radar. In my opinion, this is the case with The Open/Closed Principle, represented by the letter O in the word SOLID. In my experience, only those genuinely interested in SOLID principles tend to understand what this principle means. We may have applied this principle without even realizing it in some instances, such as when working with the Strategy pattern. However, the Strategy pattern is just one application of the Open/Closed Principle. In this article, we will delve into the full purpose of this principle, with all examples provided in Go.
When we do not respect the Open/Closed Principle
You should be able to extend the behavior of a system without having to modify that system.
The requirement for the Open/Closed Principle, as seen above, was provided by Uncle Bob in his blog. I prefer this way of defining The Open/Closed Principle because it highlights its full brilliance. At first glance, it may seem like an absurd requirement. Seriously, how can we extend something without modifying it? I mean, is it possible to change something without changing it? By examining the code example below, we can see what it means for certain structures not to adhere to this principle and the potential consequences.
The bad Authentication Service
type AuthenticationService struct {
//
// some fields
//
}
func (s *AuthenticationService) Authenticate(ctx *gin.Context) (*User, error) {
switch ctx.GetString("authType") {
case "bearer":
return c.authenticateWithBearerToken(ctx.Request.Header)
case "basic":
return c.authenticateWithBasicAuth(ctx.Request.Header)
case "applicationKey":
return c.authenticateWithApplicationKey(ctx.Query("applicationKey"))
}
return nil, errors.New("unrecognized authentication type")
}
func (s *AuthenticationService) authenticateWithApplicationKey(key string) (*User, error) {
//
// authenticate User from Application Key
//
}
func (s *AuthenticationService) authenticateWithBasicAuth(h http.Header) (*User, error) {
//
// authenticate User from Basic Auth
//
}
func (s *AuthenticationService) authenticateWithBearerToken(h http.Header) (*User, error) {
//
// validate JWT token from the request header
//
}
The example presents a single struct, AuthenticationService
. Its purpose is to authenticate a User
from the web
application’s Context
, supported by the Gin package. Here, we have the main
method, Authenticate, which checks for specific authentication type associated with the data within the Context
.
How User
is retrieved from the Context
may vary based on whether the User
uses a bearer JWT token, basic authentication,
or an application key.
Inside the struct, we’ve included various methods for extracting permission slices in different ways. If we adhere to
The Single Responsibility Principle,
AuthenticationService
should be responsible for determining if the authentication mean exists within the Context
,
without being involved in the authorization process itself. The authorization process should be defined elsewhere,
possibly in another struct or module. So, if we intend to expand the authorization process elsewhere, we’d also need
to adjust the logic here.
This implementation leads to several issues:
AuthenticationService
mixes logic initially handled in another location.- Any changes to the authentication logic, even if it’s in a different module, require modifications in
AuthenticationService
. - Adding a new method of extracting an
User
fromContext
always necessitates modifications toAuthenticationService
. - The logic within
AuthenticationService
inevitably grows with each new authentication method. - Unit testing for
AuthenticationService
involves too many technical details related to different authentication methods.
So, once again, we have some code to refactor.
How we do respect The Open/Closed Principle
The Open/Closed Principle says that software structures should be open for extension but closed for modification.
The statement above suggests potential approaches for our new code, emphasizing the need to adhere to the Open/Closed Principle (OCP). Our code should be designed in a way that enables extensions to be added from external sources. In Object-Oriented Programming, we achieve such extensibility by employing various implementations for the same interface, effectively utilizing polymorphism.
The refactored Authentication Service
type AuthenticationProvider interface {
Type() string
Authenticate(ctx *gin.Context) (*User, error)
}
type AuthenticationService struct {
providers []AuthenticationProvider
//
// some fields
//
}
func (s *AuthenticationService) Authenticate(ctx *gin.Context) (*User, error) {
for _, provider := range c.providers {
if ctx.GetString("authType") != provider.Type() {
continue
}
return provider.Authenticate(ctx)
}
return nil, errors.New("unrecognized authentication type")
}
In the example above, we have a candidate that adheres to the Open/Closed Principle (OCP). The struct,
AuthenticationService
, doesn’t conceal technical details about extracting a User
from the Context
. Instead,
we introduced a new interface, AuthenticationProvider
, which serves as the designated place for implementing various
authentication logic. For instance, it can include TokenBearerProvider
, ApiKeyProvider
, or BasicAuthProvider
.
This approach allows us to centralize the logic for authorized users within one module, rather than scattering it
throughout the codebase. Furthermore, we achieve our primary objective: extending AuthenticationService
without
needing to modify it. We can initialize AuthenticationService
with as many different AuthenticationProviders
as required.
Suppose we want to introduce the capability to obtain a User
from a session key. In that case, we create a new
SessionProvider
, responsible for extracting the cookie from the Context
and using it to retrieve User
from the
SessionStore
. We’ve made it feasible to extend AuthenticationService
whenever necessary, without altering its
internal logic. This illustrates the concept of being open to extension while closed for modification.
Some more examples
We can apply The Open/Closed Principle to methods, not just to structs. An example of this can be seen in the code below:
Breaking OCP in Functions
func GetCities(sourceType string, source string) ([]City, error) {
var data []byte
var err error
if sourceType == "file" {
data, err = ioutil.ReadFile(source)
if err != nil {
return nil, err
}
} else if sourceType == "link" {
resp, err := http.Get(source)
if err != nil {
return nil, err
}
data, err = ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
}
var cities []City
err = yaml.Unmarshal(data, &cities)
if err != nil {
return nil, err
}
return cities, nil
}
The function GetCities
reads the list of cities from some source. That source may be a file or some resource on the
Internet. Still, we may want to read data from memory, from Redis, or any other source in the future. So somehow,
it would be better to make the process of reading raw data a little more abstract. With that said, we may provide a
reading strategy from the outside as a method argument.
Respecting OCP in Functions
type DataReader func(source string) ([]byte, error)
func ReadFromFile(fileName string) ([]byte, error) {
data, err := ioutil.ReadFile(fileName)
if err != nil {
return nil, err
}
return data, nil
}
func ReadFromLink(link string) ([]byte, error) {
resp, err := http.Get(link)
if err != nil {
return nil, err
}
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return data, nil
}
func GetCities(reader DataReader, source string) ([]City, error) {
data, err := reader(source)
if err != nil {
return nil, err
}
var cities []City
err = yaml.Unmarshal(data, &cities)
if err != nil {
return nil, err
}
return cities, nil
}
As you can see in the solution above, in Go, we can define a new type that embeds a function. Here, we’ve created
a new type called DataReader
, which represents a function type for reading raw data from some source. The
ReadFromFile
and ReadFromLink
methods are actual implementations of the DataReader
type. The GetCities
method expects an actual implementation of DataReader
as an argument, which is then executed inside the function
body to obtain raw data. As you can see, the primary purpose of OCP is to provide more flexibility in our code,
making it easier for users to extend our libraries without having to modify them directly. Our libraries become
more valuable when others can extend them without the need forking, pull requests, or modifications to the original code.
Conclusion
Thank you for the explanation! The Open/Closed Principle (OCP) is indeed a crucial SOLID principle, emphasizing the importance of designing software in a way that allows for extension without modification of existing code structures. It promotes the use of polymorphism and the creation of clear interfaces to enable this extensibility. OCP helps make software more adaptable and maintainable as requirements change and new features are added.