The Problem
When building data-intensive applications in Go, you often find yourself writing repetitive boilerplate code. A common scenario is handling multiple data transformation layers:
- Converting GraphQL filters to database query parameters
- Converting database records to API response models
- Maintaining count queries with identical filter logic
This creates a maintenance nightmare. Every time you add a new field to your domain model, you must update three or more places in your codebase. The logic is identical — only the struct types differ.
Consider this pattern:
- A filter conversion function that maps GraphQL filter inputs to database query parameters for list operations
- An identical conversion function for count operations (same logic, different parameter struct)
- A model conversion function that transforms database records to GraphQL response models
That’s 50% duplication before you even write real business logic.
The Traditional Solutions and Their Limitations
Option 1: Accept the Duplication
The simplest approach, but:
- Maintenance becomes exponential as your domain grows
- Bugs fixed in one place need fixing in multiple places
- New developers don’t understand why the code is repeated
- Refactoring becomes a multi-hour task
Option 2: Use Reflection
Reflection-based mappers like mapstructure or custom reflection utilities:
// Slow and loses type safetyresult, _ := mapstructure.Decode(source, &destination)Drawbacks:
- Runtime overhead (reflection is slow)
- Loss of compile-time type checking
- Harder to debug
- Performance issues at scale
- Complex error handling
Option 3: Generic Interfaces (Strategy Pattern)
Create an interface that multiple structs implement:
type FilterSetter interface {
SetStringField(name string, value *string)
SetIntField(name string, value *int)
}Drawbacks:
- Still requires massive switch statements in each implementation
- No reduction in boilerplate — just reorganization
- Difficult to add new field types without changing the interface
- Still a lot of manual code to write and maintain
The Elegant Solution: go:generate
Go’s built-in code generation tool provides the perfect balance between maintainability, performance, and type safety. Instead of fighting duplication, you generate the repetitive code from a single source of truth.
How go:generate Works
Go includes a go:generate A command that runs arbitrary tools during the build process. The typical workflow:
- Define your data model in code (your source of truth)
- Write a generator program that reads this model
- Run
go generateto create type-safe, optimized code - The generated code is committed to version control (optional but recommended)
Why this is better:
- ✅ Zero runtime overhead — generated code is optimized at compile time
- ✅ Type-safe — full IDE support, compile-time checking
- ✅ Single source of truth — maintain mapping in one place
- ✅ Version control friendly — generated code is plain Go
- ✅ Easy to review — generated code is human-readable
- ✅ No magic — explicit, transparent transformation
A Real-World Implementation
Step 1: Define Your Mapping Configuration
Create a generator tool that contains your data transformation definitions:
type FieldMapping struct {
FilterName
string // Name in the filter input
SqlcFieldOut string // Name in SQL parameter struct
FieldType string // "string", "int64", "float64", "time"
MinField string // For range queries
MaxField string // For range queries
}
type ModelFieldMapping struct {
DBField string // Name in database record
ModelField string // Name in API response model
FieldType
string
ConvertFunc string // Conversion
function to apply
}Step 2: List Your Fields Once
Instead of duplicating field mappings across multiple functions, list them once:
var fields = []FieldMapping{
{
FilterName: "Name", SqlcFieldOut: "Name", FieldType: "string"
},
{
FilterName: "CreatedAt", SqlcFieldOut: "StartCreatedAt", FieldType: "time", MaxField:
"EndCreatedAt"
},
//...etc
}
var modelFields = []ModelFieldMapping{
{
DBField: "Name", ModelField: "Name", ConvertFunc: "utils.NullStringToPointer"
},
{
DBField: "CreatedAt", ModelField: "CreatedAt", ConvertFunc: "utils.NullTimeToTime"
},
//...etc
}This is your single source of truth. All transformation logic derives from these definitions.
Step 3: Create a Template-Based Generator
Use Go’s text/template package to generate the boilerplate:
const template =
`// Code generated; DO NOT EDIT.package servicefunc (s *Service) GraphqlFilterToListParams(filter *model.Filter) sqlc.ListParams { params := sqlc.ListParams{} if filter != nil {{{- range .FilterFields}}{{- if eq .FieldType "string"}} params.{{.SqlcFieldOut}} = utils.ToNullString(filter.{{.FilterName}}){{- else if eq .FieldType "time"}} params.{{.SqlcFieldOut}} = utils.ToNullTime(filter.{{.FilterName}}) params.{{.MaxField}} = utils.ToNullTime(filter.{{.MaxField}}){{- end}}{{- end}} } return params}`Step 4: Wire It Into Your Build
Add a go:generate directive to your service file:
package service//go:generate go run../../../cmd/gen/generator.goRun the generator:
go generate ./...This creates optimized, type-safe code automatically.
Benefits in Practice
Before: Maintaining 3 Functions
Service.go
├── graphqlFilterToListProductParams() [200 lines]
├──
graphqlFilterToCountProductParams() [195 lines - 95% duplicate]
└── dbGsmToGraphQLProduct()
[150 lines]Every change requires updating multiple functions.
After: Maintaining 1 Configuration
Generator
└── fields = []FieldMapping{...} [1 definition]
└── modelFields =
[]ModelFieldMapping{...}Generated:
└── filter_mapper.gen.go [auto-generated from single
source]
├── GraphqlFilterToListProductParams()
├── GraphqlFilterToCountProductParams()
└──
dbGsmToGraphQLProduct()Add a field once, regenerate, done.
Maintenance Scenarios
Adding a new field:
- Before: Edit 3+ functions, increase the chance of bugs
- After: Add one line to your configuration, run
go generate
Fixing a conversion bug:
- Before: Fix in all places it appears
- After: Fix in the generator template, regenerate
Changing field mapping rules:
- Before: Manual refactor across multiple functions
- After: Update the configuration, regenerate
Code review:
- Before: Review duplicate logic in multiple places
- After: Review the generator template once
Real-World Performance
The generated code is indistinguishable from handwritten code:
// Generated code - no reflection, pure assignmentfunc (s *Service) dbToGraphQL(db *sqlc.Record)
*model.Record { return &model.Record{ Field1: utils.NullStringToPointer(db.Field1),
Field2: utils.NullTimeToTime(db.Field2), // ... etc }}This compiles to identical machine code as handwritten code. Zero overhead.
Best Practices
1. Generate Only Repetitive Code
Use generators for boilerplate that follow predictable patterns. Don’t generate business logic.
2. Keep Generators Simple
Your generator tool should be ~100–200 lines. If it’s getting complex, you might be over-engineering.
3. Store Generated Files
Commit generated files to version control so:
- Others can see what code is generated
- CI can verify that the generation is up-to-date
- Bisecting git history works correctly
4. Make Generation Part of CI/CD
Add a check to verify the generated code is current:
# Generate codego generate ./...# Verify no files changed
git diff --exit-code5. Document Your Mapping Configuration
Add comments to your field definitions explaining any non-obvious mappings:
var fields = []FieldMapping{
// Database uses snake_case, GraphQL uses camelCase
{
FilterName: "StatusCode", SqlcFieldOut: "status_code",...
},
}Conclusion
Code generation isn’t new or exotic. Go’s go:generate makes it accessible for everyday problems. When you find yourself copying and pasting code patterns, consider:
- Is this pattern repetitive?
- Will it need to change frequently?
- Does it follow a predictable structure?
If yes to all three, generate it.
