How to Structure Go Code

Ill highlight three approaches to organizing and structuring your Go code. Each approach introduces a different level of abstraction.


I’ll highlight three approaches to organizing and structuring your Go code. Each approach introduces a different level of abstraction. Then, I’ll compare them all and cover the use cases for each one.

Our goal is to implement an HTTP server containing user information (Main DB in the figure below), where each user has a role (e.g. basic, moderator, admin), with an additional database (Configuration DB in the figure below), containing sets of permissions available for each role (e.g. read, write, edit).

Our HTTP server should implement an endpoint that returns a set of permissions for a given user ID.


Let’s further assume that the configuration DB rarely changes and has long loading times, so we want to maintain it in-memory, load it once the server starts, and refresh it once per hour.

The entire code can be found in the repository on GitHub.

. . .

Approach I: Single Package

The single package approach introduces a flat hierarchy, in which the entire server is implemented in one package. Full code.

Note: the comments in the code snippets are important for understanding the principles of each approach.

main.go
database.go
handler.go
We still use separate files to divide different responsibilities. This makes it more readable and easier to maintain.
. . .

Approach II: Coupled Packages

In this approach we introduce packages. A package should have sole responsibility over some behavior. Here we allow packages to interact with each other, thus needing to maintain less code.

Still, we have to make sure we’re not breaking the principle of responsibility to ensure each piece of logic is implemented completely in a single package.

Another important guideline for this approach is that since Go disallows circular dependencies between packages, we have to create a neutral package containing only bare definitions of interfaces and singleton instances. This will ensure our code is free from circular dependencies. Full code.

main.go
definition/database.go
definition/config.go
database/user.go
database/config.go
config/permissions.go
handler/user_permissions_by_id.go

. . .


Approach III: Independent Packages

In this approach we also organize our project in packages. Here, each package must declare all of its dependencies locally via interfaces and variables. This makes it completely unaware of other packages.

In this approach, the definition package from the previous approach is actually spread between all the other packages; each package declaring its own interface for every service. This may seem as an annoying duplication at first glance, but it isn’t. Each package that uses a service should declare its own interface, specifying only what it requires from it, omitting the rest. Full code.

main.go
database/user.go
database/config.go
config/permissions.go
handler/user_permissions_by_id.go
That’s it! Those are the 3 levels of abstraction, the first one being the most slim, containing global state and tightly coupled logic, providing the fastest implementation and the least code to write and maintain, the second being a moderate hybrid, and the third completely decoupled and reusable but requiring the most overhead for maintenance.
. . .

Pros and Cons


Approach I: Single Package
Pros:
  1. Least code, much faster to implement, less to maintain.
  2. No packages mean no circular dependencies to keep in mind.
  3. Easy for testing due to the existence of service interfaces. In order to test some piece of logic, you may set the singleton instances to any implementation you choose (concrete or mock) and then fire the test logic.

Cons
  • Single package also means no private access, everything is accessible from everywhere, this puts more responsibility on the developer. For example, remember not to instantiate struct directly when constructor function is required in order to perform some init logic.
  • Global state (the singleton instances) may create an unmet assumption, for example, uninitialized singleton instance will cause nil pointer panic at runtime.
  • Tightly coupled logic means nothing can easily be extracted or reused from this project.
  • Having no packages independently managing pieces of logic also means the developer must be very responsible placing every piece of code correctly, otherwise unexpected behavior may arise.
Approach II: Coupled Packages
Pros
  • Packaging our project helps us ensure responsibility of logic per package, and can be enforced by the compiler. Additionally, we can use private access, and have control over what we choose to expose.
  • The use of definition package allows for singleton instances while avoiding circular dependencies. This means we can write less code, avoid managing reference passing of instances, and not wasting any time on potential compile issues.
  • This approach is also ready for testing due to the existence of service interfaces. With this approach each package may be tested internally.
Cons
  • Organizing project in packages has some overhead, the initial implementation will probably take longer than the single package approach.
  • The use of global state (singleton instances) in this approach may cause issues as well.
  • The project is separated into packages, which makes it much easier to extract and reuse logic. However, the packages are not completely independent since they all interact with the definition package. In this approach extraction of code and reusability are not completely automatic.
Approach III: Independent Packages
Pros
  • Packaging helps us ensure responsibility of logic in one package, and have access control.
  • No potential circular dependencies since packages are completely independent.
  • All packages are completely extractable and reusable. Whenever a package is needed in another project, it can simply be moved to a shared location and used without having to change a thing.
  • No global state means no unexpected behavior.
  • This approach is the best one for testing. Each package may be completely tested without depending on other packages due to the local interfaces.

Cons
  • This approach is a bit slower to implement than the other two.
  • A lot more to maintain. Reference passing means we have a lot of places we need to update when we need to write breaking changes. Also, having multiple interfaces representing the same service means we have to update all of them every time we introduce a change to that service.
. . .

Conclusions and Use Cases

Given the lack of community guidelines Go code comes in many shapes and forms, each has interesting merits. However, mixing different design patterns may cause problems. To organize this, I’ve introduced three different approaches to write and structure Go code. So when should each approach be used? I propose the following:

Approach I: The single package approach will probably be the go-to approach when working in small, well-experienced teams on small projects wanting to achieve fast results. This approach is faster and easier to kick start, although it requires much caution and coordination when maintaining due to lack of enforcement capabilities.

Approach II: The coupled packages approach is kind of a hybrid fusion of the other two, it has the advantages of being relatively fast and easy to maintain, while having most of the enforcement capabilities. It may be used for bigger projects by bigger teams, but still lacks reusability and has some overhead maintaining.

Approach III: The independent packages approach may suit projects of a more complex nature, bigger projects, projects that are probably more long term, worked on by bigger teams, or projects that contain pieces of logic that will probably be reused later on. This approach has a longer implementation time, and takes more time to maintain.

Never miss a post from Ritesh swamy, when you sign up for Ednsquare.