Engineering

Top-down development with Golang interfaces

Golang interfaces encourage a top-down development approach. First, you write the top-level package. It can be

  • an HTTP handler if you develop web application
  • a function, that reads CLI flags if you develop CLI tool
  • an SDK function, if you develop SDK, or just want to abstract your core logic from entry point protocol such as HTTP, CLI, gRPC, etc.

Second, when you develop this package you probably want to decompose your work. For example, you want to have some abstraction to work with the persistence layer. At this point, you define the interface. You describe signatures of methods that you would like to have, for example:

type Storage interface { func Save(entity Entity) error func Read(id string) (Entity, error) }

You don’t think so much about how exactly an entity will be persisted, sometimes you even don’t have any idea about what database you are going to use. But you can still continue to develop your functionality. It is called the top-down approach and it has its own advantages and disadvantages. You can focus on major functionality, not details, you can mock your dependencies for development or test purposes.

Interface’s implementation

When you finish your package then you go down the package tree and start implementing interfaces that you defined in the top-level package. These implementations must satisfy specific interfaces of the top-level package. This means they won’t be very reusable. It happens because of two reasons

  1. You follow the signature that was defined at the top-level package. Other potential consumers define their own interfaces and their signatures might not be the same.
  2. You import types (for example Entity in the example above) in your implementation to satisfy the interface. This type belongs to your top-level package.

It’s important to note that in Golang interfaces are implemented implicitly, and this is the explanation for this design on the tour.golang.org website:

A type implements an interface by implementing its methods. There is no explicit declaration of intent, no “implements” keyword. Implicit interfaces decouple the definition of an interface from its implementation, which could then appear in any package without prearrangement.

For me, this explanation is a little sly. Because you must import types from the package that declares an interface anyway, for example, Entity. The only case when your implementation can really appear in any package without rearrangement as claimed above is when signatures of methods contain only primitive types, for example:

func Error() string

It is also a very popular decision to put your types not inside the package with the interface but in the separate third package, like this:

webhandlers/ <- top-level package declares only interface, not types webhandlers/webhandlers.go storage/ storage/storage.go types/ types/types.go <- Entity is declared here.

But this approach didn’t remove coupling between your implementation and interface at all. Because you still need to use the same types that are used in the package which declares interfaces.

It leads to the first problem of a top-down approach. You have strong coupling. Your implementation is developed with the caller in mind. It is ok in most situations. Let’s be honest, when you develop an application you rarely want all your submodules to be reusable by other applications, not only your own. But if you realise that some piece of your code should really be independent you’ll have to implement it without taking any interfaces into account to design a good API reusable by different applications.

Then you just write some kind of adapter package, that satisfies the interface in the top-level package by relying on a new dependency that you developed. A common approach is to move such re-usable packages into separate code repositories with different lifecycles to totally decouple them from your application.

The second problem of a top-down approach is that sometimes you cannot define interfaces without learning how underlying technologies work and what their API looks like. It is ok because you shouldn’t blindly follow any rules. Feel free to mix the top-down approach with the bottom-up approach. You start your application from the top-level module, but if you need to know details or write them first let’s do that.

Conclusion

Golang encourages you to write programs top-down with the interfaces feature. They are defined in upper-level packages (that need some dependencies) and implemented in lower-level packages (interface implementations). This way our upper-level packages are decoupled from the dependencies but our lower-level packages are still tightly coupled to their callers.

Sometimes it is ok, but if you think that some of your lower-level packages can be more re-usable – just forget about any interfaces and design this package with flexible and re-usable API that can be used by different consumers. Then just write adapter code/package that satisfies the interface in the upper package by adopting lower package API. Mix top-down and bottom-up development approaches and write reliable software with Golang.

November 01, 2021

Vlad Tokarev

Backend engineer