Building a monolith in Go - Layout

daunderworks

Doug Bridgens

Posted on May 1, 2024

Building a monolith in Go - Layout

I'm building a reasonably sized commercial app in Go. By sharing decisions and choices (using a fictional restaurant app) I am hoping it may solicit advice from better developers, possibly help others, and generally force me to reflect when writing it out.

The crucial point in scaling a business is when you hire enough developers to be able to specialise effort. That jump from one, two, or five developers working on a shared code-base to multiple teams with their own responsibilities.

This is the moment you have to completely refactor the spaghetti, or have an easy ride to a service architecture. Or probably somewhere in between. Having done this many times I think it's possible to have an easy ride, if and when the time comes to scaling.

Go's package system is very simple, and really suits separated concerns in distinct repo's. However, the practicality of continuously updating local copies becomes a real drag for a small startup. Deploying and versioning multiple services adds to the friction and cost.

By contrast, a monolith is simple. Particularly with Go packages, a monolith layout can emulate a service architecture. If this is possible then we gain simple deployments and a simple dev process.

The key is to build and layout the monolith in a way that makes it easy to extract components later on. While avoiding the (process, infrastructure) overhead of a micro-service architecture from day one.

Package Layout

My fictional restaurant Go Eat has five principle areas of concern. So I create a top-level package for each:

./goeat/booking
./goeat/menu
./goeat/staff
./goeat/service
./goeat/kitchen
Enter fullscreen mode Exit fullscreen mode

I have tried so many layouts that I now realise layout is not strong enough on its own to force good habits. It would be very easy to create a mess from this layout, with deep calls between packages that are hard to unpick.

Domain Boundaries

One of the nasties that hits companies when the try and scale is monolith mess. Code execution paths that are so intertwined that the pragmatic path to scale is rewriting the app. That's a costly way of scaling, but at least you know what it should do.

I want to avoid creating this mess by using Go's internal package qualifier. For example, when writing functions in kitchen I want to prevent dipping into staff because it's convenient.

Go's package export method (capitalised type names) is too lax for my case, at the service/domain/parent-package level. I want to protect package exports inter-service, but still use them intra-service.

To solve this I am using a clearly named publicapi.go file at the top of each (service) package tree. And by protecting all other functions under internal I have enforcement.

./goeat/kitchen/
./goeat/kitchen/internal/rota.go

./goeat/staff/
./goeat/staff/publicapi.go
./goeat/staff/internal/calendar/main.go
Enter fullscreen mode Exit fullscreen mode

My kitchen service needs to update its rota from the calendar package in the staff service. So I create a public API route into staff like this:

// ./goeat/staff/publicapi.go

package staff

import "goeat/staff/internal/calendar"

var (
        GetKitchenRota = func() []string {
                return calendar.GetRota("kitchen")
        }
)
Enter fullscreen mode Exit fullscreen mode

It's really just a local version of an http API. Which will make a future transition easier to do.

And calling this from the kitchen service looks like this:

// ./goeat/kitchen/internal/rota.go

package rota

import (
    "goeat/staff"
)

func fetchRota() []string {

    rota := staff.GetKitchenRota()

    // do something with the rota

    return rota
}
Enter fullscreen mode Exit fullscreen mode

A readability bonus is the name of the import being staff. The domain is much clearer than using a sub-package export, which would result in calendar.GetRota.

Testing

Implementing the public 'api' methods as vars enables me to isolate testing between services.

It is trivial to mock the responses from the staff service, independently of that package. I haven't found a simpler way of doing this, every other way seems to involve code gymnastics with interfaces or channels. This particular use-case is emulating services within a monolith.

A simple test example in the kitchen service:

//go:build test

// ./goeat/kitchen/internal/rota/main_test.go

package rota

import (
    "goeat/staff"
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestFetchRota(t *testing.T) {

    mockRota := []string{"A", "B"}
    mockStaffGetKitchenRota := func() []string {
        return mockRota
    }

    // override the 'api' response with our mock function
    staff.GetKitchenRota = mockStaffGetKitchenRota

    assert.Equal(t, fetchRota(), mockRota, "expect A, B")
}
Enter fullscreen mode Exit fullscreen mode

In this way I can develop features in one service (kitchen) without touching the code in other services. Fewer typos, fewer bugs.

Summary

My choice to develop an app as a monolith depends on a bunch of particular use-case factors.

I think Go's inherent simplicity around packages make it easy to create domain boundaries. This makes the whole code-base simpler to think about.

Simplicity is key, because with few (or solo) developers complexity kills motivation. Speed of development is often less about CPU cycles, and more about efficient process.

A single deployable binary is easier to manage and test, at small scale. Although Go suits a discrete service-architecture, the overhead (repo's, ci, infra) is too much right now.

The sample code is on GitHub, which I'll update as I go: Go Eat

💖 💪 🙅 🚩
daunderworks
Doug Bridgens

Posted on May 1, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related