Practical SOLID in Golang: Open/Closed Principle

- 7 minutes read - 1364 words

We 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:

  1. AuthenticationService mixes logic initially handled in another location.
  2. Any changes to the authentication logic, even if it’s in a different module, require modifications in AuthenticationService.
  3. Adding a new method of extracting an User from Context always necessitates modifications to AuthenticationService.
  4. The logic within AuthenticationService inevitably grows with each new authentication method.
  5. 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.

Useful Resources

comments powered by Disqus