Clean Architecture: Using Interface Segregation in Go

Clean Architecture: Using Interface Segregation in Go
Photo by Chinmay B / Unsplash

In this blog, we will talk about how we can use Go's powerful interfaces to optimize our Clean Architecture or Domain Driven Design (DDD) by implementing Interface Segregation from SOLID. That is a lot of terminology in a single sentence; let's break it down.

Basics

If you already have an understanding of Clean Architecture, SOLID and Interface Segregation, you can skip this Basics section.

Clean Architecture

Clean Architecture is a design philosophy originated from Uncle Bob (Robert C. Martin) in his identically named book: Clean Architecture.

It suggests a software architecture with universal rules that allow us to improve how long we can maintain a system in the long run. While the majority of the subject is very interesting, and I think every developer should at least read the book, I don't think you should treat it as a holy grail. Discover what rules make sense for you and your project and apply these. I think it is perfectly okay to create your own version of Clean Architecture after you have read the book.

This article requires a basic understanding of both Clean Architecture and Go moving forward.

SOLID

SOLID comprises 5 design principles that allow you to write better maintainable code:

  • Single responibility
  • Open-closed
  • Liskov
  • Interface Segregation
  • Dependency Inversion

For the sake of this article, I will only focus on Interface Segregation.

Wikipedia states:

In the field of software engineering, the interface segregation principle (ISP) states that no code should be forced to depend on methods it does not use. ISP splits interfaces that are very large into smaller and more specific ones so that clients will only have to know about the methods that are of interest to them.

This means that we should not depend on an interface with 10 methods when we only use 2 methods of the interface. You will have a very complex dependency which is harder to maintain, support, and also mock during tests. We should only depend on an interface with 2 methods, the ones we are actually implementing.

Interface Segregation

In traditional OOP languages, we define the interface and then explicitly depend on that interface when we write our implementation. For example:

interface IPaymentGateway {
    String getPaymentStatusByID(String id)
    void CreatePayment(Int amount)
    void UpdatePayment(String id, Int amount)
}

class StripePaymentGateway implements IPaymentGateway {
    // Implements methods here
}

Our interface and implementation in Java.

When we create a use case, for example, to check the payment status (to ship an order after it has been paid), we would depend on the interface.

class PaymentStatusUsecase implements Usecase {
    final IPaymentGateway paymentGateway;

    PaymentStatusUsecase(IPaymentGateway paymentGateway) {
      paymentGateway = paymentGateway;
    }

    void handleUsecase(String paymentID) {
        paymentGateway.getPaymentStatusByID(paymentID);
    }
}

An usecase depending on the defined interface.

You can already read here; the use case only uses part of the interface, 1 of 3 available methods. Our interface actually doesn't need to depend on an entire IPaymentGateway, but wants something that can look for a payment status, for example, an IPaymentStatusFetcher. We can update our interfaces and implementation in other traditional languages. But what if the gateway was created in a third-party module we have imported? We will need to write a lot of wrapper code around the library so we can have the implementation depend on multiple small interfaces.

interface IPaymentStatusFetcher {
    String getPaymentStatusByID(String id)
}

interface IPaymentGateway {
    void CreatePayment(Int amount)
    void UpdatePayment(String id, Int amount)
}

class StripePaymentGateway implements IPaymentGateway, IPaymentStatusFetcher {
    // Implements methods here
}

An example breaking down into smaller interfaces

Or maybe the package does not even have the PaymentGateway implement any interfaces. You would also need to write a wrapper around the package. However...

Go

Go does interfaces a bit differently than traditional OOP languages because the compiler does not require you to explicitly define that a certain struct implements an interface.

If a struct methods match the interface signature, it will automatically be approved to be used where the interface is required.

type IPaymentStatusFetcher interface {
    getPaymentStatusByID(id string) (string, err)
}

type StripePaymentGateway struct {
    // any struct attributes here
}

func (g *StripePaymentGateway) getPaymentStatusByID(id string) (string, err) {
    // implementation here
}

// other methods that this Gateway supports

Example on defining the interface and implementation in Go

What makes this very powerful is that we do not need to tell the implementation upfront what interface it needs to support. So we can now move the interface definition away from the implementation, but near where the interface is needed. This can be a service, use case, or any other class requiring an interface dependency.

type IPaymentStatusFetcher interface {
    getPaymentStatusByID(id string) string
}

type GetPaymentStatusUsecase struct {
    paymentStatusFetcher IPaymentStatusFetcher
}

func NewGetPaymentStatusUsecase(paymentStatusFetcher PaymentStatusFetcher) {
    return GetPaymentStatusUsecase{
        paymentStatusFetcher: paymentStatusFetcher
    }
}

func (u *GetPaymentStatusUsecase) handleUsecase(paymentID string) err {
   status, err := u.paymentStatusFetcher.getPaymentStatusByID(paymentID)
   if err != nil {
       return err
   }

   // do something with the status

   return nil
}

This allows us to define only what we need for our code that is dependent on the interface. This also means we can easily create interfaces for structs defined in external classes, allowing us to easily work and mock these dependencies.

Bonus: Unit testing with dependencies

Using mockery, we can easily create mocks for our dependencies too, allowing us to quickly start writing unit tests and mocking out our dependencies.

On the example above, we can just add the following above our interface definition:

//go:generate mockery --name PaymentStatusFetcher
type IPaymentStatusFetcher interface {
    getPaymentStatusByID(id string) string
}

Make sure mockery is installed, and we can then run go generate ./... and the mocks will be created in a subfolder and package named mocks near where you defined the interface as well.

Conclusion

I hope this article gave enough background information on Interface Segregation together with Clean Architecture and the examples helped you understand why Go's interface is so powerful and easy to use.

I always try to define any dependency as an interface because it is so simple to achieve using Go. It helps me think about structure first, and implementation later and really simplifies writing unit tests. This all combined allows us to extend the longlivity of our codebase, which is what Uncle Bob's goal was with Clean Architecture.