Code generation - NOT using AI - for building better Go projects
Nowadays, when talking about code generation, everyone thinks about AI/LLMs, but this article has nothing to do with AI.
I also want to disclaim that this article and all the Go projects referred to here were handcrafted by me. Since English is my second language, I used AI in a limited way to refine my grammar and to generate small, function-scoped snippets of code. In other words, there is no “vibe” involved - just intentional engineering.
Maybe you already know about the //go:generate directive. There are many excellent specialized tools available:
stringerfor enums.sqlcfor type-safe SQL.mockeryfor testing.
These follow the Unix philosophy of doing one thing well. However, they don’t address the core of the application: the business domain. I found myself still writing repetitive “glue code” - manual error wrapping, dependency wiring, and fragmented method calls - that cluttered my business logic.
To solve this, I created a code generation toolbox - modular tools that can be used standalone or composed into a single generator for your project. The architecture looks like this:
toolbox Your project
┌────────────────┐ ┌────────────────┐
│go-chainer-gen ◄──────────┐ ┌────────┼ source code │
└────────────────┘ │ │ └────────────────┘
┌────────────────┐ ┌──────────────▼─┐
│go-composer-gen ◄─────────│ your-generator │
└────────────────┘ └──────────────┬─┘ ┌────────────────┐
┌────────────────┐ │ └────────► generated code │
│go-stringer-gen ◄──────────┘ └────────────────┘
└────────────────┘
The toolbox
go-chainer-gen
Imagine you are working on a function called
CreateUser. Usually, it looks like this:
// CreateUserInput may live somewhere very far from this method
// Service may contain many dependencies that CreateUser doesn't need, and it may be defined far away
func (*s Service) CreateUser(ctx context.Context, input CreateUserInput) error {
// validation code
validate, err := ...
if err != nil {
...
}
// authorization code
isAllowed, err := ...
if err != nil {
...
}
// handle code
err := ...
if err != nil {
...
}
return nil
}You see the pattern often:
validate -> authorize -> handle. While this isn’t
a “deal-breaker,” you eventually run into these problems:
- Reduced Focus: The definitions for what
CreateUserrequires may live far away, forcing you to jump between files and breaking your focus while reading the function. - Testing Friction: To test the
authorizelogic, you have to mock enough data to pass thevalidationstep first. To test thehandlelogic, you have to mock both preceding steps. - Visual Noise: Function bodies become cluttered
with repetitive
if err != nilblocks.
To solve this, you can break the method down into smaller focused
functions, each doing exactly one job. Then
go-chainer-gen chains them together
into a single execution flow.
// source code
type CreateUserInput struct{} // the specific input for this operation
type createUserOp struct {
repo UserStorer // direct dependencies live here
mailer Mailer
}
func (op *createUserOp) validate(ctx context.Context, input CreateUserInput) error {
return nil
}
func (op *createUserOp) authorize(ctx context.Context, input CreateUserInput) (string, error) {
return "passed from authorize to handle", nil
}
func (op *createUserOp) handle(ctx context.Context, input CreateUserInput, fromAuthz string) error {
return nil
}With minimal pkl configuration
// file: chainer.pkl
amends "package://nhatp.com/go/chainer-gen/pkl@0.3.0#/Config.pkl"
import "package://nhatp.com/go/chainer-gen/pkl@0.3.0#/match.pkl"
packages {
["github.com/gen/project"] {
struct_name = match.with("createUserOp")
}
}
Then running go-chainer-gen produces the following
chaining code, handling the error checks and the
data handoff (passing v1 to handle)
automatically:
// Code generated by go-chainer-gen - dev. DO NOT EDIT.
package main
import (
"context"
"fmt"
)
// execute runs createUserOp.validate, createUserOp.authorize, and
// createUserOp.handle in sequence.
func (op *createUserOp) execute(ctx context.Context, input CreateUserInput) error {
v0 := op.validate(ctx, input)
if v0 != nil {
return fmt.Errorf("validate: %w", v0)
}
v1, v2 := op.authorize(ctx, input)
if v2 != nil {
return fmt.Errorf("authorize: %w", v2)
}
v3 := op.handle(ctx, input, v1)
if v3 != nil {
return fmt.Errorf("handle: %w", v3)
}
return nil
}The benefits are:
- Locality: it improves the locality of the
operation; you can put the specific dependencies for
CreateUserinside thecreateUserOpstruct. - Separation of Concerns: it helps you test each part of the operation independently and more easily.
- Cleanliness: It improves code readability by reducing manual error-handling boilerplate to a minimum.
The possibilities go beyond simple boilerplate chaining. You can
even generate complex OpenTelemetry instrumentation
by leveraging the custom Emitter to wrap your logic in spans and
error-tracking code. You can also configure the method names and
order, and go-chainer-gen can skip or pass down
parameters as needed. For more details, please check the go-chainer-gen
repository.
Dependency wiring is handled by go-composer-gen,
described next.
go-composer-gen
Imagine you have multiple small, focused operation structs with direct dependencies in the struct fields:
// source code
type Repository interface{}
type Service interface{}
type Mailer interface{}
type createUserOp struct {
repo Repository
}
func (op *createUserOp) execute(ctx context.Context) error { return nil }
type updateUserOp struct {
mailer Mailer
service Service
}
func (op *updateUserOp) execute(ctx context.Context, id int) error { return nil }
type deleteUserOp struct {
repository Repository
mailer Mailer
}
func (op *deleteUserOp) execute(ctx context.Context, id string) error { return nil }These structs have great locality, are easy to test, and contain
exactly what they need. But when you need to bundle them into a
“composed” struct with a public interface,
go-composer-gen does the heavy lifting:
// Code generated by go-composer-gen - dev. DO NOT EDIT.
package main
import "context"
type Service interface {
CreateUser(ctx context.Context) error
UpdateUser(ctx context.Context, id int) error
DeleteUser(ctx context.Context, id string) error
}
type serviceImpl struct {
createUserOp *createUserOp
updateUserOp *updateUserOp
deleteUserOp *deleteUserOp
}
func (s *serviceImpl) CreateUser(ctx context.Context) error {
return s.createUserOp.execute(ctx)
}
func (s *serviceImpl) UpdateUser(ctx context.Context, id int) error {
return s.updateUserOp.execute(ctx, id)
}
func (s *serviceImpl) DeleteUser(ctx context.Context, id string) error {
return s.deleteUserOp.execute(ctx, id)
}
type serviceDeps struct {
repository Repository
mailer Mailer
service Service
}
func newService(deps *serviceDeps) Service {
impl := &serviceImpl{
createUserOp: &createUserOp{repo: deps.repository},
deleteUserOp: &deleteUserOp{
mailer: deps.mailer,
repository: deps.repository,
},
updateUserOp: &updateUserOp{
mailer: deps.mailer,
service: deps.service,
},
}
return impl
}
var _ Service = (*serviceImpl)(nil)The tool supports configuration and custom emitters, which you can use to generate repetitive code such as tracing or logging. For more details, check the go-composer-gen repository.
When you combine go-chainer-gen and
go-composer-gen, the workflow shifts noticeably:
- Your code is organized into small, single-responsibility structs, each with only its direct dependencies
- Each operation is independently testable without mocking unrelated steps
- Dependency wiring is entirely handled for you - just define the ops and let the generator compose them
- Error wrapping and method chaining are generated automatically, keeping business logic clean
And if your domain uses enums, go-stringer-gen
fits in naturally - bundling all String() methods into
the same generated output alongside your chained and composed
code.
Verify the toolbox yourself
Start by installing the pkl CLI - when running as a
standalone generator, the toolbox requires the pkl
binary to be available on your PATH to evaluate
.pkl configuration files.
# macOS
brew install pkl For other platforms, see the pkl-lang installation docs.
Every tool in the toolbox includes a test
sub-command designed to execute Golden Tests
defined in Markdown format. The Markdown file acts as a Virtual
File System, allowing you to define a project structure,
configuration inputs, and the expected “golden” output file - all in
one place.
You can easily run these tests using the following commands:
# Run all tests in a file of the go-chainer-gen repository on main branch
go run nhatp.com/go/chainer-gen/cmd/go-chainer-gen test 'repo://README.md'
go run nhatp.com/go/composer-gen/cmd/go-composer-gen test 'repo://features/multiple-structs-and-dependencies.md'
go run nhatp.com/go/stringer-gen/cmd/go-stringer-gen test 'repo://features/configs.md'
# Run your remote test file and print the setup options
go run nhatp.com/go/chainer-gen/cmd/go-chainer-gen test 'https://to-your-url/file.md' -s
# Run a specific test case by name within a local file and print everything
go run nhatp.com/go/chainer-gen/cmd/go-chainer-gen test features/your-local-file.md -v -n "test-case-name"Read details regarding the Markdown-based Golden Test format.
Build your own generator
Now that you know what is in your toolbox, you can write a generator that fits your specific workflow. You can build it to be highly opinionated, tailored specifically to your team’s needs, or highly flexible, allowing your team to configure exactly what they want. I have provided two examples that demonstrate both approaches.
A highly opinionated generator
This generator has no configuration, everything is baked into the binary. These are the opinions:
- Business logic should live in a struct named with suffix
Op. Standard steps are[validate] -> [authorize] -> [process] -> [handle]. - All
*Opstructs are composed together into a public interfaceService. - All enum
String()methods are generated using line comments. - The generated file is
codegen.goand has the same package name as the source code.
A highly flexible generator
This bundles all the toolbox generators into a single configurable entry point. You have to configure everything to make it work. The config file looks like this:
packages {
["github.com/you/project"] {
chainer {
// exactly like go-chainer-gen options
}
composer {
// exactly like go-composer-gen options
}
stringer {
// exactly like go-stringer-gen options
}
}
}
Of course, you can build anything in between these two extremes - that’s your job as the architect.
Want to try it before building your own? There is a Live Demo running directly in your browser - no installation required.
I hope you enjoyed the article and find the toolbox useful. What do you think? Do you have any advice or feedback for me?
Thank you for your time!
If you like the project, feel free to buy me a coffee.