Increasing Cohesion in Go with Generic Decorators
Cohesion is part of the low coupling, high cohesion principle that’s supposed to keep your code maintainable. While low coupling means few dependencies, high cohesion roughly translates to single responsibility. Highly cohesive code (a module or a function) is focused on a single purpose. Low cohesion means it does many unrelated things.
I’ve written about coupling in the previous article on anti-patterns. Here are some tips on increasing cohesion in Go applications using the recently released generics.
The Application Logic
There’s a place in your project where your application logic lives. We call it the application layer and split it into commands and queries, CQRS style. Perhaps you call it handlers, services, controllers, or use cases. Hopefully, it’s not mixed with implementation details, like HTTP handlers.
Note
If you don’t keep your logic separate yet, check our articles on Clean Architecture and CQRS.
Whatever you call this code, it’s probably not only logic. Some common operations need to happen for each request your application handles. They’re often related to observability, like logging, metrics, and tracing. Other examples are retries, timeouts, or an authorization mechanism.
func (h SubscribeHandler) Handle(ctx context.Context, cmd Subscribe) (err error) {
start := time.Now()
h.logger.Println("Subscribing to newsletter", cmd)
defer func() {
end := time.Since(start)
h.metricsClient.Inc("commands.subscribe.duration", int(end.Seconds()))
if err == nil {
h.metricsClient.Inc("commands.subscribe.success", 1)
h.logger.Println("Subscribed to newsletter")
} else {
h.metricsClient.Inc("commands.subscribe.failure", 1)
h.logger.Println("Failed subscribing to newsletter:", err)
}
}()
}
Keeping such operations close to the logic decreases the cohesion. It mixes different concepts.
Eventually, it gets hard to grasp the logic itself. Even if you move some code to helper functions, you’ll end up passing many arguments and handling errors. You risk that someone copy-pastes the entire header of the function and forgets to change one of the fields. Updating the supporting code requires changing the logic parts.
The User Context
An interesting scenario is when you need to handle requests differently depending on the caller.
A typical example is checking the user’s permissions or attributes.
You need to pass some kind of user context to the handler — a token, a user ID, or a complete User
object.
The logic can’t proceed without it.
user, err := UserFromContext(ctx)
if err != nil {
return err
}
if !user.Active {
return errors.New("the user's account is not active")
}
But often, there are a few entry points to do the same action. For example, in a system subscribing users to a newsletter, we could have:
- A public HTTP API that uses the user context coming from a session token.
- An internal gRPC API that other services call. An admin CLI tool also uses it.
- A message handler that reacts to an event that already happened (a user signed up who agreed to receive a newsletter).
- A migration that runs on the service’s startup. It backfills subscriptions that weren’t there because of a bug.
Except for the first entry point, all others miss a proper user context the logic could use
You must manually craft and pass a fake user context for entry points other than the HTTP API.
fakeUser := User{
ID: "1", // Missing ID in the context, let's assume it's the root user making changes
Active: true,
}
ctx = ContextWithUser(ctx, fakeUser)
err := handler.Handle(ctx, cmd)
if err != nil {
return err
}
Even if your application exposes only a public HTTP API, testing this function isn’t a good experience. You need to prepare a fake user every time you call the handler or mock it somewhere in the dependencies.
func TestSubscribe(t *testing.T) {
logger := log.New(os.Stdout, "", log.LstdFlags)
metricsClient := nopMetricsClient{}
handler := NewSubscribeHandler(logger, metricsClient)
user := User{
ID: "1000",
Active: true,
}
ctx := ContextWithUser(context.Background(), user)
cmd := Subscribe{
Email: "user@example.com",
NewsletterID: "product-news",
}
err := handler.Handle(ctx, cmd)
if err != nil {
t.Fatal(err)
}
}
❌ Anti-pattern: Low cohesion
Don’t mix your application’s logic with other code directly in a handler.
The Decorator Pattern
Decorators are one of the least controversial design patterns. After all, HTTP middlewares are widely used, and it’s the same idea. You can think of decorators as application-level middlewares.
Decorators let you separate things that don’t belong together, but you can still explicitly use them where needed.
You wrap the logic with other operations. The logic handler doesn’t know what decorates it.
type subscribeAuthorizationDecorator struct {
base SubscribeHandler
}
func (d subscribeAuthorizationDecorator) Handle(ctx context.Context, cmd Subscribe) error {
user, err := UserFromContext(ctx)
if err != nil {
return err
}
if !user.Active {
return errors.New("the user's account is not active")
}
return d.base.Handle(ctx, cmd)
}
The logic part stays super short now.
func (h subscribeHandler) Handle(ctx context.Context, cmd Subscribe) error {
// Subscribe the user to the newsletter
return nil
}
You can pick which decorators to use depending on the use case. You can set them up in constructors.
func NewAuthorizedSubscribeHandler(logger Logger, metricsClient MetricsClient) SubscribeHandler {
return subscribeLoggingDecorator{
base: subscribeMetricsDecorator{
base: subscribeAuthorizationDecorator{
base: subscribeHandler{},
},
client: metricsClient,
},
logger: logger,
}
}
func NewUnauthorizedSubscribeHandler(logger Logger, metricsClient MetricsClient) SubscribeHandler {
return subscribeLoggingDecorator{
base: subscribeMetricsDecorator{
base: subscribeHandler{},
client: metricsClient,
},
logger: logger,
}
}
func NewSubscribeHandler() SubscribeHandler {
return subscribeHandler{}
}
You can write a unit test for the exact same code but without things not related directly to the logic. You don’t need to mock the logger or the metrics client. Tests become shorter and easier to follow.
func TestSubscribe(t *testing.T) {
handler := NewSubscribeHandler()
cmd := Subscribe{
Email: "user@example.com",
NewsletterID: "product-news",
}
err := handler.Handle(context.Background(), cmd)
if err != nil {
t.Fatal(err)
}
}
Note
You still need to test the authorization mechanism. I suggest doing this on the component tests level.
You can learn more about different test types in Microservices test architecture.
✅ Tactic: Decorators
Wrap your logic with decorators. Choose which to use depending on the use case.
Generic decorators
Splitting code into decorators comes with some boilerplate. Usually, we like to deal with boilerplate using code generation. For example, you can easily generate API or storage models.
We used to generate our decorators as well. But with Go 1.18 out there, we can now replace them with generic decorators.
Because all our command handlers follow the same convention, the command is the only generic type we need.
(A command is a struct
containing all inputs for the command handler.)
type CommandHandler[C any] interface {
Handle(ctx context.Context, cmd C) error
}
type authorizationDecorator[C any] struct {
base CommandHandler[C]
}
func (d authorizationDecorator[C]) Handle(ctx context.Context, cmd C) error {
user, err := UserFromContext(ctx)
if err != nil {
return err
}
if !user.Active {
return errors.New("the user's account is not active")
}
return d.base.Handle(ctx, cmd)
}
You can use the generic decorators with any other command handler. You could even move them to a separate library.
func NewUnauthorizedSubscribeHandler(logger Logger, metricsClient MetricsClient) CommandHandler[Subscribe] {
return loggingDecorator[Subscribe]{
base: metricsDecorator[Subscribe]{
base: SubscribeHandler{},
client: metricsClient,
},
logger: logger,
}
}
✅ Tactic: Generic decorators
Use generics to reduce boilerplate.
One Caveat
In most cases in Go, it makes sense to accept interfaces and return structs. But to make wrapping possible, you need to return an interface from constructors.
type SubscribeHandler interface {
Handle(ctx context.Context, cmd Subscribe) error
}
type subscribeHandler struct{}
func NewSubscribeHandler() SubscribeHandler {
return subscribeHandler{}
}
The downside is that it’s more challenging to navigate the project.
You can’t just click the Handle
method to land in the definition.
You should be fine if you use a solid IDE that shows what implements an interface.
Also, be aware that it can surprise someone trying to follow the general rule. It’s OK to break the rules but understand (and be ready to explain) why you make an exception.
Another thing to watch out for is how you wrap your decorators. Outer decorators get executed first, and sometimes the order makes a difference. For example, you want to keep logs and metrics even if the authorization fails.
The Complete Example
Check the complete source code on the anti-patterns repository.
It features logging and metrics decorators ready to use with minimal changes.
Wild Workouts
In parallel, Robert introduced decorators to Wild Workouts, the example project you might know from our previous posts. Check the commit to see what this looks like in a real application.
There are two helper functions not covered in this post:
ApplyCommandDecorators
and
ApplyQueryDecorators
.
Robert also added support for running Wild Workouts locally on M1 CPUs. If you didn’t have a chance to do it yet, it might be a good moment to play with the project.