Files
junhong_cmp_fiber/specs/001-fiber-middleware-integration/data-model.md

28 KiB

Data Model: Fiber Middleware Integration

Feature: 001-fiber-middleware-integration Date: 2025-11-10 Phase: 1 - Design & Contracts

Overview

This document defines the data structures, entities, and models used in the middleware integration feature. All structures follow Go idiomatic design principles with simple, flat structures and no Java-style patterns.


1. Configuration Model

Config Structure

File: pkg/config/config.go

// Config represents the application configuration
type Config struct {
    Server     ServerConfig     `mapstructure:"server"`
    Redis      RedisConfig      `mapstructure:"redis"`
    Logging    LoggingConfig    `mapstructure:"logging"`
    Middleware MiddlewareConfig `mapstructure:"middleware"`
}

// ServerConfig contains HTTP server settings
type ServerConfig struct {
    Address         string        `mapstructure:"address"`           // e.g., ":3000"
    ReadTimeout     time.Duration `mapstructure:"read_timeout"`      // e.g., "10s"
    WriteTimeout    time.Duration `mapstructure:"write_timeout"`     // e.g., "10s"
    ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`  // e.g., "30s"
    Prefork         bool          `mapstructure:"prefork"`           // Multi-process mode
}

// RedisConfig contains Redis connection settings
type RedisConfig struct {
    Address      string        `mapstructure:"address"`       // e.g., "localhost:6379"
    Password     string        `mapstructure:"password"`      // Leave empty if no auth
    DB           int           `mapstructure:"db"`            // Database number (0-15)
    PoolSize     int           `mapstructure:"pool_size"`     // Max connections
    MinIdleConns int           `mapstructure:"min_idle_conns"` // Keep-alive connections
    DialTimeout  time.Duration `mapstructure:"dial_timeout"`  // e.g., "5s"
    ReadTimeout  time.Duration `mapstructure:"read_timeout"`  // e.g., "3s"
    WriteTimeout time.Duration `mapstructure:"write_timeout"` // e.g., "3s"
}

// LoggingConfig contains logging settings
type LoggingConfig struct {
    Level       string           `mapstructure:"level"`        // debug, info, warn, error
    Development bool             `mapstructure:"development"`  // Enable dev mode (pretty print)
    AppLog      LogRotationConfig `mapstructure:"app_log"`     // Application log settings
    AccessLog   LogRotationConfig `mapstructure:"access_log"`  // HTTP access log settings
}

// LogRotationConfig contains log rotation settings for Lumberjack
type LogRotationConfig struct {
    Filename   string `mapstructure:"filename"`    // Log file path
    MaxSize    int    `mapstructure:"max_size"`    // Max size in MB before rotation
    MaxBackups int    `mapstructure:"max_backups"` // Max number of old files to keep
    MaxAge     int    `mapstructure:"max_age"`     // Max days to retain old files
    Compress   bool   `mapstructure:"compress"`    // Compress rotated files
}

// MiddlewareConfig contains middleware settings
type MiddlewareConfig struct {
    EnableAuth       bool              `mapstructure:"enable_auth"`        // Enable keyauth middleware
    EnableRateLimiter bool             `mapstructure:"enable_rate_limiter"` // Enable limiter (default: false)
    RateLimiter      RateLimiterConfig `mapstructure:"rate_limiter"`       // Rate limiter settings
}

// RateLimiterConfig contains rate limiter settings
type RateLimiterConfig struct {
    Max        int           `mapstructure:"max"`        // Max requests per window
    Expiration time.Duration `mapstructure:"expiration"` // Time window (e.g., "1m")
    Storage    string        `mapstructure:"storage"`    // "memory" or "redis"
}

// Validate validates configuration values
func (c *Config) Validate() error {
    if c.Server.Address == "" {
        return errors.New("server address cannot be empty")
    }
    if c.Server.ReadTimeout <= 0 {
        return errors.New("server read timeout must be positive")
    }
    if c.Redis.Address == "" {
        return errors.New("redis address cannot be empty")
    }
    if c.Redis.PoolSize <= 0 {
        return errors.New("redis pool size must be positive")
    }
    if c.Logging.AppLog.Filename == "" {
        return errors.New("app log filename cannot be empty")
    }
    if c.Logging.AccessLog.Filename == "" {
        return errors.New("access log filename cannot be empty")
    }
    if c.Middleware.RateLimiter.Max <= 0 {
        c.Middleware.RateLimiter.Max = 100 // Default
    }
    return nil
}

Example YAML Configuration (configs/config.yaml):

server:
  address: ":3000"
  read_timeout: "10s"
  write_timeout: "10s"
  shutdown_timeout: "30s"
  prefork: false

redis:
  address: "localhost:6379"
  password: ""
  db: 0
  pool_size: 10
  min_idle_conns: 5
  dial_timeout: "5s"
  read_timeout: "3s"
  write_timeout: "3s"

logging:
  level: "info"
  development: false
  app_log:
    filename: "logs/app.log"
    max_size: 100      # MB
    max_backups: 30
    max_age: 30        # days
    compress: true
  access_log:
    filename: "logs/access.log"
    max_size: 500      # MB
    max_backups: 90
    max_age: 90        # days
    compress: true

middleware:
  enable_auth: true
  enable_rate_limiter: false  # Disabled by default
  rate_limiter:
    max: 100                  # requests
    expiration: "1m"          # per minute
    storage: "memory"         # or "redis"

Validation Rules:

  • All required fields must be non-empty
  • Timeouts must be positive durations
  • Pool sizes must be positive integers
  • Log filenames must be valid paths
  • Rate limiter max must be positive (defaults to 100)

2. Authentication Model

AuthToken Entity

Storage: Redis key-value pair Key Format: auth:token:{token_string} (generated via constants.RedisAuthTokenKey()) Value Format: Plain string containing user ID TTL: Managed by Redis (set when token is created)

// Token validation doesn't use a struct - just Redis key-value
// Key:   "auth:token:abc123def456"
// Value: "user-789"
// TTL:   3600 seconds (1 hour)

Redis Operations:

// Store token (example - not part of this feature)
rdb.Set(ctx, constants.RedisAuthTokenKey(token), userID, 1*time.Hour)

// Validate token (this feature)
userID, err := rdb.Get(ctx, constants.RedisAuthTokenKey(token)).Result()
if err == redis.Nil {
    // Token not found or expired
}

// Delete token (example - logout feature)
rdb.Del(ctx, constants.RedisAuthTokenKey(token))

Key Generation Function (pkg/constants/redis.go):

// RedisAuthTokenKey generates Redis key for authentication tokens
func RedisAuthTokenKey(token string) string {
    return fmt.Sprintf("auth:token:%s", token)
}

Validation Rules:

  • Token must exist as Redis key
  • Redis must be available (fail closed if not)
  • Token value must be non-empty user ID
  • TTL is checked automatically by Redis (expired keys return redis.Nil)

3. Request Context Model

RequestContext Structure

File: pkg/middleware/context.go (or stored in Fiber's c.Locals())

// Request context is stored in Fiber's Locals, not a struct
// Access via: c.Locals("key")

// Request ID (set by requestid middleware)
requestID := c.Locals(constants.ContextKeyRequestID).(string)  // UUID v4 string

// User ID (set by keyauth middleware after validation)
userID, ok := c.Locals(constants.ContextKeyUserID).(string)
if !ok {
    // Not authenticated
}

// Start time (for duration calculation)
startTime := c.Locals(constants.ContextKeyStartTime).(time.Time)

Context Keys (constants in pkg/constants/constants.go):

const (
    // Context keys for Fiber Locals
    ContextKeyRequestID = "requestid"
    ContextKeyUserID    = "user_id"
    ContextKeyStartTime = "start_time"
)

Lifecycle:

  1. requestid middleware: Sets requestid in Locals (UUID v4)
  2. logger middleware: Sets start_time in Locals
  3. keyauth middleware: Sets user_id in Locals (after validation)
  4. handler: Accesses context values from Locals
  5. logger middleware (after handler): Calculates duration and logs

4. Log Entry Models

Application Log Entry

Format: JSON Output: logs/app.log Logger: Zap (appLogger instance)

{
  "timestamp": "2025-11-10T15:30:45.123Z",
  "level": "info",
  "logger": "service.user",
  "caller": "user/service.go:42",
  "message": "User created successfully",
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "user_id": "user-789",
  "username": "john_doe",
  "ip": "192.168.1.100"
}

Fields:

  • timestamp: ISO 8601 format (RFC3339)
  • level: debug, info, warn, error
  • logger: Logger name (optional, for structured logging)
  • caller: Source file and line number
  • message: Log message
  • request_id: Request correlation ID (if available)
  • user_id: Authenticated user ID (if available)
  • Custom fields: Any additional context-specific fields

Zap Usage:

appLogger.Info("User created successfully",
    zap.String("request_id", requestID),
    zap.String(constants.ContextKeyUserID, userID),
    zap.String("username", username),
    zap.String("ip", ip),
)

Access Log Entry Format

Purpose: Records all HTTP requests for audit trail, performance monitoring, and troubleshooting Format: JSON (one entry per line) Output: logs/access.log Logger: Zap (accessLogger instance) Requirement: Implements spec.md FR-011

Complete JSON Schema:

{
  "timestamp": "2025-11-10T15:30:45.123Z",
  "level": "info",
  "method": "POST",
  "path": "/api/v1/users",
  "status": 200,
  "duration_ms": 45.234,
  "request_id": "550e8400-e29b-41d4-a716-446655440000",
  "ip": "192.168.1.100",
  "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...",
  "user_id": "user-789"
}

Field Definitions:

Field Type Required Description Example
timestamp string Yes ISO 8601 timestamp (RFC3339 with milliseconds) 2025-11-10T15:30:45.123Z
level string Yes Log level (always "info" for access logs) info
method string Yes HTTP method GET, POST, PUT, DELETE, PATCH
path string Yes Request path (including query params) /api/v1/users?page=1
status int Yes HTTP status code 200, 401, 500
duration_ms float64 Yes Request processing duration in milliseconds 45.234
request_id string Yes UUID v4 request identifier 550e8400-e29b-41d4-a716-446655440000
ip string Yes Client IP address 192.168.1.100
user_agent string Yes User-Agent header value Mozilla/5.0...
user_id string No Authenticated user ID (empty string if not authenticated) user-789 or ""

Notes:

  • All access logs are written at "info" level
  • user_id field is empty string ("") for unauthenticated requests
  • duration_ms includes full middleware chain execution time
  • path includes query parameters for complete request tracking
  • Logged after response is sent (includes actual status code)

Zap Usage:

accessLogger.Info("",
    zap.String("method", c.Method()),
    zap.String("path", c.Path()),
    zap.Int("status", c.Response().StatusCode()),
    zap.Float64("duration_ms", duration.Seconds()*1000),
    zap.String("request_id", requestID),
    zap.String("ip", c.IP()),
    zap.String("user_agent", c.Get("User-Agent")),
    zap.String(constants.ContextKeyUserID, userID),
)

5. API Response Model

Unified Response Structure

File: pkg/response/response.go

// Response is the unified API response structure
type Response struct {
    Code      int         `json:"code"`      // Application error code (0 = success)
    Data      interface{} `json:"data"`      // Response data (object, array, or null)
    Message   string      `json:"msg"`       // Human-readable message
    Timestamp string      `json:"timestamp"` // ISO 8601 timestamp (optional, can be added)
}

// Success returns a successful response
func Success(c *fiber.Ctx, data interface{}) error {
    return c.JSON(Response{
        Code:      0,
        Data:      data,
        Message:   "success",
        Timestamp: time.Now().Format(time.RFC3339),
    })
}

// Error returns an error response
func Error(c *fiber.Ctx, httpStatus int, code int, message string) error {
    return c.Status(httpStatus).JSON(Response{
        Code:      code,
        Data:      nil,
        Message:   message,
        Timestamp: time.Now().Format(time.RFC3339),
    })
}

// SuccessWithMessage returns a successful response with custom message
func SuccessWithMessage(c *fiber.Ctx, data interface{}, message string) error {
    return c.JSON(Response{
        Code:      0,
        Data:      data,
        Message:   message,
        Timestamp: time.Now().Format(time.RFC3339),
    })
}

Success Response Example:

{
  "code": 0,
  "data": {
    "id": "user-123",
    "name": "John Doe",
    "email": "john@example.com"
  },
  "msg": "success",
  "timestamp": "2025-11-10T15:30:45Z"
}

Error Response Example:

{
  "code": 1002,
  "data": null,
  "msg": "Invalid or expired token",
  "timestamp": "2025-11-10T15:30:45Z"
}

List Response Example:

{
  "code": 0,
  "data": [
    {"id": "1", "name": "Item 1"},
    {"id": "2", "name": "Item 2"}
  ],
  "msg": "success",
  "timestamp": "2025-11-10T15:30:45Z"
}

6. Error Model

Error Code Constants

File: pkg/errors/codes.go

// Application error codes
const (
    CodeSuccess                  = 0    // Success
    CodeInternalError            = 1000 // Internal server error
    CodeMissingToken             = 1001 // Missing authentication token
    CodeInvalidToken             = 1002 // Invalid or expired token
    CodeTooManyRequests          = 1003 // Too many requests (rate limited)
    CodeAuthServiceUnavailable   = 1004 // Authentication service unavailable (Redis down)
)

// Error messages (bilingual)
var errorMessages = map[int]struct {
    EN string
    ZH string
}{
    CodeSuccess:                {"Success", "成功"},
    CodeInternalError:          {"Internal server error", "内部服务器错误"},
    CodeMissingToken:           {"Missing authentication token", "缺失认证令牌"},
    CodeInvalidToken:           {"Invalid or expired token", "令牌无效或已过期"},
    CodeTooManyRequests:        {"Too many requests", "请求过于频繁"},
    CodeAuthServiceUnavailable: {"Authentication service unavailable", "认证服务不可用"},
}

// GetMessage returns error message for given code and language
func GetMessage(code int, lang string) string {
    msg, ok := errorMessages[code]
    if !ok {
        return "Unknown error"
    }
    if lang == "zh" {
        return msg.ZH
    }
    return msg.EN
}

Custom Error Types

File: pkg/errors/errors.go

import "errors"

// Standard error types for middleware
var (
    ErrMissingToken      = errors.New("missing authentication token")
    ErrInvalidToken      = errors.New("invalid or expired token")
    ErrRedisUnavailable  = errors.New("redis unavailable")
    ErrTooManyRequests   = errors.New("too many requests")
)

// AppError represents an application error with code
type AppError struct {
    Code    int    // Application error code
    Message string // Error message
    Err     error  // Underlying error (optional)
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Err)
    }
    return e.Message
}

func (e *AppError) Unwrap() error {
    return e.Err
}

// New creates a new AppError
func New(code int, message string) *AppError {
    return &AppError{
        Code:    code,
        Message: message,
    }
}

// Wrap wraps an existing error with code and message
func Wrap(code int, message string, err error) *AppError {
    return &AppError{
        Code:    code,
        Message: message,
        Err:     err,
    }
}

7. Rate Limit State Model

Rate Limit Tracking

Storage: In-memory (default) or Redis (for distributed) Managed by: Fiber limiter middleware (internal storage)

// Rate limit state is managed internally by Fiber limiter
// For memory storage: map[string]*limiterEntry
// For Redis storage: Redis keys with TTL

// Memory storage structure (internal to Fiber)
type limiterEntry struct {
    count      int       // Current request count
    expiration time.Time // Window expiration time
}

// Redis storage structure (if using Redis storage)
// Key:   "ratelimit:{ip_address}"
// Value: JSON {"count": 5, "expiration": "2025-11-10T15:31:00Z"}
// TTL:   Same as expiration window

Key Generation (for custom Redis storage):

// pkg/constants/redis.go
func RedisRateLimitKey(ip string) string {
    return fmt.Sprintf("ratelimit:%s", ip)
}

Access Pattern:

  • Middleware checks rate limit state before handler
  • Increment counter on each request
  • Reset counter when window expires
  • Return 429 when limit exceeded

Entity Relationship Diagram

┌─────────────────┐
│  Configuration  │ (YAML file)
│  - Server       │
│  - Redis        │
│  - Logging      │
│  - Middleware   │
└────────┬────────┘
         │ loads into
         ▼
┌─────────────────┐     ┌──────────────────┐
│   Application   │────▶│  Redis Client    │
│   (main.go)     │     │  (connection     │
└────────┬────────┘     │   pool)          │
         │              └──────────┬───────┘
         │ creates                 │
         ▼                         │ validates tokens
┌─────────────────┐                │
│ Middleware      │                │
│ Chain:          │                │
│ - Recover       │                │
│ - RequestID     │                │
│ - Logger        │                │
│ - KeyAuth       │◀───────────────┘
│ - RateLimiter   │
└────────┬────────┘
         │ processes
         ▼
┌─────────────────┐     ┌──────────────────┐
│   Handlers      │────▶│  Response        │
│   (API          │     │  {code, data,    │
│    endpoints)   │     │   msg}           │
└─────────────────┘     └──────────────────┘
         │
         │ logs to
         ▼
┌─────────────────┐
│  Log Files      │
│  - app.log      │ (Zap + Lumberjack)
│  - access.log   │
└─────────────────┘

Data Flow Diagram

Request Processing Flow

1. HTTP Request arrives
   ↓
2. Recover Middleware (catch panics)
   ↓
3. RequestID Middleware (generate UUID v4)
   → Store in c.Locals(constants.ContextKeyRequestID)
   ↓
4. Logger Middleware (start)
   → Store start_time in c.Locals(constants.ContextKeyStartTime)
   ↓
5. KeyAuth Middleware
   → Extract token from header
   → Call TokenValidator.Validate(token)
   → Validate with Redis: GET auth:token:{token}
   → If valid: Store user_id in c.Locals(constants.ContextKeyUserID)
   → If invalid: Return 401 with error code
   → If Redis down: Return 503 with error code
   ↓
6. [RateLimiter Middleware] (if enabled)
   → Check rate limit for c.IP()
   → If exceeded: Return 429 with error code
   ↓
7. Handler (business logic)
   → Access c.Locals(constants.ContextKeyRequestID), c.Locals(constants.ContextKeyUserID)
   → Process request
   → Return response via response.Success() or response.Error()
   ↓
8. Logger Middleware (end)
   → Calculate duration
   → Log to access.log with all context
   ↓
9. HTTP Response sent

Summary

Key Entities:

  1. Config: Application configuration (YAML → struct)
  2. AuthToken: Redis key-value (token → user ID)
  3. RequestContext: Fiber Locals (requestid, user_id, start_time)
  4. LogEntry: JSON logs (app.log, access.log)
  5. Response: Unified API response ({code, data, msg})
  6. Error: Error codes and custom types
  7. RateLimitState: Managed by Fiber limiter (memory or Redis)

Design Principles:

  • Simple, flat structures (no deep nesting)
  • Direct field access (no getters/setters)
  • Composition over inheritance
  • Explicit error handling
  • Go naming conventions (URL, ID, HTTP)
  • No Java-style patterns (no I-prefix, no Impl-suffix)

Next: Generate API contracts (OpenAPI specification)


8. Configuration Validation Rules

This section defines comprehensive validation constraints for all configuration fields, implementing the requirements in spec.md FR-003.

Validation Error Format

All validation errors MUST follow this format:

"Invalid configuration: {field_path}: {error_reason} (current value: {value}, expected: {constraint})"

Example:

"Invalid configuration: server.read_timeout: duration out of range (current value: 1s, expected: 5s-300s)"

Server Configuration Validation

Field Type Required Constraint Default Error Message
server.address string Yes Non-empty, format :PORT or HOST:PORT :3000 "server.address: must be non-empty and in format ':PORT' or 'HOST:PORT'"
server.read_timeout duration Yes 5s - 300s 10s "server.read_timeout: duration out of range (expected: 5s-300s)"
server.write_timeout duration Yes 5s - 300s 10s "server.write_timeout: duration out of range (expected: 5s-300s)"
server.shutdown_timeout duration Yes 10s - 120s 30s "server.shutdown_timeout: duration out of range (expected: 10s-120s)"
server.prefork bool No true or false false "server.prefork: must be boolean (true/false)"

Redis Configuration Validation

Field Type Required Constraint Default Error Message
redis.address string Yes Non-empty, format HOST:PORT localhost:6379 "redis.address: must be non-empty and in format 'HOST:PORT'"
redis.password string No Any string (empty allowed) "" N/A
redis.db int No 0 - 15 0 "redis.db: database number out of range (expected: 0-15)"
redis.pool_size int No 1 - 1000 10 "redis.pool_size: pool size out of range (expected: 1-1000)"
redis.min_idle_conns int No 0 - pool_size 5 "redis.min_idle_conns: must be 0 to pool_size"
redis.dial_timeout duration No 1s - 30s 5s "redis.dial_timeout: timeout out of range (expected: 1s-30s)"
redis.read_timeout duration No 1s - 30s 3s "redis.read_timeout: timeout out of range (expected: 1s-30s)"
redis.write_timeout duration No 1s - 30s 3s "redis.write_timeout: timeout out of range (expected: 1s-30s)"

Logging Configuration Validation

Field Type Required Constraint Default Error Message
logging.level string No One of: debug, info, warn, error info "logging.level: invalid log level (expected: debug, info, warn, error)"
logging.development bool No true or false false "logging.development: must be boolean (true/false)"

App Log Validation

Field Type Required Constraint Default Error Message
logging.app_log.filename string Yes Non-empty, valid file path logs/app.log "logging.app_log.filename: must be non-empty valid file path"
logging.app_log.max_size int No 1 - 1000 (MB) 100 "logging.app_log.max_size: size out of range (expected: 1-1000 MB)"
logging.app_log.max_backups int No 0 - 999 (0 = keep all) 30 "logging.app_log.max_backups: count out of range (expected: 0-999)"
logging.app_log.max_age int No 1 - 365 (days) 30 "logging.app_log.max_age: retention period out of range (expected: 1-365 days)"
logging.app_log.compress bool No true or false true "logging.app_log.compress: must be boolean (true/false)"

Access Log Validation

Field Type Required Constraint Default Error Message
logging.access_log.filename string Yes Non-empty, valid file path logs/access.log "logging.access_log.filename: must be non-empty valid file path"
logging.access_log.max_size int No 1 - 1000 (MB) 500 "logging.access_log.max_size: size out of range (expected: 1-1000 MB)"
logging.access_log.max_backups int No 0 - 999 (0 = keep all) 90 "logging.access_log.max_backups: count out of range (expected: 0-999)"
logging.access_log.max_age int No 1 - 365 (days) 90 "logging.access_log.max_age: retention period out of range (expected: 1-365 days)"
logging.access_log.compress bool No true or false true "logging.access_log.compress: must be boolean (true/false)"

Middleware Configuration Validation

Field Type Required Constraint Default Error Message
middleware.enable_auth bool No true or false true "middleware.enable_auth: must be boolean (true/false)"
middleware.enable_rate_limiter bool No true or false false "middleware.enable_rate_limiter: must be boolean (true/false)"

Rate Limiter Validation

Field Type Required Constraint Default Error Message
middleware.rate_limiter.max int No 1 - 10000 30 "middleware.rate_limiter.max: request limit out of range (expected: 1-10000)"
middleware.rate_limiter.expiration duration No 1s - 1h 1m "middleware.rate_limiter.expiration: window duration out of range (expected: 1s-1h)"
middleware.rate_limiter.storage string No "memory" or "redis" memory "middleware.rate_limiter.storage: invalid storage type (expected: memory or redis)"

Validation Implementation Guidelines

Location: pkg/config/loader.go - Validate() function

Validation Order:

  1. Required fields check - Fail fast if critical fields missing
  2. Type validation - Viper handles basic type conversion
  3. Range validation - Check numeric bounds
  4. Format validation - Validate string formats (HOST:PORT, file paths)
  5. Cross-field validation - Check dependencies (e.g., min_idle_conns <= pool_size)

Example Implementation Pattern:

func (c *Config) Validate() error {
    // Required fields
    if c.Server.Address == "" {
        return fmt.Errorf("Invalid configuration: server.address: must be non-empty (current value: empty)")
    }

    // Range validation
    if c.Server.ReadTimeout < 5*time.Second || c.Server.ReadTimeout > 300*time.Second {
        return fmt.Errorf("Invalid configuration: server.read_timeout: duration out of range (current value: %s, expected: 5s-300s)", c.Server.ReadTimeout)
    }

    // Format validation
    if !strings.Contains(c.Redis.Address, ":") {
        return fmt.Errorf("Invalid configuration: redis.address: invalid format (current value: %s, expected: HOST:PORT)", c.Redis.Address)
    }

    // Cross-field validation
    if c.Redis.MinIdleConns > c.Redis.PoolSize {
        return fmt.Errorf("Invalid configuration: redis.min_idle_conns: must not exceed pool_size (current value: %d, pool_size: %d)", c.Redis.MinIdleConns, c.Redis.PoolSize)
    }

    return nil
}

Testing Requirements:

  • Unit tests MUST cover all validation rules (T020 in tasks.md)
  • Test valid configurations (should pass)
  • Test each constraint violation (should fail with correct error message)
  • Test edge cases (min/max boundaries)
  • Test malformed YAML (should be caught by Viper)