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:

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:

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:

  1. Locality: it improves the locality of the operation; you can put the specific dependencies for CreateUser inside the createUserOp struct.
  2. Separation of Concerns: it helps you test each part of the operation independently and more easily.
  3. 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:

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:

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.