Designing for the Long Term

Updated

July 30, 2026

Overview

Maintainable Go code follows consistent patterns and architectural principles.

Package Design

myapp/
├── cmd/          # Entry points
│   └── server/
├── internal/     # Private packages
│   ├── auth/
│   ├── database/
│   └── handlers/
├── pkg/          # Public libraries
└── api/          # API definitions

Dependency Direction

cmd → internal → pkg
         ↓
      external

Lower layers should not import higher layers.

Interface Segregation

// Small, focused interfaces
type Reader interface { Read([]byte) (int, error) }
type Writer interface { Write([]byte) (int, error) }

// Compose when needed
type ReadWriter interface {
    Reader
    Writer
}

Options Pattern

type Server struct {
    host    string
    port    int
    timeout time.Duration
}

type Option func(*Server)

func WithPort(p int) Option {
    return func(s *Server) { s.port = p }
}

func NewServer(opts ...Option) *Server {
    s := &Server{host: "localhost", port: 8080}
    for _, opt := range opts {
        opt(s)
    }
    return s
}

Error Handling

// Wrap with context
return fmt.Errorf("repository.GetUser: %w", err)

// Custom error types for inspection
type NotFoundError struct{ ID int }
func (e NotFoundError) Error() string { ... }

Testing Strategy

  • Unit tests for business logic
  • Integration tests for boundaries
  • Table-driven tests for coverage
  • Mocks for external dependencies

Guidelines

  1. Keep packages small - Single responsibility
  2. Accept interfaces - Flexible dependencies
  3. Return structs - Clear types
  4. Handle errors early - Fail fast
  5. Document exported APIs - Clear comments

Summary

Principle Implementation
Encapsulation internal/ packages
Abstraction Small interfaces
Flexibility Options pattern
Testability Dependency injection

Worked example

Dependency injection with a tiny interface + table-driven unit tests.

Save as greeter.go and greeter_test.go. Then:

go mod init example
go test -v
// greeter.go
package main

type Clock interface {
    Hour() int
}

type fixedClock struct{ h int }

func (f fixedClock) Hour() int { return f.h }

func Greeting(c Clock, name string) string {
    if name == "" {
        name = "friend"
    }
    if c.Hour() < 12 {
        return "Good morning, " + name
    }
    return "Hello, " + name
}
// greeter_test.go
package main

import "testing"

func TestGreeting(t *testing.T) {
    tests := []struct {
        hour int
        name string
        want string
    }{
        {9, "Ada", "Good morning, Ada"},
        {15, "Ada", "Hello, Ada"},
        {8, "", "Good morning, friend"},
    }
    for _, tt := range tests {
        got := Greeting(fixedClock{tt.hour}, tt.name)
        if got != tt.want {
            t.Fatalf("hour=%d name=%q got %q want %q", tt.hour, tt.name, got, tt.want)
        }
    }
}

Expected output:

=== RUN   TestGreeting
--- PASS: TestGreeting (0.00s)
PASS

More examples

Functional options with defaults.

package main

import "fmt"

type Cfg struct {
    Addr string
    N    int
}

type Opt func(*Cfg)

func WithAddr(a string) Opt { return func(c *Cfg) { c.Addr = a } }
func WithN(n int) Opt       { return func(c *Cfg) { c.N = n } }

func New(opts ...Opt) Cfg {
    c := Cfg{Addr: ":8080", N: 1}
    for _, o := range opts {
        o(&c)
    }
    return c
}

func main() {
    fmt.Printf("%+v\n", New(WithN(3)))
}

Expected output:

{Addr::8080 N:3}

Runnable example

Save as main.go. Then:

go mod init example
go run .
package main

import (
    "fmt"
    "io"
    "strings"
    "time"
)

// Small interfaces at the boundary
type Greeter interface {
    Greet(name string) string
}

type formalGreeter struct{}

func (formalGreeter) Greet(name string) string {
    return "Hello, " + name
}

// Options pattern for construction
type Server struct {
    host    string
    port    int
    timeout time.Duration
    greeter Greeter
}

type Option func(*Server)

func WithPort(p int) Option {
    return func(s *Server) { s.port = p }
}

func WithTimeout(d time.Duration) Option {
    return func(s *Server) { s.timeout = d }
}

func WithGreeter(g Greeter) Option {
    return func(s *Server) { s.greeter = g }
}

func NewServer(opts ...Option) *Server {
    s := &Server{
        host:    "localhost",
        port:    8080,
        timeout: time.Second,
        greeter: formalGreeter{},
    }
    for _, opt := range opts {
        opt(s)
    }
    return s
}

func (s *Server) Handle(r io.Reader) (string, error) {
    b, err := io.ReadAll(r)
    if err != nil {
        return "", fmt.Errorf("server.Handle: %w", err)
    }
    name := strings.TrimSpace(string(b))
    if name == "" {
        return "", fmt.Errorf("server.Handle: empty name")
    }
    return s.greeter.Greet(name), nil
}

func main() {
    srv := NewServer(WithPort(9090), WithTimeout(2*time.Second))
    fmt.Printf("listen %s:%d timeout=%s\n", srv.host, srv.port, srv.timeout)

    msg, err := srv.Handle(strings.NewReader("Ada"))
    if err != nil {
        panic(err)
    }
    fmt.Println(msg)

    _, err = srv.Handle(strings.NewReader("  "))
    fmt.Println("empty err:", err)
}

Expected output:

listen localhost:9090 timeout=2s
Hello, Ada
empty err: server.Handle: empty name

What to notice: Defaults live in NewServer; options only override what callers care about. Accepting io.Reader and a small Greeter interface keeps the core testable without a network. Errors wrap with %w and a stable prefix.

Try next: Inject a mock Greeter that returns "yo " + name. Add WithHost and a table-driven test for validation.