Files
gh-buzzdan-ai-coding-rules-…/skills/code-designing/reference.md
2025-11-29 18:02:42 +08:00

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, err or val, nil is 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, not wekaTrace or weka_trace
  • Avoid generic names: util, common, helper
  • Avoid stdlib collisions: metrics collides with libs, use wekametrics

Type Names

  • Ergonomic: version.Info better than version.VersionInfo
  • Context from package: user.Service better than user.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

  1. Prevent primitive obsession - Create types for domain concepts
  2. Self-validating types - Validate in constructor, trust in methods
  3. Vertical slices - Group by feature, not layer
  4. Intent and behavior - Design types around purpose
  5. File per type - Types with logic get own file
  6. Leaf types - Most logic in self-contained types
  7. Interfaces when needed - Don't over-abstract
  8. Pre-code review - Think before coding
  9. Avoid anti-patterns - Goroutine leaks, premature optimization, etc.
  10. Idiomatic naming - Follow Go conventions