1226 lines
30 KiB
Markdown
1226 lines
30 KiB
Markdown
# 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
|
|
1. For each file under review, systematically apply all 8 categories below
|
|
2. 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
|
|
```go
|
|
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
|
|
```go
|
|
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
|
|
```go
|
|
// ❌ 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
|
|
```go
|
|
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
|
|
```go
|
|
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
|
|
```go
|
|
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
|
|
```go
|
|
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
|
|
```go
|
|
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
|
|
```go
|
|
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)
|
|
```go
|
|
package common // Too generic
|
|
|
|
type DataManager struct { // Vague
|
|
// ...
|
|
}
|
|
|
|
func ProcessData(data interface{}) interface{} { // No meaning
|
|
// ...
|
|
}
|
|
```
|
|
|
|
#### ✅ Better
|
|
```go
|
|
package user
|
|
|
|
type Service struct { // Context from package
|
|
// ...
|
|
}
|
|
|
|
func (s *Service) Create(u User) error { // Clear action
|
|
// ...
|
|
}
|
|
```
|
|
|
|
#### 🟢 Polish Opportunity (Less Idiomatic)
|
|
```go
|
|
// 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.Info` better than `version.VersionInfo`
|
|
- **Avoid generic names**: data, utils, common, domain
|
|
- **Avoid stdlib collisions**: Don't use `metrics` (collides with libs), use `wekametrics`
|
|
|
|
### 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
|
|
```go
|
|
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
|
|
```go
|
|
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**
|
|
```go
|
|
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**
|
|
```go
|
|
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**
|
|
```go
|
|
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)**
|
|
```go
|
|
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**
|
|
```go
|
|
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**
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
// ✅ 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](../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()` or `os.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 bool` with 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](../testing/reference.md)**
|
|
|
|
---
|
|
|
|
### Principles
|
|
|
|
**Test Only Public API:**
|
|
- Use `pkg_test` package 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
|
|
```go
|
|
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
|
|
```go
|
|
user := getUser()
|
|
if user.Profile == nil {
|
|
return errors.New("user has no profile")
|
|
}
|
|
email := user.Profile.Email
|
|
```
|
|
|
|
**Better: Self-validating User type**
|
|
```go
|
|
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
|
|
```go
|
|
_ = client.Record(metric) // Silent failure
|
|
```
|
|
|
|
**Problems:**
|
|
- If metrics recording fails, no visibility
|
|
- Hard to debug production issues
|
|
- Violates fail-fast principle
|
|
|
|
#### ✅ Fixed
|
|
```go
|
|
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
|
|
```go
|
|
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
|
|
```go
|
|
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
|
|
```go
|
|
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
|
|
```go
|
|
func (s *Service) CreateUser(ctx context.Context, user User) error {
|
|
return s.repo.Save(ctx, user) // Propagate context
|
|
}
|
|
```
|
|
|
|
#### ❌ Bug: Invalid Nil Return
|
|
```go
|
|
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
|
|
```go
|
|
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
|
|
```go
|
|
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
|
|
```go
|
|
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:
|
|
|
|
1. **Run Checklist** (#1-8 above)
|
|
2. **Categorize Findings**:
|
|
- 🐛 Bugs: Nil deref, ignored errors, resource leaks (fix immediately)
|
|
- 🔴 Design Debt: Types, architecture, validation
|
|
- 🟡 Readability Debt: Abstraction, flow, naming
|
|
- 🟢 Polish: Minor improvements
|
|
|
|
3. **Check Broader Context**:
|
|
- Similar issues in rest of file?
|
|
- Pattern worth addressing holistically?
|
|
|
|
4. **Generate Report**:
|
|
- Specific findings with locations
|
|
- Concrete suggestions with examples
|
|
- Impact explanations (why it matters)
|
|
- Recommended skills to fix
|
|
|
|
5. **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, err` or `val, nil` is 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 `nolint` directives 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:**
|
|
```go
|
|
// ❌ BAD - breaks when linter reorders fields
|
|
{name: "test1", 42, "result"},
|
|
|
|
// ✅ GOOD - works regardless of field order
|
|
{name: "test1", input: 42, want: "result"},
|
|
```
|