Building SLOAK: A Case Study in Testable Go Architecture
What's Wrong With The Standard?!
Frankly, nothing. The main driver for this alternate approach is to have an easier time with the classic what to put where problem and to avoid the common shortcut of parking things there for now.
So then, where do we begin? The choice should mostly boil down to what your programming language advises. For Golang, we can find a great example here.
It's great, and it gets you started quickly with common directories like cmd, internal etc. Soon we'll see a nice little tree structure like this:
.
├── cmd
├── internal
│ ├── handlers
│ ├── repository
│ ├── service
...
It's within these folders that we often add odd dependencies when we're not careful, especially when adding quick changes and additions. A typical example would be that your handler section becomes a dumping ground for quick additions. So, what can we add to that structure to improve it?
Don't Fight The Framework
We often resort to frameworks so we don't have to reinvent the wheel, or we leverage amazing open-source software to fulfill our mad ideas. Luckily, these frameworks often come with a structure and are well-documented. For the sake of not making things harder for ourselves, we should adhere to their rules for the most part. If you agree with this, we'll likely move on to looking at the flow of dependency instead.
An Onion-like Flow
We often aim to work with a domain and try to keep it clean of specifics that aren't related to it, like the type of database used or its presentation entrypoint. To help with that, I chose to adopt hexagonal architecture. The dependency rule is simple: dependencies flow inward, from the outer layers (like adapters) toward the core (the domain). We'll dive into how we can enforce that later, but first, let's look at the folder structure we can use to combine our project layout with the intended architecture, all without disrupting framework flows. The dependency flow can be depicted as follows:
+----------------------------------------+
| Driving Adapter (CLI) |
| e.g., Cobra Commands |
+----------------------------------------+
|
v
+----------------------------------------+
| Application / Service Layer |
| (Implements Ports, Orchestrates Work) |
+----------------------------------------+
|
v
+----------------------------------------+
| Domain / Core Business |
| (Pure Types, Rules, Calculations) |
+----------------------------------------+
Wait, what are we writing?
To not just start out random with abstract named files, structs and functions I decided to take on a SRE-ious topic. Our project will be a CLI performing SRE-related calculations, limited to burnrate and errorbudget for now, others will follow later. For more info on the background of this I highly recommend diving into Google's SRE Books.
./sloak calculate errorbudget --slo=99.95 --window=30d
--- Error Budget Calculation ---
SLO Target: 99.950%
Time Window: 720h0m0s
--------------------------------
Error Budget: 0.00050% of time
Allowed Downtime: 21m36s
Domains, Ports, and Services
Starting from the core and working outwards, we need our domain and services. Let's uncover where we put what.
We'll make the following structure: internal/core/domain/errorbudget/ (from the project root), where we can define our domain objects in files like budget.go.
package errorbudget
import (
"time"
"github.com/MichielVanderhoydonck/sloak/internal/core/domain/common"
)
type CalculationParams struct {
TargetSLO common.SLOTarget
TimeWindow time.Duration
}
type BudgetResult struct {
TargetSLO common.SLOTarget
TotalDuration time.Duration
AllowedError time.Duration
ErrorBudget float64
}
Next to the domain, within the core, we can also define services—internal/core/service/errorbudget/, which will encapsulate the business logic for our domain (e.g., the calculations we aim to provide).
package errorbudget
import (
"errors"
errorbudgetDomain "github.com/MichielVanderhoydonck/sloak/internal/core/domain/errorbudget"
errorbudgetPort "github.com/MichielVanderhoydonck/sloak/internal/core/port/errorbudget"
"time"
"math"
)
var _ errorbudgetPort.CalculatorService = (*CalculatorServiceImpl)(nil)
type CalculatorServiceImpl struct{}
func NewCalculatorService() errorbudgetPort.CalculatorService {
return &CalculatorServiceImpl{}
}
func (s *CalculatorServiceImpl) CalculateBudget(params errorbudgetDomain.CalculationParams) (errorbudgetDomain.BudgetResult, error) {
if params.TargetSLO.Value < 0 || params.TargetSLO.Value > 100 {
return errorbudgetDomain.BudgetResult{}, errors.New("SLO target must be between 0 and 100")
}
errorBudgetPercent := 1.0 - (params.TargetSLO.Value / 100.0)
allowedErrorNanos := float64(params.TimeWindow) * errorBudgetPercent
roundedNanos := int64(math.Round(allowedErrorNanos))
allowedError := time.Duration(roundedNanos)
return errorbudgetDomain.BudgetResult{
TargetSLO: params.TargetSLO,
TotalDuration: params.TimeWindow,
AllowedError: allowedError,
ErrorBudget: errorBudgetPercent,
}, nil
}
Note that these services implement the interfaces (ports) of our domain, which live in internal/core/port/errorbudget/.
package errorbudget
import "github.com/MichielVanderhoydonck/sloak/internal/core/domain/errorbudget"
type CalculatorService interface {
CalculateBudget(params errorbudget.CalculationParams) (errorbudget.BudgetResult, error)
}
As is tradition in Go, tests should live close to the tested file, so no need to change that!
Adapt, Assemble!
We've made good progress, but we need to provide our end users with a way to make use of our services. For that, we'll implement Cobra commands, since the CLI will be our Driving Adapter. For now the CLI itself acts as the main Driving Adapter, but if the project expands (e.g., API, file loading, metrics export), new adapters would live under internal/adapter/.
These commands reside in their domain-related folder within cmd, resulting in structures like cmd/errorbudget/errorbudget.go. All these domain-related commands are bootstrapped onto our Cobra application via root.go, which is then called by our main.go, following regular conventions.
With all that in place, our structure looks as follows:
.
├── cmd
│ ├── burnrate
│ │ ├── burnrate_test.go
│ │ └── burnrate.go
│ ├── calculate.go
│ ├── errorbudget
│ │ ├── errorbudget_test.go
│ │ └── errorbudget.go
│ ├── root.go
│ └── sloak
│ └── main.go
├── internal
│ ├── adapter
│ │ └── README.md
│ ├── core
│ │ ├── domain
│ │ │ ├── burnrate
│ │ │ │ └── burnrate.go
│ │ │ ├── common
│ │ │ │ ├── slo_test.go
│ │ │ │ └── slo.go
│ │ │ └── errorbudget
│ │ │ └── budget.go
│ │ ├── port
│ │ │ ├── burnrate
│ │ │ │ └── port.go
│ │ │ └── errorbudget
│ │ │ └── port.go
│ │ └── service
│ │ ├── burnrate
│ │ │ ├── service_test.go
│ │ │ └── service.go
│ │ └── errorbudget
│ │ ├── service_test.go
│ │ └── service.go
│ └── util
│ ├── time_test.go
│ └── time.go
├── README.md
...
Note how nothing in domain/ imports from service/ or from Cobra, and nothing in service/ imports from cmd/. The folder names reflect dependency direction directly. Each domain (“errorbudget”, “burnrate”) has its own sibling folders in domain/ port/ and service/. This keeps all responsibilities grouped by domain, not by layer.
Enforcing Design
These rules are all nice and dandy, but we need a way to ensure we keep following them. For that purpose, I added a go-arch-lint config to keep our lovely design lasagna correct in each layer.
version: 3
workdir: .
allow:
depOnAnyVendor: false
deepScan: true
excludeFiles:
- "^.*_test\\.go$"
- "^.*\/test\/.*$"
vendors:
3rd-cobra:
in: github.com/spf13/cobra
components:
Domain:
in: ['internal/core/domain/**']
Port:
in: ['internal/core/port/**']
Application:
in: ['internal/core/service/**']
Adapter:
in: ['internal/adapter/**']
CLI:
in: ['cmd/**']
Util:
in: ['internal/util/**']
deps:
Domain:
mayDependOn: [Domain]
canUse: []
anyVendorDeps: true
Port:
mayDependOn: [Domain]
canUse: []
Application:
mayDependOn: [Domain, Port, Util]
canUse: []
Adapter:
mayDependOn: [Domain, Port, Application, Util]
canUse: []
CLI:
mayDependOn: [CLI, Domain, Port, Application, Util]
canUse: [3rd-cobra]
Util:
mayDependOn: []
canUse: []
anyVendorDeps: true
Trade-offs
As with any approach, nothing is a silver bullet and this is no exception! More structure means more folders. With this small amount of functionality, it's absolutely extra overhead. This is also why this approach makes less sense for rapid prototyping since it does abstract quite a bit. The benefit that is noticeable is that we can test way easier and more granularly, some examples:
Testing The Service
package errorbudget_test
import (
"errors"
"testing"
"time"
"github.com/MichielVanderhoydonck/sloak/internal/core/domain/common"
errorbudgetDomain "github.com/MichielVanderhoydonck/sloak/internal/core/domain/errorbudget"
errorbudgetService "github.com/MichielVanderhoydonck/sloak/internal/core/service/errorbudget"
)
func TestCalculatorService(t *testing.T) {
svc := errorbudgetService.NewCalculatorService()
mustNewSLO := func(val float64) common.SLOTarget {
slo, err := common.NewSLOTarget(val)
if err != nil {
t.Fatalf("failed to create valid SLO for test: %v", err)
}
return slo
}
testCases := []struct {
name string
params errorbudgetDomain.CalculationParams
expectedError error
expectedDowntime time.Duration
}{
{
name: "99.9% over 30 days",
params: errorbudgetDomain.CalculationParams{
TargetSLO: mustNewSLO(99.9),
TimeWindow: 30 * 24 * time.Hour, // 720h
},
expectedError: nil,
expectedDowntime: (43 * time.Minute) + (12 * time.Second), // 0.1% of 720h
},
{
name: "99.95% over 30 days",
params: errorbudgetDomain.CalculationParams{
TargetSLO: mustNewSLO(99.95),
TimeWindow: 30 * 24 * time.Hour, // 720h
},
expectedError: nil,
expectedDowntime: (21 * time.Minute) + (36 * time.Second), // 0.05% of 720h
},
{
name: "95% over 7 days",
params: errorbudgetDomain.CalculationParams{
TargetSLO: mustNewSLO(95.0),
TimeWindow: 7 * 24 * time.Hour, // 168h
},
expectedError: nil,
expectedDowntime: (8 * time.Hour) + (24 * time.Minute), // 5% of 168h
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := svc.CalculateBudget(tc.params)
if tc.expectedError != nil {
if !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error '%v', but got '%v'", tc.expectedError, err)
}
} else {
if err != nil {
t.Fatalf("did not expect an error, but got: %v", err)
}
if result.AllowedError != tc.expectedDowntime {
t.Errorf("expected downtime %v, but got %v", tc.expectedDowntime, result.AllowedError)
}
if result.TargetSLO.Value != tc.params.TargetSLO.Value {
t.Error("result did not contain the original SLO target")
}
}
})
}
}
Testing The Command
package errorbudget_test
import (
"bytes"
"io"
"os"
"strings"
"testing"
"time"
"github.com/MichielVanderhoydonck/sloak/cmd/errorbudget"
"github.com/MichielVanderhoydonck/sloak/internal/core/domain/common"
errorbudgetDomain "github.com/MichielVanderhoydonck/sloak/internal/core/domain/errorbudget"
)
type mockCalculatorService struct {
MockResult errorbudgetDomain.BudgetResult
MockError error
}
func (m *mockCalculatorService) CalculateBudget(params errorbudgetDomain.CalculationParams) (errorbudgetDomain.BudgetResult, error) {
return m.MockResult, m.MockError
}
func TestErrorBudgetCommand(t *testing.T) {
slo99_95, _ := common.NewSLOTarget(99.95)
mockResult := errorbudgetDomain.BudgetResult{
TargetSLO: slo99_95,
TotalDuration: 30 * 24 * time.Hour,
AllowedError: (21 * time.Minute) + (36 * time.Second),
ErrorBudget: 0.05,
}
mockSvc := &mockCalculatorService{
MockResult: mockResult,
MockError: nil,
}
errorbudget.SetService(mockSvc)
output, restoreStdout := captureOutput(t)
cmd := errorbudget.NewErrorBudgetCmd()
cmd.SetArgs([]string{
"--slo=99.95",
"--window=30d",
})
cmd.Execute()
restoreStdout()
outStr := output.String()
t.Log(outStr)
if !strings.Contains(outStr, "Allowed Downtime: 21m36s") {
t.Error("Output string did not contain the expected allowed downtime")
}
if !strings.Contains(outStr, "Error Budget: 0.05000%") {
t.Error("Output string did not contain the expected error budget percentage")
}
}
func captureOutput(t *testing.T) (output *bytes.Buffer, restore func()) {
t.Helper()
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
output = new(bytes.Buffer)
done := make(chan struct{})
go func() {
io.Copy(output, r)
close(done)
}()
restore = func() {
w.Close()
<-done
os.Stdout = old
}
return output, restore
}
Advice For Existing Project
It's always a bit of a bummer seeing things done greenfield without the common battle against some legacy code, that’s why I want to suggest a few tips to get you going on your refactor journey.
- Identify your Domains
- Extract your Ports
- Move logic to the Services
- Keep the Adapters "dumb"
What's Next?
Well, like all side projects or tryouts, they go on the backburner and never get finished I'll try to expand and iterate on the commands, hopefully landing a sweet SRE tool for people to use in the future.
Feel free to have a peek at the repository and let me know what you think!
Thanks for joining me in this madness—see you on our next venture!