513 lines
12 KiB
Markdown
513 lines
12 KiB
Markdown
# 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
|
|
```go
|
|
// ❌ 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
|
|
```go
|
|
// 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
|
|
```go
|
|
// ❌ 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`:
|
|
```markdown
|
|
# 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
|
|
```go
|
|
// ❌ 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:**
|
|
```go
|
|
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:**
|
|
```go
|
|
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
|
|
```go
|
|
// 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
|
|
```go
|
|
// ❌ 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:
|
|
```go
|
|
// ❌ 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:
|
|
```go
|
|
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:
|
|
```go
|
|
// ✅ 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
|
|
```go
|
|
// ❌ 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
|