12 KiB
Code Design Principles
Core principles for designing Go types and architecture.
1. Primitive Obsession Prevention (Yoke Design Strategy)
Principle
When it makes sense, avoid using primitives directly. Instead, create a type with proper named methods.
When to Create a Type
A primitive should become a type when:
- It has validation rules
- It has behavior/logic associated with it
- It represents a domain concept
- It's used across multiple places
- Passing an invalid value would be a bug
Pattern: Self-Validating Type
// ❌ Primitive obsession
func CreateUser(id string, email string, port int) error {
if id == "" {
return errors.New("id required")
}
if !isValidEmail(email) {
return errors.New("invalid email")
}
if port <= 0 || port >= 9000 {
return errors.New("invalid port")
}
// ...
}
// ✅ Self-validating types
type UserID string
type Email string
type Port int
func NewUserID(s string) (UserID, error) {
if s == "" {
return "", errors.New("id required")
}
return UserID(s), nil
}
func NewEmail(s string) (Email, error) {
if !isValidEmail(s) {
return "", errors.New("invalid email")
}
return Email(s), nil
}
func NewPort(i int) (Port, error) {
if i <= 0 || i >= 9000 {
return 0, errors.New("port must be between 1 and 8999")
}
return Port(i), nil
}
func CreateUser(id UserID, email Email, port Port) error {
// No validation needed - types guarantee validity
// Pure business logic
}
Benefits
- Compile-time safety: Can't pass wrong type
- Centralized validation: Rules in one place (constructor)
- Self-documenting: Type name explains purpose
- Easier refactoring: Change validation in one place
Examples from Real Code
// Parser complexity split into roles
type HeaderParser struct { /* ... */ }
type PathParser struct { /* ... */ }
type BodyParser struct { /* ... */ }
// Instead of one complex Parser with all logic
2. Self-Validating Types
Principle
Types should validate their invariants in constructors. Methods should trust that the object is valid.
Pattern: Private Fields + Validating Constructor
// ❌ Non-self-validating
type UserService struct {
Repo Repository // Public, might be nil
}
func (s *UserService) CreateUser(user User) error {
if s.Repo == nil { // Defensive check in every method
return errors.New("repo is nil")
}
return s.Repo.Save(user)
}
// ✅ Self-validating
type UserService struct {
repo Repository // Private
}
func NewUserService(repo Repository) (*UserService, error) {
if repo == nil {
return nil, errors.New("repo is required")
}
return &UserService{repo: repo}, nil
}
func (s *UserService) CreateUser(user User) error {
// No nil check needed - constructor guarantees validity
return s.repo.Save(user)
}
Nil is Not a Valid Value
- Never return nil values (except errors:
nil, errorval, nilis okay) - Never pass nil into a function
- Check arguments in constructor, not in methods
Avoid Defensive Coding
- Don't check for nil fields inside methods
- Constructor should guarantee object validity
- Methods can trust object state
3. Vertical Slice Architecture
Principle
Group by feature and behavior, not by technical layer.
All code for a feature lives together in one directory.
Examples
❌ BAD: Horizontal Layers
internal/
├── handlers/health_handler.go
├── services/health_service.go
└── models/health.go
Problems: Feature scattered, hard to understand complete behavior, team conflicts
✅ GOOD: Vertical Slice
internal/health/
├── handler.go
├── service.go
├── repository.go
└── models.go
Benefits: Feature colocated, easy to understand/extract, parallel work
Migration Strategy
New features: Always implement as vertical slices Existing horizontal code: Refactor incrementally
Create docs/architecture/vertical-slice-migration.md:
# Vertical Slice Migration Plan
## Current State: [horizontal/mixed description]
## Target: Vertical slices in internal/[feature]/
## Strategy: New features vertical, refactor existing incrementally
## Progress: [x] new_feature (this PR), [ ] health, [ ] verification
Never mix: Don't have both health/service.go AND services/health_service.go for same feature.
4. Types Around Intent and Behavior
Principle
Design types around intent and behavior, not just shape.
Ask Before Creating a Type
- What is the purpose of this type?
- What invariants must it maintain?
- What behavior does it have?
- Why does it exist (beyond grouping fields)?
Pattern: Types with Behavior
// ❌ Type is just data container
type Config struct {
Host string
Port int
}
// ✅ Type has behavior and validation
type ServerAddress struct {
host string
port int
}
func NewServerAddress(host string, port int) (ServerAddress, error) {
if host == "" {
return ServerAddress{}, errors.New("host required")
}
if port <= 0 || port > 65535 {
return ServerAddress{}, errors.New("invalid port")
}
return ServerAddress{host: host, port: port}, nil
}
func (a ServerAddress) String() string {
return fmt.Sprintf("%s:%d", a.host, a.port)
}
func (a ServerAddress) IsLocal() bool {
return a.host == "localhost" || a.host == "127.0.0.1"
}
5. Type File Organization
Principle
Types with logic should be in their own file. Name the file after the type.
Pattern
user/
├── user.go # User type
├── user_id.go # UserID type with validation
├── email.go # Email type with validation
├── service.go # UserService
└── repository.go # Repository interface
When to Extract to Own File
- Type has multiple methods
- Type has complex validation
- Type has significant documentation
- Type is important enough to be easily found
6. Leaf vs Orchestrating Types
Leaf Types
Definition: Types not dependent on other custom types
Characteristics:
- Self-contained
- Minimal dependencies
- Pure logic
- Easy to test
Example:
type UserID string
type Email string
type Age int
// These are leaf types - they depend only on primitives
Testing:
- Should have 100% unit test coverage
- Test only public API
- Use table-driven tests
Orchestrating Types
Definition: Types that coordinate other types
Characteristics:
- Depend on other types (composition)
- Implement business workflows
- Minimal logic (mostly delegation)
Example:
type UserService struct {
repo Repository
notifier Notifier
}
// This orchestrates Repository and Notifier
Testing:
- Integration tests covering seams
- Test with real implementations, not mocks
- Can overlap with leaf type coverage
Design Goal
Most logic should be in leaf types.
- Leaf types are easy to test and maintain
- Orchestrators should be thin wrappers
7. Abstraction Through Interfaces
Principle
Don't create interfaces until you need them (avoid interface pollution).
When to Create an Interface
- You have multiple implementations
- You need to inject dependency for testing
- You're defining a clear contract
Pattern: Interface at Usage Point
// In user/service.go
type Repository interface { // Defined where used
Save(ctx context.Context, u User) error
Get(ctx context.Context, id UserID) (*User, error)
}
type UserService struct {
repo Repository // Depends on interface
}
// In user/postgres.go
type PostgresRepository struct {
db *sql.DB
}
func (r *PostgresRepository) Save(ctx context.Context, u User) error {
// Implementation
}
// In user/inmem.go
type InMemoryRepository struct {
users map[UserID]User
}
func (r *InMemoryRepository) Save(ctx context.Context, u User) error {
// Implementation
}
Benefits
- Easy to test (use in-memory implementation)
- Can swap implementations
- Clear contract
Don't Over-Abstract
// ❌ Interface pollution
type UserGetter interface {
Get(id UserID) (*User, error)
}
type UserSaver interface {
Save(u User) error
}
type UserDeleter interface {
Delete(id UserID) error
}
// ✅ Single cohesive interface
type Repository interface {
Get(ctx context.Context, id UserID) (*User, error)
Save(ctx context.Context, u User) error
Delete(ctx context.Context, id UserID) error
}
8. Design Checklist (Pre-Code Review)
Before writing code, review:
Can Logic Move to Smaller Types?
- Are there primitives that should be types?
- Can complex logic be split into multiple types?
- Example: Parser → HeaderParser + PathParser
Type Intent
- Is type designed around behavior, not just shape?
- Does type have clear responsibility?
- Why does this type exist?
Validation
- Is validation in constructor?
- Are fields private?
- Can methods trust object validity?
Architecture
- Is this a vertical slice (not horizontal layer)?
- Are related types in same package?
- Is package name specific (not generic)?
Dependencies
- Are dependencies injected through constructor?
- Are dependencies interfaces (if needed)?
- Is constructor validating dependencies?
Only after satisfactory answers → proceed to write code.
9. Common Go Anti-Patterns to Avoid
Goroutine Leaks
Always ensure goroutines can exit:
// ❌ Goroutine leak
func StartWorker() {
go func() {
for {
// No way to exit
work()
}
}()
}
// ✅ Goroutine with exit
func StartWorker(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done():
return
default:
work()
}
}
}()
}
Interface Pollution
Don't create interfaces until you need them.
Premature Optimization
Measure before optimizing.
Ignoring Context
Always respect context cancellation:
func DoWork(ctx context.Context) error {
// Check context
if err := ctx.Err(); err != nil {
return err
}
// ...
}
Mutex in Wrong Scope
Keep mutex close to data it protects:
// ✅ Mutex with data
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
10. Naming Conventions
Package Names
- Use flatcase:
wekatrace, notwekaTraceorweka_trace - Avoid generic names:
util,common,helper - Avoid stdlib collisions:
metricscollides with libs, usewekametrics
Type Names
- Ergonomic:
version.Infobetter thanversion.VersionInfo - Context from package:
user.Servicebetter thanuser.UserService - Avoid redundancy: method receiver provides context
Method Names
// ❌ Redundant
func (s *UserService) CreateUserAccount(u User) error
// ✅ Concise
func (s *UserService) Create(u User) error
Idiomatic Go
- Write idiomatic Go code
- Follow Go community style and best practices
- Use effective Go guidelines
Summary: Design Principles
- Prevent primitive obsession - Create types for domain concepts
- Self-validating types - Validate in constructor, trust in methods
- Vertical slices - Group by feature, not layer
- Intent and behavior - Design types around purpose
- File per type - Types with logic get own file
- Leaf types - Most logic in self-contained types
- Interfaces when needed - Don't over-abstract
- Pre-code review - Think before coding
- Avoid anti-patterns - Goroutine leaks, premature optimization, etc.
- Idiomatic naming - Follow Go conventions