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
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
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")
}
)
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
}
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")
}
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
Top comments (0)