30 KiB
Design Principles Checklist
Complete validation guide with debt-based categorization.
How to Use This Reference
This checklist is applied by the pre-commit-review skill using LLM reasoning to analyze code:
Application Process
- For each file under review, systematically apply all 8 categories below
- For each detected issue, generate a finding with:
- Category: Bug, Design Debt, Readability Debt, or Polish
- Location: file:line with specific line numbers
- Issue: Description with relevant code snippet
- Better: Improved pattern with example code
- Why: Impact explanation (maintenance, bugs, productivity)
- Fix: Recommended approach (which skill, which pattern)
- Effort: Time estimate for fixing
Detection Strategy
LLM analyzes code by asking questions for each principle:
- Does this code violate a design principle? → Flag it
- How severe is the impact? → Categorize (Bug > Design > Readability > Polish)
- What's the better pattern? → Provide example
- How much effort to fix? → Estimate time
Tools used during detection:
- Read tool: Get file contents for analysis
- Grep tool: Find usage patterns, count occurrences, detect duplication across codebase
- LLM reasoning: Pattern match anti-patterns, apply heuristics, calculate scores
Juiciness Scoring (for Primitive Obsession)
When detecting potential types, calculate juiciness score:
Behavioral (rich behavior):
- Complex validation (regex, ranges, business rules): +3
- Multiple meaningful methods (≥2): +2
- State transitions/transformations: +2
- Format conversions: +1
Structural (organizing complexity):
- Parsing unstructured data into fields: +3
- Grouping related data that travels together: +2
- Making implicit structure explicit: +2
- Replacing map[string]interface{}: +2
Usage (simplifies code):
- Used in 5+ places: +2
- Used in 3-4 places: +1
- Significantly simplifies calling code: +1
- Makes tests cleaner: +1
Scoring:
- Score ≥4: HIGH priority (clear win, recommend creating type)
- Score 2-3: MEDIUM priority (judgment call, present to user)
- Score 0-1: LOW priority (don't create type, over-engineering)
1. Primitive Obsession [Design Debt 🔴]
Detection
Look for:
- String types representing domain concepts (userID, email, path)
- Int types representing domain values (Port, Age, StatusCode)
- Float types representing domain measurements (Price, Distance)
- Primitive parameters without validation
- Logic operating directly on primitives
Examples
❌ Design Debt
func CompleteTask(id string) error {
if id == "" {
return ErrInvalidTaskID
}
// continue with logic...
return nil
}
func CreateUser(id string, email string, age int) error {
if id == "" {
return errors.New("id required")
}
if !strings.Contains(email, "@") {
return errors.New("invalid email")
}
if age < 0 || age > 150 {
return errors.New("invalid age")
}
// ... business logic
}
Problems:
- Validation scattered across codebase
- No compile-time guarantees
- Easy to pass invalid values
- Harder to change validation rules
✅ No Debt
type TaskID string
func NewTaskID(s string) (TaskID, error) {
if s == "" {
return "", ErrInvalidTaskID
}
return TaskID(s), nil
}
func (s *TaskService) CompleteTask(id TaskID) error {
// logic using validated TaskID - no validation needed
return nil
}
// More comprehensive example
type UserID string
type Email string
type Age 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 !strings.Contains(s, "@") {
return "", errors.New("invalid email")
}
return Email(s), nil
}
func NewAge(i int) (Age, error) {
if i < 0 || i > 150 {
return 0, errors.New("invalid age")
}
return Age(i), nil
}
func CreateUser(id UserID, email Email, age Age) error {
// No validation needed - types guarantee validity
// ... business logic only
}
Benefits:
- Type safety at compile time
- Validation centralized in constructors
- Self-documenting code
- Easier to refactor
Also Check: Enums
// ❌ Design Debt
if status == "READY"
// ✅ No Debt
type Status string
const StatusReady Status = "READY"
Review Questions
- Can this primitive be passed invalid? → Needs type
- Is validation repeated elsewhere? → Needs type
- Does this represent a domain concept? → Needs type
Fix
Use @code-designing skill to create self-validating types
2. Storifying [Readability Debt 🟡]
Detection
Look for:
- Functions mixing high-level steps with low-level details
- Implementation details obscuring business logic
- Long functions (>50 LOC) with multiple concerns
- Unclear flow/sequence of operations
Examples
❌ Readability Debt
func createPizza(order *Order) *Pizza {
pizza := &Pizza{Base: order.Size,
Sauce: order.Sauce,
Cheese: "Mozzarella"}
// High-level: toppings
if order.kind == "Veg" {
pizza.Toppings = vegToppings
} else if order.kind == "Meat" {
pizza.Toppings = meatToppings
}
// Low-level: oven temperature control
oven := oven.New()
if oven.Temp != cookingTemp {
for (oven.Temp < cookingTemp) {
time.Sleep(checkOvenInterval)
oven.Temp = getOvenTemp(oven)
}
}
// Low-level: baking mechanics
if !pizza.Baked {
oven.Insert(pizza)
time.Sleep(cookTime)
oven.Remove(pizza)
pizza.Baked = true
}
// High-level: boxing
box := box.New()
pizza.Boxed = box.PutIn(pizza)
pizza.Sliced = box.SlicePizza(order.Size)
pizza.Ready = box.Close()
return pizza
}
Problems:
- Hard to understand flow at a glance
- Mixes abstraction levels (business + infrastructure)
- Difficult to test pieces independently
- Hard to modify one concern without affecting others
✅ No Debt
func createPizza(order *Order) *Pizza {
pizza := prepare(order)
bake(pizza)
box(pizza)
return pizza
}
func prepare(order *Order) *Pizza {
pizza := &Pizza{Base: order.Size,
Sauce: order.Sauce,
Cheese: "Mozzarella"}
addToppings(pizza, order.kind)
return pizza
}
func addToppings(pizza *Pizza, kind string) {
if kind == "Veg" {
pizza.Toppings = vegToppings
} else if kind == "Meat" {
pizza.Toppings = meatToppings
}
}
func bake(pizza *Pizza) {
oven := oven.New()
heatOven(oven)
bakePizza(pizza, oven)
}
func heatOven(oven *Oven) { /* ... */ }
func bakePizza(pizza *Pizza, oven *Oven) { /* ... */ }
func box(pizza *Pizza) { /* ... */ }
Benefits:
- Reads like a story (prepare → bake → box)
- Each function single abstraction level
- Easy to test each step independently
- Clear where to make changes
Principle
Top-level functions should read like a story, not implementation
- All steps clear and easy to understand at a glance
- Hide nitty-gritty details behind methods with proper names
Review Questions
- Does this function read like steps or implementation? → Story = good
- Are there multiple abstraction levels? → Extract helpers
- Could I explain this flow in 3-5 steps? → Should match code structure
Fix
Use @refactoring skill to extract functions and clarify abstraction levels
3. Self-Validating Types [Design Debt 🔴]
Detection
Look for:
- Structs with public fields that need validation
- Methods checking if fields are nil/empty/invalid
- Validation happening outside constructors
- Defensive programming inside methods
Examples
❌ Design Debt
type UserService struct {
Repo Repository // Public, might be nil
EmailSender EmailSender // Public, might be nil
}
func (s *UserService) CreateUser(ctx context.Context, user User) error {
// Defensive checks in every method
if s.Repo == nil {
return errors.New("repo is nil")
}
if s.EmailSender == nil {
return errors.New("email sender is nil")
}
// Actual logic
return s.Repo.Save(ctx, user)
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// Must repeat checks in every method
if s.Repo == nil {
return nil, errors.New("repo is nil")
}
return s.Repo.Get(ctx, id)
}
Problems:
- Every method must check for nil
- Easy to forget defensive checks
- Can't trust object state
- Wastes time/code on validation
✅ No Debt
type UserService struct {
repo Repository // Private
emailSender EmailSender // Private
}
func NewUserService(repo Repository, emailSender EmailSender) (*UserService, error) {
// Validate once in constructor
if repo == nil {
return nil, errors.New("repo is required")
}
if emailSender == nil {
return nil, errors.New("email sender is required")
}
return &UserService{
repo: repo,
emailSender: emailSender,
}, nil
}
func (s *UserService) CreateUser(ctx context.Context, user User) error {
// No validation needed - constructor guarantees validity
return s.repo.Save(ctx, user)
}
func (s *UserService) GetUser(ctx context.Context, id UserID) (*User, error) {
// No nil checks needed
return s.repo.Get(ctx, id)
}
Benefits:
- Constructor validates once
- Methods trust object state
- Impossible to create invalid objects
- Less defensive code
Principle
Types should be self-validating:
- Check arguments in constructor
- No need to check for nil object fields inside methods
- Avoid defensive coding
Review Questions
- Do methods check field validity? → Move to constructor
- Are fields public when they shouldn't be? → Make private
- Can this object be invalid after construction? → Add validation
Fix
Use @code-designing skill to add validating constructors
4. Abstraction Levels [Readability Debt 🟡]
Detection
Look for:
- Business logic mixed with infrastructure code
- High-level concepts mixed with low-level operations
- Function doing "what" AND "how" simultaneously
- Different conceptual levels in same function
Examples
❌ Readability Debt
func ProcessOrder(order Order) error {
// High-level: validation
if order.ID == "" {
return errors.New("invalid order")
}
for _, item := range order.Items {
if item.Price < 0 {
return errors.New("invalid price")
}
}
// Low-level: database connection
db, err := sql.Open("postgres", os.Getenv("DB_URL"))
if err != nil {
return fmt.Errorf("db connection: %w", err)
}
defer db.Close()
// Mixed: transaction handling
tx, err := db.Begin()
if err != nil {
return err
}
// Low-level: SQL query construction
query := "INSERT INTO orders (id, total) VALUES ($1, $2)"
// ... many more lines of SQL/DB logic
// High-level: notification
if err := sendEmail(order.CustomerEmail, "Order confirmed"); err != nil {
return err
}
return nil
}
Problems:
- Hard to understand flow at a glance
- Mixes business logic with infrastructure
- Difficult to test independently
- Hard to change one concern without affecting others
✅ No Debt
func ProcessOrder(order Order) error {
if err := validateOrder(order); err != nil {
return err
}
if err := saveOrder(order); err != nil {
return err
}
if err := notifyCustomer(order); err != nil {
return err
}
return nil
}
func validateOrder(order Order) error {
// Validation logic only
}
func saveOrder(order Order) error {
// Database logic only
}
func notifyCustomer(order Order) error {
// Notification logic only
}
Benefits:
- Reads like a story (validate → save → notify)
- Each function single abstraction level
- Easy to test each step
- Clear separation of concerns
Principle
A function should operate at a single conceptual level
- Don't mix low-level implementation with high-level business logic
- Don't mix business logic with infrastructure
Review Questions
- Does this mix business and infrastructure? → Separate
- Are there different conceptual levels? → Extract layers
- Is the "what" clear or buried in "how"? → Clarify
Fix
Use @refactoring skill to separate abstraction layers
5. Vertical Slice Architecture [Design Debt 🔴 - ADVISORY]
Detection
Look for:
- Features split across domain/, services/, handlers/ directories
- Horizontal layering vs vertical slicing
Note: This is Design Debt but ADVISORY only. Never blocks. User may have valid reasons (time, team decisions).
Examples
⚠️ Horizontal Layering
internal/{handlers,services,domain}/feature.go
Problems: Feature scattered, coupling, team conflicts
✅ Vertical Slicing
internal/feature/{handler,service,repository,models}.go
Benefits: Colocated, easy to understand, parallel work
Advisory Messages
Horizontal pattern:
🔴 Design Debt (Advisory): Horizontal Layering
Vertical slicing preferred for: cohesion, maintainability, boundaries
Consider: Start migration with docs/architecture/vertical-slice-migration.md
Valid reasons to proceed: time constraints, team agreement
Proceed or refactor?
Mixed without docs:
💡 Polish: Document migration in docs/architecture/vertical-slice-migration.md
Helps team understand pattern and track progress.
Vertical slice:
✅ Architecture: Vertical Slice Pattern
Follows recommended pattern, feature colocated
Fix
If user wants refactor: Use @code-designing skill
6. Naming [Readability Debt 🟡 or Polish 🟢]
Detection
Look for:
- Generic names: utils, common, helpers, manager, handler (without context)
- Redundant names: UserService.CreateUserAccount
- Non-idiomatic names: getUserData vs GetUser
- Colliding names with stdlib or common libraries
Examples
🟡 Readability Debt (Generic/Vague)
package common // Too generic
type DataManager struct { // Vague
// ...
}
func ProcessData(data interface{}) interface{} { // No meaning
// ...
}
✅ Better
package user
type Service struct { // Context from package
// ...
}
func (s *Service) Create(u User) error { // Clear action
// ...
}
🟢 Polish Opportunity (Less Idiomatic)
// Less idiomatic
func (s *Service) CreateUserInDatabase(user User) error
// More idiomatic
func (s *Service) Create(user User) error // Receiver provides context
Principles
- Write idiomatic Go code
- Use flatcase for package names (e.g.,
wekatrace) - Ergonomic naming:
version.Infobetter thanversion.VersionInfo - Avoid generic names: data, utils, common, domain
- Avoid stdlib collisions: Don't use
metrics(collides with libs), usewekametrics
Review Questions
🟡 Readability:
- Is the name generic/vague? → Make specific
- Does it collide with stdlib? → Choose unique name
🟢 Polish:
- Is it idiomatic? → Minor naming improvements
- Is it ergonomic? → Reduce redundancy
Fix
🟡 Readability: Use @refactoring skill 🟢 Polish: Minor renaming
7. Testing Approach [Design Debt 🔴]
Detection
Look for:
- Tests in same package (not pkg_test)
- Testing private methods/functions
- Heavy use of mocks instead of real implementations
- Tests with cyclomatic complexity > 1 (conditionals in tests)
- time.Sleep in tests
Examples
❌ Design Debt
package user // Same package - can test private
func TestInternalValidation(t *testing.T) { // Testing private
result := validateEmailInternal("test@example.com")
assert.True(t, result)
}
func TestServiceWithMocks(t *testing.T) {
mockRepo := &MockRepository{} // Heavy mocking
mockEmailer := &MockEmailer{}
mockRepo.On("Save", mock.Anything).Return(nil)
mockEmailer.On("Send", mock.Anything).Return(nil)
svc := &UserService{Repo: mockRepo, EmailSender: mockEmailer}
// Test with mocks
}
func TestWithSleep(t *testing.T) {
go doWork()
time.Sleep(100 * time.Millisecond) // ❌ Flaky
// assert
}
✅ No Debt
package user_test // External package - tests public API only
func TestService_CreateUser(t *testing.T) { // Test public API
// Use real implementations
repo := user.NewInMemoryRepository()
emailer := user.NewTestEmailer()
svc, err := user.NewUserService(repo, emailer)
require.NoError(t, err)
// Test public behavior
err = svc.CreateUser(context.Background(), testUser)
assert.NoError(t, err)
}
func TestWithChannel(t *testing.T) {
done := make(chan struct{})
go func() {
doWork()
close(done)
}()
select {
case <-done:
// Success
case <-time.After(1 * time.Second):
t.Fatal("timeout")
}
}
Test Quality Review
Beyond structure, review if tests actually test the system properly.
Detection: Does Test Actually Test the SUT?
Look for:
- Weak assertions: Tests that pass but don't verify behavior
- Missing use cases: Important scenarios not covered
- Mock overuse: Mocking prevents testing real behavior
- Test isolation: Tests depend on each other or shared state
- Incomplete verification: Only checking happy path
- Conditionals in tests: wantErr bool pattern (violates complexity = 1)
❌ Poor Test Quality
Example 1: Weak Assertion
func TestCreateUser(t *testing.T) {
svc := setupService()
err := svc.CreateUser(ctx, user)
assert.NoError(t, err) // Only checks no error
// ❌ Doesn't verify user was actually created!
// ❌ Doesn't check user in database
// ❌ Doesn't verify email was sent
}
Example 2: Mock Prevents Real Testing
func TestDataProcessor(t *testing.T) {
mockDB := &MockDatabase{}
mockDB.On("Query", "SELECT...").Return(mockData, nil)
processor := NewProcessor(mockDB)
result := processor.Process()
assert.Equal(t, expected, result)
// ❌ Never tests real database interaction
// ❌ Can't catch SQL syntax errors
// ❌ Can't catch data marshaling issues
}
Example 3: Missing Important Use Cases
func TestParseEmail(t *testing.T) {
email, err := ParseEmail("test@example.com")
assert.NoError(t, err)
assert.Equal(t, "test@example.com", email.String())
// ❌ Only tests happy path
// ❌ Missing: empty string, invalid format, edge cases
}
Example 4: Conditionals in Tests (wantErr anti-pattern)
func TestParseEmail(t *testing.T) {
tests := []struct {
name string
input string
want Email
wantErr bool // ❌ Anti-pattern
}{
{name: "valid", input: "test@example.com", want: Email("test@example.com")},
{name: "empty", input: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseEmail(tt.input)
if tt.wantErr { // ❌ Conditional in test (complexity > 1)
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
✅ Good Test Quality
Example 1: Complete Verification
func TestCreateUser(t *testing.T) {
// Use real implementations
db := setupTestDB(t)
emailer := &TestEmailer{sent: []Email{}}
svc := NewUserService(db, emailer)
user := User{Email: "test@example.com", Name: "Test"}
err := svc.CreateUser(ctx, user)
require.NoError(t, err)
// ✅ Verify user in database
saved, err := db.GetUser(ctx, user.ID)
require.NoError(t, err)
assert.Equal(t, user.Email, saved.Email)
assert.Equal(t, user.Name, saved.Name)
// ✅ Verify email sent
assert.Len(t, emailer.sent, 1)
assert.Equal(t, user.Email, emailer.sent[0].To)
assert.Contains(t, emailer.sent[0].Body, "Welcome")
}
Example 2: Test Real Database
func TestUserRepository_Save(t *testing.T) {
// ✅ Use real database (in-memory or testcontainers)
db := setupPostgresTestContainer(t)
// OR: db := setupInMemoryDB(t)
repo := NewUserRepository(db)
user := User{Email: "test@example.com"}
// Test real database operations
err := repo.Save(ctx, user)
require.NoError(t, err)
// Verify by querying database directly
var count int
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = ?",
user.Email).Scan(&count)
require.NoError(t, err)
assert.Equal(t, 1, count)
}
Example 3: Correct Pattern (Separate Functions, Complexity = 1)
Instead of conditionals, use separate test functions:
// ✅ Success cases - always expect success (no conditionals)
func TestParseEmail_Success(t *testing.T) {
tests := []struct {
name string
input string
want Email
}{
{name: "simple", input: "test@example.com", want: Email("test@example.com")},
{name: "with plus", input: "test+tag@example.com", want: Email("test+tag@example.com")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseEmail(tt.input)
require.NoError(t, err) // No conditionals
assert.Equal(t, tt.want, got)
})
}
}
// ✅ Error cases - always expect error (no conditionals)
func TestParseEmail_Error(t *testing.T) {
tests := []struct {
name string
input string
}{
{name: "empty", input: ""},
{name: "no @", input: "testexample.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseEmail(tt.input)
assert.Error(t, err) // No conditionals
})
}
}
See testing/reference.md for complete testing patterns and anti-patterns.
Test Quality Checkpoints
When reviewing tests, check:
1. Real Implementation Usage
- Database: Use in-memory DB or testcontainers (not mocks)
- Files: Use
t.TempDir()oros.CreateTemp()(not mocks) - HTTP: Use
httptest.Server(not mocks) - External services: Use real test instances or testcontainers
- Only mock when absolutely necessary (external APIs you don't control)
2. Complete Verification
- Assert actual behavior, not just "no error"
- Verify side effects (database changes, files written, messages sent)
- Check state before and after operation
3. Use Case Coverage
- ✅ Happy path + ✅ Edge cases + ✅ Error cases
4. Test Independence
- Tests can run in any order
- Use
t.Cleanup()for cleanup - No shared mutable state
5. No Conditionals (Complexity = 1)
- ❌ No
wantErr boolwith if statements - ✅ Separate success/error test functions
6. Meaningful Assertions
- Use specific assertions with messages
- Verify business logic, not implementation
For complete testing patterns and examples, see testing/reference.md
Principles
Test Only Public API:
- Use
pkg_testpackage name - Test types via constructors only
- No testing private methods
Avoid Mocks:
- Use real implementations (HTTP test servers, temp files, in-memory DBs)
- Test with actual dependencies (integration-style)
Table-Driven Tests:
- Good when each case has cyclomatic complexity = 1
- NO conditionals inside t.Run()
- Separate success/error cases into different test functions
Testify Suites:
- ONLY for complex infrastructure setup
- NOT for simple unit tests
Synchronization:
- Avoid time.Sleep
- Use wait groups or channels
Coverage:
- Leaf types: 100% unit test coverage
- Orchestrating types: Integration tests
Review Questions
Structure:
- Are tests in same package? → Use pkg_test
- Testing private methods? → Test public API instead
- Using mocks heavily? → Use real implementations
- Using time.Sleep? → Use channels/wait groups
Quality:
- Does test verify actual behavior? → Add meaningful assertions
- Are important use cases covered? → Add edge cases and error cases
- Using mocks where real implementation possible? → Use testcontainers/in-memory/temp files
- Do tests verify side effects? → Check database/files/messages
- Are tests independent? → Use t.Cleanup(), avoid shared state
- Conditionals in tests (wantErr)? → Separate success and error test functions
Fix
Use @testing skill to restructure tests
8. Design Bugs [Bug 🐛]
Detection
Look for:
- Potential nil dereferences
- Errors assigned to
_(silently ignored) - Missing defer for resource cleanup
- Race conditions (shared state without synchronization)
- Context not propagated (using context.Background() in call chain)
- Invalid nil returns (returning nil for non-error values)
- time.Sleep in production code (should use timers/contexts)
- Goroutine leaks (no way to exit)
Examples
❌ Bug: Potential Nil Dereference
user := getUser() // Can return user with nil Profile
email := user.Profile.Email // Panic if Profile is nil
Problems:
- Crash risk if Profile is nil
- No defensive check
- Hard to debug in production
✅ Fixed
user := getUser()
if user.Profile == nil {
return errors.New("user has no profile")
}
email := user.Profile.Email
Better: Self-validating User type
func NewUser(..., profile Profile) (*User, error) {
if profile == nil {
return nil, errors.New("profile required")
}
return &User{Profile: profile}, nil
}
// Now Profile is guaranteed non-nil
func (u *User) GetEmail() string {
return u.Profile.Email // Safe, no check needed
}
❌ Bug: Ignored Error
_ = client.Record(metric) // Silent failure
Problems:
- If metrics recording fails, no visibility
- Hard to debug production issues
- Violates fail-fast principle
✅ Fixed
if err := client.Record(metric); err != nil {
log.Printf("failed to record metric: %v", err)
}
// Better: Return error if it's critical
if err := client.Record(metric); err != nil {
return fmt.Errorf("record metric: %w", err)
}
❌ Bug: Resource Leak
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
data, err := io.ReadAll(f)
if err != nil {
return err // File never closed!
}
f.Close()
return process(data)
}
Problems:
- Early return doesn't close file
- Resource leak
- Can exhaust file descriptors
✅ Fixed
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // Always closes, even on early return
data, err := io.ReadAll(f)
if err != nil {
return err
}
return process(data)
}
❌ Bug: Context Not Propagated
func (s *Service) CreateUser(ctx context.Context, user User) error {
// Ignoring ctx, using Background
return s.repo.Save(context.Background(), user)
}
Problems:
- Cancellation not respected
- Timeouts don't work
- Can't trace requests
✅ Fixed
func (s *Service) CreateUser(ctx context.Context, user User) error {
return s.repo.Save(ctx, user) // Propagate context
}
❌ Bug: Invalid Nil Return
func FindUser(id string) *User {
// ...
return nil // Nil is not a valid value for non-error returns
}
Problems:
- Caller must check for nil
- Easy to forget nil check
- Violates "nil is not a valid value" principle
✅ Fixed
func FindUser(id UserID) (*User, error) {
user, found := users[id]
if !found {
return nil, fmt.Errorf("user not found: %s", id)
}
return &user, nil
}
❌ Bug: Goroutine Leak
func startWorker() {
go func() {
for {
work := <- workChan
process(work)
// No way to exit this goroutine!
}
}()
}
Problems:
- Goroutine runs forever
- Memory leak
- Can't shutdown cleanly
✅ Fixed
func startWorker(ctx context.Context) {
go func() {
for {
select {
case work := <- workChan:
process(work)
case <-ctx.Done():
return // Clean exit
}
}
}()
}
Review Questions
- Can anything panic? → Check nil flows
- Are errors handled? → No
_ = ... - Are resources cleaned up? → Check defer usage
- Is context propagated? → No context.Background in chains
- Can goroutines exit? → Check cancellation
Fix
Fix bugs immediately before any refactoring work.
- Nil issues → Add validation or use self-validating types
- Ignored errors → Log at minimum, return if critical
- Resource leaks → Add defer statements
- Context issues → Propagate ctx through call chain
- Goroutine leaks → Add cancellation via context
Review Process Summary
For each modified file:
-
Run Checklist (#1-8 above)
-
Categorize Findings:
- 🐛 Bugs: Nil deref, ignored errors, resource leaks (fix immediately)
- 🔴 Design Debt: Types, architecture, validation
- 🟡 Readability Debt: Abstraction, flow, naming
- 🟢 Polish: Minor improvements
-
Check Broader Context:
- Similar issues in rest of file?
- Pattern worth addressing holistically?
-
Generate Report:
- Specific findings with locations
- Concrete suggestions with examples
- Impact explanations (why it matters)
- Recommended skills to fix
-
User Decision:
- Commit as-is
- Fix specific debt categories
- Expand scope to broader refactor
Additional Principles from coding_rules.md
Function Complexity
- Keep functions under 50 LOC
- Max 2 nesting levels
- Deeply nested if/else → Extract functions or early returns
Nil Handling
- Never return nil values (except for errors:
nil, errorval, nilis ok) - Never pass nil into functions
- Avoid defensive nil checks in methods (validate in constructor)
Defer Complexity
- If defer functions have cyclomatic complexity > 1 → Extract to separate function
Test Coverage Strategy
- Leaf types (not dependent on others): 100% unit test coverage
- Most logic should be in leaf types
- Orchestrating types: Integration tests covering seams
Linting
- Never use
nolintdirectives without approval - Try to fix code first
- If false positive, add to exclusions in
.golangci.yaml - Fix can be as simple as logging error instead of ignoring
Table-Driven Tests
ALWAYS use named struct fields:
// ❌ BAD - breaks when linter reorders fields
{name: "test1", 42, "result"},
// ✅ GOOD - works regardless of field order
{name: "test1", input: 42, want: "result"},