Skip to main content
  1. Articles/

Practical SOLID in Golang: Open/Closed Principle

·1369 words·7 mins· loading · loading ·
Marko Milojevic
Author
Marko Milojevic
Software engineer and architect. Golang and LLM enthusiast. Awful chess player, gym rat, harmonica newbie and cat lover.
SOLID principles in Golang - This article is part of a series.
Part 2: This Article

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
#

SOLID principles in Golang - This article is part of a series.
Part 2: This Article

Related

Practical SOLID in Golang: Single Responsibility Principle

·2013 words·10 mins· loading · loading
There aren’t too many opportunities for a breakthrough in software development. They usually arise from either rewiring our logic after initial misunderstandings or filling in gaps in our knowledge. I appreciate that feeling of deeper understanding. It can happen during a coding session, while reading a book or an online article, or even while sitting on a bus. An internal voice follows, saying, “Ah, yes, that’s how it works.” Suddenly, all past mistakes seem to have a logical reason, and future requirements take shape. I experienced such a breakthrough with the SOLID principles, which were first introduced in a document by Uncle Bob and later expounded upon in his book, “Clean Architecture.” In this article, I intend to embark on a journey through all the SOLID principles, providing examples in Go. The first principle on the list, representing the letter ‘S’ in SOLID, is the Single Responsibility Principle. When we do not respect Single Responsibility # The Single Responsibility Principle (SRP) asserts that each software module should serve a single, specific purpose that could lead to change. The sentence above comes directly from Uncle Bob himself. Initially, its application was linked to modules and the practice of segregating responsibilities based on the organization’s daily tasks. Nowadays, SRP has a broader scope, influencing various aspects of software development. We can apply its principles to classes, functions, modules, and naturally, in Go, even to structs. Some Frankenstein of EmailService type EmailService struct { db *gorm.DB smtpHost string smtpPassword string smtpPort int } func NewEmailService(db *gorm.DB, smtpHost string, smtpPassword string, smtpPort int) *EmailService { return &EmailService{ db: db, smtpHost: smtpHost, smtpPassword: smtpPassword, smtpPort: smtpPort, } } func (s *EmailService) Send(from string, to string, subject string, message string) error { email := EmailGorm{ From: from, To: to, Subject: subject, Message: message, } err := s.db.Create(&email).Error if err != nil { log.Println(err) return err } auth := smtp.PlainAuth("", from, s.smtpPassword, s.smtpHost) server := fmt.Sprintf("%s:%d", s.smtpHost, s.smtpPort) err = smtp.SendMail(server, auth, from, []string{to}, []byte(message)) if err != nil { log.Println(err) return err } return nil } Let’s analyze the code block above. In this code, we have a struct called EmailService, which contains only one method, Send. This service is intended for sending emails. Although it may seem okay at first glance, upon closer inspection, we realize that this code violates the Single Responsibility Principle (SRP) in several ways.

Practical DDD in Golang: Specification

·1596 words·8 mins· loading · loading
There are not many code structures that bring me joy whenever I need to write them. The first time I implemented such code was with a lightweight ORM in Go, back when we didn’t have one. However, I used ORM for many years, and at some point, when you rely on ORM, using QueryBuilder becomes inevitable. Here, you may notice terms like “predicates”, and that’s where we can find the Specification pattern. It’s hard to find any pattern we use as Specification, yet we do not hear its name. I think the only thing harder is to write an application without using this pattern. The Specification has many applications. We can use it for querying, creation, or validation. We may provide a unique code that can do all this work or provide different implementations for different use cases. For Validation # The first use case for the Specification pattern is validation. Typically, we validate data in forms, but this is at the presentation level. Sometimes, we perform validation during creation, such as for Value Objects. In the context of the domain layer, we can use Specifications to validate the states of Entities and filter them from a collection. So, validation at the domain layer has a broader meaning than for user inputs. Base Product Specification type Product struct { ID uuid.UUID Material MaterialType IsDeliverable bool Quantity int } type ProductSpecification interface { IsValid(product Product) bool } A simple Product Specification type HasAtLeast struct { pieces int } func NewHasAtLeast(pieces int) ProductSpecification { return HasAtLeast{ pieces: pieces, } } func (h HasAtLeast) IsValid(product Product) bool { return product.Quantity >= h.pieces } In the example above, there is an interface called ProductSpecification. It defines only one method, IsValid, which expects instances of Product and returns a boolean value as a result if the Product passes validation rules. A simple implementation of this interface is HasAtLeast, which verifies the minimum quantity of the Product. Function as a Product Specification type FunctionSpecification func(product Product) bool func (fs FunctionSpecification) IsValid(product Product) bool { return fs(product) } func IsPlastic(product Product) bool { return product.Material == Plastic } func IsDeliverable(product Product) bool { return product.IsDeliverable } More interesting validators are two functions, IsPlastic and IsDeliverable. We can wrap those functions with a specific type, FunctionSpecification. This type embeds a function with the same signature as the two mentioned. Besides that, it provides methods that respect the ProductSpecification interface. This example is a nice feature of Go, where we can define a function as a type and attach a method to it so that it can implicitly implement some interface. In this case, it exposes the method IsValid, which executes the embedded function.

Practical DDD in Golang: Repository

·2108 words·10 mins· loading · loading
Today, it is hard to imagine writing an application without accessing some form of storage at runtime. This includes not only writing application code but also deployment scripts, which often need to access configuration files, which are also a type of storage in a sense. When developing applications to solve real-world business problems, connecting to databases, external APIs, caching systems, or other forms of storage is practically unavoidable. It’s no surprise, then, that Domain-Driven Design (DDD) includes patterns like the Repository pattern to address these needs. While DDD didn’t invent the Repository pattern, it added more clarity and context to its usage. The Anti-Corruption Layer # Domain-Driven Design (DDD) is a principle that can be applied to various aspects of software development and in different parts of a software system. However, its primary focus is on the domain layer, which is where our core business logic resides. While the Repository pattern is responsible for handling technical details related to external data storage and doesn’t inherently belong to the business logic, there are situations where we need to access the Repository from within the domain layer. Since the domain layer is typically isolated from other layers and doesn’t directly communicate with them, we define the Repository within the domain layer, but we define it as an interface. This interface serves as an abstraction that allows us to interact with external data storage without tightly coupling the domain layer to specific technical details or implementations. A Simple Repository example type Customer struct { ID uuid.UUID // // some fields // } type Customers []Customer type CustomerRepository interface { GetCustomer(ctx context.Context, ID uuid.UUID) (*Customer, error) SearchCustomers(ctx context.Context, specification CustomerSpecification) (Customers, int, error) SaveCustomer(ctx context.Context, customer Customer) (*Customer, error) UpdateCustomer(ctx context.Context, customer Customer) (*Customer, error) DeleteCustomer(ctx context.Context, ID uuid.UUID) (*Customer, error) } The interface that defines method signatures within our domain layer is referred to as a “Contract.” In the example provided, we have a simple Contract interface that specifies CRUD (Create, Read, Update, Delete) methods. By defining the Repository as this interface, we can use it throughout the domain layer. The Repository interface always expects and returns our Entities, such as Customer and Customers (collections with specific methods attached to them, as defined in Go). It’s important to note that the Entity Customer doesn’t contain any information about the underlying storage type, such as Go tags for defining JSON structures, Gorm columns, or anything of that sort. This kind of low-level storage configuration is typically handled in the infrastructure layer.