Designing for the Long Term
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
- Keep packages small - Single responsibility
- Accept interfaces - Flexible dependencies
- Return structs - Clear types
- Handle errors early - Fail fast
- 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.