Go S.O.L.I.D: The Single Responsibility Principle

📚 4 min read Tweet this post

SOLID is a set of five principles of object-oriented design that were introduced by Robert C. Martin. These principles can help developers create more maintainable, scalable, and flexible software applications. The SOLID principles are:

  • Single Responsibility Principle (SRP): This principle states that a class should have only one reason to change. In other words, a class should have a single responsibility and should not have multiple responsibilities that are unrelated to one another.

  • Open-Closed Principle (OCP): This principle states that a class should be open for extension but closed for modification. In other words, you should be able to add new functionality to a class without changing its existing code.

  • Liskov Substitution Principle (LSP): This principle states that if a class is a subtype of another class, then it should be able to be used in the same way as the parent class without any issues.

  • Interface Segregation Principle (ISP): This principle states that clients should not be forced to depend on interfaces they do not use. In other words, you should create smaller, more focused interfaces rather than having a large, generic interface that has a lot of methods that may not be used by all clients.

  • Dependency Inversion Principle (DIP): This principle states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. This helps to reduce the coupling between modules and makes it easier to change the implementation of a module without affecting the rest of the system.

In Go, these principles can be applied in a variety of ways. For example, you can use interfaces to implement the Open-Closed Principle and the Interface Segregation Principle. You can also use dependency injection to implement the Dependency Inversion Principle. By following these principles, you can design your Go code in a way that is more maintainable, scalable, and flexible.

This post will be focusing on the SRP. The Single Responsibility Principle (SRP) is a software design principle that states that a class or module should have only one reason to change. In other words, a class or module should have a single, well-defined responsibility and should not have multiple responsibilities that are unrelated to one another.

Here is an example implementation when designing a struct to manage customer orders in an online store:

type Order struct {
	ID int
	Customer Customer
	Items []Item
	Total float64
}

func (o *Order) AddItem(item Item) {
	o.Items = append(o.Items, item)
	o.Total += item.Price
}

func (o *Order) RemoveItem(item Item) {
	for i, oItem := range o.Items {
		if oItem.ID == item.ID {
			o.Items = append(o.Items[:i], o.Items[i+1:]...)
			o.Total -= oItem.Price
			break
		}
	}
}

func (o *Order) Submit() error {
	// Validate the order
	if len(o.Items) == 0 {
		return errors.New("Cannot submit an empty order")
	}
	if o.Total < 0 {
		return errors.New("Invalid order total")
	}

	// Save the order to the database
	// Send a notification to the customer
	// Update inventory
	// etc.
	return nil
}

In this example, the Order struct has three methods: AddItem, RemoveItem, and Submit. The AddItem and RemoveItem methods are responsible for adding and removing items from the order, respectively. The Submit method is responsible for submitting the order and performing various tasks related to completing the order, such as validating the order, saving it to the database, and sending a notification to the customer.

By separating the responsibilities of the Order struct into distinct methods, we can ensure that each method has a single, well-defined responsibility. This makes the code easier to understand and maintain, as each method only has a single purpose and can be changed or modified independently of the other methods.

One way to separate the responsibilities of the Order struct in the example I provided would be to create separate structs for different responsibilities. For example, you could create a separate OrderValidator struct that is responsible for validating orders, a separate OrderPersistence struct that is responsible for saving orders to the database, and a separate NotificationService struct that is responsible for sending notifications to customers.

Here is an example of how these separate the struct:

type OrderValidator struct{}

func (v *OrderValidator) Validate(order Order) error {
	if len(order.Items) == 0 {
		return errors.New("Cannot submit an empty order")
	}
	if order.Total < 0 {
		return errors.New("Invalid order total")
	}
	return nil
}

type OrderPersistence struct{}

func (p *OrderPersistence) Save(order Order) error {
	// Save the order to the database
	return nil
}

type NotificationService struct{}

func (n *NotificationService) SendNotification(customer Customer, message string) error {
	// Send a notification to the customer
	return nil
}

To use these structs in the Order struct, you could pass them as dependencies to the Order constructor, like this:

type Order struct {
	ID int
	Customer Customer
	Items []Item
	Total float64
	validator OrderValidator
	persistence OrderPersistence
	notification NotificationService
}

func NewOrder(customer Customer, validator OrderValidator, persistence OrderPersistence, notification NotificationService) *Order {
	return &Order{
		Customer: customer,
		validator: validator,
		persistence: persistence,
		notification: notification,
	}
}

Then, in the Submit method, you can use these dependencies to perform the tasks related to completing the order:

func (o *Order) Submit() error {
	if err := o.validator.Validate(o); err != nil {
		return err
	}
	if err := o.persistence.Save(o); err != nil {
		return err
	}
	if err := o.notification.SendNotification(o.Customer, "Your order has been received and is being processed."); err != nil {
		return err
	}
	return nil
}

By separating the responsibilities of the Order struct into separate structs, you can more easily follow the Single Responsibility Principle and make the code easier to maintain and understand.

In a real-world application, the Order struct might have additional methods for performing other tasks related to managing orders, such as canceling an order or processing a refund. However, each of these methods should have a single, well-defined responsibility, in accordance with the Single Responsibility Principle.

programming go solid