Advanced Go Patterns
Senior Go engineer applying advanced language features for framework-level and performance-critical code. This skill covers generics, reflection, functional options, compile-time assertions, and safe metaprogramming.
TL;DR Checklist
- Use generics for type-safe collections and algorithms — prefer over interfaces when possible
- Use the functional options pattern for constructors with many optional parameters
- Use
go:embedfor compile-time asset inclusion — never hardcode file paths - Use
type _ interface{}compile-time assertions to verify interface satisfaction - Avoid reflection unless necessary — it bypasses the type system and is slow
- Never use
unsafeunless you fully understand the memory layout and aliasing rules
When to Use
Use this skill when:
- Building a framework or library that needs type-safe generic APIs
- Designing constructors with many optional parameters (functional options)
- Need compile-time asset embedding (
go:embed) - Writing performance-critical code where reflection overhead is unacceptable
- Creating compile-time assertions to catch interface violations at build time
When NOT to Use
Avoid this skill for:
- Application business logic — simple code is better than clever code
- When a plain interface or struct method would solve the problem
- Any code where readability matters more than abstraction
- Code that will be maintained by developers unfamiliar with advanced Go patterns
Core Workflow
Choose the Right Abstraction — Decide between interfaces, generics, or composition. Checkpoint: The abstraction adds value — it reduces code duplication or enables type safety.
Implement the Pattern — Write the generic function, option type, or reflection logic. Checkpoint: Code compiles with
go vet— no type safety violations.Add Compile-Time Assertions — Verify that types satisfy expected interfaces. Checkpoint: Compilation fails if the interface contract is violated.
Benchmark Performance — Ensure the pattern doesn't add unacceptable overhead. Checkpoint: Benchmark shows acceptable performance vs. the non-abstracted version.
Document the Pattern — Explain why the pattern was chosen and how to use it. Checkpoint: Usage examples are included in godoc comments.
Implementation Patterns
Pattern 1: Generics (❌ BAD vs ✅ GOOD)
Generics enable type-safe code without runtime type assertions.
❌ BAD — Interface-Based Collection
// ❌ BAD: interface{} requires runtime type assertions — unsafe and slow
type Stack struct {
items []interface{}
}
func (s *Stack) Push(item interface{}) {
s.items = append(s.items, item)
}
func (s *Stack) Pop() interface{} {
if len(s.items) == 0 {
return nil
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item
}
// Usage requires type assertion — easy to get wrong
func processStack() {
s := &Stack{}
s.Push("hello")
s.Push(42) // compiles — but wrong type!
// Runtime type assertion — panics if type is wrong
str := s.Pop().(string) // panics if the top item was 42
fmt.Println(str)
}
What's wrong:
interface{}accepts any type —s.Push(42)compiles but is semantically wrongPop().(string)panics at runtime if the type is wrong- No compile-time safety — type errors are runtime errors
interface{}has allocation overhead — each value is boxed- No documentation of the expected element type
✅ GOOD — Generic Stack
// Stack is a generic LIFO (last-in, first-out) data structure.
// The element type T is specified at construction time.
type Stack[T any] struct {
items []T
}
// NewStack creates a new generic stack.
func NewStack[T any]() *Stack[T] {
return &Stack[T]{items: make([]T, 0)}
}
// Push adds an item to the top of the stack.
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
// Pop removes and returns the top item.
// Returns zero value of T and false if the stack is empty.
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
// Len returns the number of items in the stack.
func (s *Stack[T]) Len() int {
return len(s.items)
}
// IsEmpty returns true if the stack has no items.
func (s *Stack[T]) IsEmpty() bool {
return len(s.items) == 0
}
// Usage — type is enforced at compile time
func processStack() {
// String stack
strStack := NewStack[string]()
strStack.Push("hello")
strStack.Push("world")
str, ok := strStack.Pop()
if !ok {
log.Fatal("stack is empty")
}
fmt.Println(str) // "world"
// This would not compile — type is enforced:
// strStack.Push(42) // ❌ cannot use 42 (int) as string
// Int stack — separate type
intStack := NewStack[int]()
intStack.Push(42)
intStack.Push(100)
n, ok := intStack.Pop()
if ok {
fmt.Println(n) // 100
}
}
Why this works:
- Type is specified at construction time —
NewStack[string]()creates a string stack - Compile-time enforcement —
strStack.Push(42)does not compile - No runtime type assertions —
Pop()returns(T, bool)— zero value on empty - No allocation overhead — values are stored directly, not boxed as
interface{} - Clear API —
IsEmpty(),Len(), and the(T, bool)return make usage obvious
Pattern 2: Functional Options (❌ BAD vs ✅ GOOD)
The functional options pattern provides a clean API for constructors with many optional parameters.
❌ BAD — Many Optional Parameters
// ❌ BAD: too many parameters — hard to use, easy to forget required ones
type Server struct {
Addr string
Port int
Timeout time.Duration
CertFile string
KeyFile string
MaxConns int
KeepAlive bool
ReadBuf int
WriteBuf int
Debug bool
}
// Constructor with 10 parameters — 7 optional, 3 required
func NewServer(addr string, port int, timeout time.Duration,
certFile string, keyFile string, maxConns int,
keepAlive bool, readBuf int, writeBuf int, debug bool) *Server {
// ...
}
// Usage — which parameters are optional? What are the defaults?
// Hard to read, easy to mess up
server := NewServer("0.0.0.0", 8080, 30*time.Second,
"cert.pem", "key.pem", 100,
true, 4096, 4096, false)
What's wrong:
- 10 parameters — impossible to remember which are required vs. optional
- No documentation of defaults — caller must guess
- Order matters — swapping
certFileandkeyFileis a silent bug - Adding a new parameter breaks all callers
- Hard to set just one option without specifying all others
✅ GOOD — Functional Options Pattern
// Server provides an HTTP server with configurable options.
type Server struct {
addr string
port int
timeout time.Duration
certFile string
keyFile string
maxConns int
keepAlive bool
readBuf int
writeBuf int
debug bool
}
// Option configures a Server via functional options.
type Option func(*Server)
// WithAddr sets the server address. Default: "0.0.0.0".
func WithAddr(addr string) Option {
return func(s *Server) {
s.addr = addr
}
}
// WithPort sets the server port. Default: 8080.
func WithPort(port int) Option {
return func(s *Server) {
s.port = port
}
}
// WithTimeout sets the read/write timeout. Default: 30s.
func WithTimeout(timeout time.Duration) Option {
return func(s *Server) {
s.timeout = timeout
}
}
// WithTLS sets TLS certificate and key files.
func WithTLS(certFile, keyFile string) Option {
return func(s *Server) {
s.certFile = certFile
s.keyFile = keyFile
}
}
// WithMaxConns sets the maximum number of connections. Default: 100.
func WithMaxConns(maxConns int) Option {
return func(s *Server) {
s.maxConns = maxConns
}
}
// WithDebug enables debug logging. Default: false.
func WithDebug(enabled bool) Option {
return func(s *Server) {
s.debug = enabled
}
}
// NewServer creates a Server with the given options.
// All options are optional — defaults are applied for unset values.
func NewServer(options ...Option) *Server {
s := &Server{
addr: "0.0.0.0",
port: 8080,
timeout: 30 * time.Second,
maxConns: 100,
readBuf: 4096,
writeBuf: 4096,
}
// Apply options in order — last one wins for conflicting options
for _, opt := range options {
opt(s)
}
return s
}
// Usage — clean, readable, only specify what you need
server := NewServer(
WithPort(9090),
WithTimeout(60*time.Second),
WithTLS("cert.pem", "key.pem"),
WithDebug(true),
)
// Minimal usage — all defaults
server := NewServer()
// Override a single default
server := NewServer(WithPort(443))
Why this works:
- Each option is a self-documenting function —
WithPort(9090)is readable - Order doesn't matter — options are applied sequentially in
NewServer - Adding options is easy — new
WithXxxfunctions don't break existing callers - Defaults are explicit in
NewServer— caller knows what happens with no options - Type-safe —
WithPort("abc")doesn't compile
Pattern 3: Compile-Time Assertions and go:embed (❌ BAD vs ✅ GOOD)
Compile-time assertions catch interface violations at build time. go:embed includes assets at compile time.
❌ BAD — Runtime Interface Checking
// ❌ BAD: interface satisfaction checked at runtime — too late
type Repository interface {
Get(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, user *User) error
}
type MockRepository struct{}
func (m *MockRepository) Get(ctx context.Context, id string) (*User, error) {
return nil, nil
}
// No compile-time check — if MockRepository doesn't implement Repository,
// the error surfaces only when the code is compiled (and only if someone
// tries to assign it)
func main() {
// This would silently fail to compile if MockRepository is missing a method,
// but only at the assignment site — no clear error message
var repo Repository = &MockRepository{}
_ = repo
}
// ❌ BAD: hardcoding asset paths — breaks if files move
func loadTemplate() string {
data, err := os.ReadFile("templates/profile.html")
if err != nil {
log.Fatal(err)
}
return string(data)
}
What's wrong:
- No compile-time guarantee that
MockRepositoryimplementsRepository - If a method is added to the interface, the mock won't compile — but only at the usage site
- Hardcoded file paths — fragile, breaks on different machines/environments
os.ReadFileat runtime — template loading happens on every call
✅ GOOD — Compile-Time Assertions with go:embed
// package: internal/user
// Compile-time interface assertion.
// This line will fail to compile if *MockRepository does NOT implement UserRepository.
var _ UserRepository = (*MockRepository)(nil)
// MockRepository implements UserRepository for testing.
type MockRepository struct {
users map[string]*User
}
func NewMockRepository() *MockRepository {
return &MockRepository{users: make(map[string]*User)}
}
func (m *MockRepository) Get(ctx context.Context, id string) (*User, error) {
user, ok := m.users[id]
if !ok {
return nil, fmt.Errorf("user %s not found", id)
}
return user, nil
}
func (m *MockRepository) Create(ctx context.Context, user *User) error {
if _, ok := m.users[user.ID]; ok {
return fmt.Errorf("user %s already exists", user.ID)
}
m.users[user.ID] = user
return nil
}
// Multiple interface assertions in one line
var (
_ UserRepository = (*MockRepository)(nil)
_ io.Reader = (*MockRepository)(nil)
_ io.Closer = (*MockRepository)(nil)
)
// package: web
//go:embed templates/*.html
var templateFS embed.FS
// templateCache holds pre-parsed templates.
var templateCache = sync.OnceValue(func() *template.Template {
tmpl := template.Must(template.New("").ParseFS(templateFS, "templates/*.html"))
return tmpl
})
// renderProfile renders the user profile page.
func renderProfile(w http.ResponseWriter, user *User) {
tmpl := templateCache()
if err := tmpl.ExecuteTemplate(w, "profile.html", user); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "internal error", 500)
}
}
Directory structure:
myapp/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── user/
│ │ └── repository.go // UserRepository interface + MockRepository
│ └── web/
│ ├── handler.go // renderProfile with go:embed
│ └── templates/
│ ├── profile.html
│ ├── list.html
│ └── error.html
├── go.mod
└── go.sum
Why this works:
var _ UserRepository = (*MockRepository)(nil)— fails to compile if the mock doesn't implement the interface- The nil pointer
(*MockRepository)(nil)is never created at runtime — it's a compile-time only check //go:embedincludes HTML templates in the binary at compile time — no runtime file readssync.OnceValue(Go 1.23+) lazily parses templates once — zero overhead on first access, cached thereafter- Template paths are relative to the source file — no hardcoded strings
- Templates are bundled in the binary — deployment is a single binary
Constraints
MUST DO
- MUST use generics for type-safe collections and algorithms — prefer over
interface{}when the type is known at construction - MUST use the functional options pattern for constructors with more than 3 optional parameters
- MUST use
//go:embedfor including static assets — never hardcode file paths - MUST add compile-time interface assertions with
var _ Interface = (*Type)(nil) - MUST document generic type parameters with godoc comments (
// T is the element type) - MUST benchmark any code that uses reflection — ensure overhead is acceptable
MUST NOT DO
- MUST NOT use reflection to access private fields — it bypasses the type system and is fragile
- MUST NOT use
unsafewithout a thorough understanding of memory layout and aliasing rules - MUST NOT overuse generics — simple interfaces or concrete types are often clearer
- MUST NOT use functional options for constructors with fewer than 3 optional parameters — plain structs are simpler
- MUST NOT embed files with
go:embedthat change frequently — embedded files are baked into the binary at compile time - MUST NOT use
type _ interface{}assertions in production code without a clear reason — they add maintenance overhead
Related Skills
| Skill | Purpose |
|---|---|
best-practices |
Go idioms and code organization |
modular-design |
Interface-based design that works with generics |
concurrency-patterns |
Generic concurrency utilities (channels, workers) |
database-patterns |
Generic repository patterns for type-safe data access |