slate-v3-1770514618/.sdlc/features/user-preferences/design.md
rdev-worker 5a6d2dc3a9
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /design-feature user-preferences
2026-02-08 01:47:26 +00:00

21 KiB

Design: User Preferences API

Architecture Approach

Replace the existing example scaffold in the preferences-api service with a user preferences system. The hexagonal architecture layers remain identical — only the domain entity, service logic, port interface, adapter, handlers, and OpenAPI spec change. No new services, packages, or infrastructure components are introduced.

Layers Changed

Layer What Changes
Domain Example entity → Preferences entity with typed fields and validation
Port ExampleRepository interface → PreferencesRepository with Get/Upsert by UserID
Adapter In-memory map keyed by UserID instead of ExampleID
Service CRUD logic → Get + Upsert with shallow merge and authorization checks
Handlers 5 endpoints (CRUD) → 2 endpoints (GET + PUT) with auth-gated access
OpenAPI Example schemas/paths → Preferences schemas/paths
Config No changes (auth config already exists)
main.go Wire PreferencesRepository and PreferencesService instead of example equivalents

What Is Removed

All example scaffold code: domain/example.go, port/example.go, adapter/memory/example.go, service/example.go, handlers/example.go, and their tests. The domain/errors.go file is replaced with preferences-specific errors.

Data Model Changes

Domain Entity: preferences.go

package domain

import "time"

// UserID is a strongly-typed identifier for users.
type UserID string

func (id UserID) String() string { return string(id) }
func (id UserID) IsZero() bool   { return id == "" }

// Preferences holds all user preferences.
type Preferences struct {
    UserID        UserID
    Theme         string
    Language      string
    Notifications NotificationSettings
    UpdatedAt     time.Time
}

type NotificationSettings struct {
    Email  bool
    Push   bool
    Digest string
}

Allowed Values (validated in domain)

Field Type Allowed Default
theme string light, dark, system system
language string ISO 639-1 (validated via regex ^[a-z]{2}$) en
notifications.email bool true, false true
notifications.push bool true, false true
notifications.digest string daily, weekly, never weekly

Domain Validation

// NewDefaultPreferences returns a Preferences with all defaults applied.
func NewDefaultPreferences(userID UserID) *Preferences

// Validate checks that all field values are within allowed sets.
// Returns a domain error listing invalid fields.
func (p *Preferences) Validate() error

// MergeFrom applies a shallow merge: top-level keys from `incoming` overwrite
// corresponding fields in `p`. The Notifications struct is replaced entirely
// when provided. Fields not present in `incoming` are left unchanged.
func (p *Preferences) MergeFrom(incoming *PreferencesUpdate)

Domain Errors: errors.go

var (
    ErrPreferencesNotFound = errors.New("preferences not found")
    ErrInvalidTheme        = errors.New("invalid theme value")
    ErrInvalidLanguage     = errors.New("invalid language value")
    ErrInvalidDigest       = errors.New("invalid digest value")
    ErrInvalidPreferences  = errors.New("invalid preferences")
)

Merge Semantics (Detail)

The spec requires shallow merge at top-level keys. Concretely:

  1. Client sends {"preferences": {"theme": "light"}} → only theme changes; language and notifications are untouched.
  2. Client sends {"preferences": {"notifications": {"email": false}}} → the entire notifications struct is replaced with {email: false, push: false, digest: ""}. Because we replace the whole nested object, missing sub-fields get zero values — but we fill missing notification sub-fields with defaults before validation to avoid forcing the client to always send the full object. This is the most user-friendly interpretation of "replaced entirely when provided" from the spec.

Implementation: A PreferencesUpdate struct uses pointer fields to distinguish "provided" from "not provided":

type PreferencesUpdate struct {
    Theme         *string
    Language      *string
    Notifications *NotificationSettingsUpdate
}

type NotificationSettingsUpdate struct {
    Email  *bool
    Push   *bool
    Digest *string
}

MergeFrom only overwrites fields where the pointer is non-nil. For Notifications, if the pointer is non-nil, individual sub-fields within NotificationSettingsUpdate are merged (non-nil pointers overwrite, nil pointers keep existing values). This provides the predictable behavior the spec describes while not forcing clients to send complete notification objects.

API Changes

Endpoints

Method Path Auth Description
GET /api/preferences-api/preferences/{user_id} Required Get all preferences for a user
PUT /api/preferences-api/preferences/{user_id} Required Create or update preferences (merge semantics)

Removed Endpoints

All /api/preferences-api/examples/* endpoints are removed.

GET /api/preferences-api/preferences/{user_id}

Path Parameters:

  • user_id — UUID format, validated with httpvalidation UUID validator

Authorization:

  • Token subject must match user_id, OR user must have admin role
  • Returns 403 Forbidden if neither condition is met

Responses:

  • 200 OK — preferences found, returned in envelope
  • 403 Forbidden — user_id doesn't match token and user is not admin
  • 404 Not Found — no preferences stored for this user

Response Body (200):

{
  "data": {
    "user_id": "550e8400-e29b-41d4-a716-446655440000",
    "preferences": {
      "theme": "dark",
      "language": "en",
      "notifications": {
        "email": true,
        "push": false,
        "digest": "weekly"
      }
    },
    "updated_at": "2026-02-08T00:00:00Z"
  },
  "meta": { "request_id": "...", "timestamp": "..." }
}

PUT /api/preferences-api/preferences/{user_id}

Path Parameters:

  • user_id — UUID format

Authorization:

  • Same as GET (own-user or admin)

Request Body:

{
  "preferences": {
    "theme": "light",
    "notifications": {
      "email": false
    }
  }
}

Validation:

  • preferences field must be present and a JSON object → 400 Bad Request if missing
  • Unknown top-level keys inside preferences (anything other than theme, language, notifications) → 400 Bad Request
  • Values validated against allowed sets → 400 Bad Request with field-level details

Responses:

  • 200 OK — preferences created or updated, full merged result returned
  • 400 Bad Request — validation failure (missing preferences, unknown keys, invalid values)
  • 403 Forbidden — authorization failure

Response Body (200): Same shape as GET response, with merged preferences.

Handler Request/Response Types

// PUT request body
type UpdatePreferencesRequest struct {
    Preferences *PreferencesPayload `json:"preferences" validate:"required"`
}

type PreferencesPayload struct {
    Theme         *string                    `json:"theme,omitempty"`
    Language      *string                    `json:"language,omitempty"`
    Notifications *NotificationSettingsPayload `json:"notifications,omitempty"`
}

type NotificationSettingsPayload struct {
    Email  *bool   `json:"email,omitempty"`
    Push   *bool   `json:"push,omitempty"`
    Digest *string `json:"digest,omitempty"`
}

// GET/PUT response body (inside envelope)
type PreferencesResponse struct {
    UserID      string                        `json:"user_id"`
    Preferences PreferencesDataResponse       `json:"preferences"`
    UpdatedAt   string                        `json:"updated_at"`
}

type PreferencesDataResponse struct {
    Theme         string                       `json:"theme"`
    Language      string                       `json:"language"`
    Notifications NotificationSettingsResponse `json:"notifications"`
}

type NotificationSettingsResponse struct {
    Email  bool   `json:"email"`
    Push   bool   `json:"push"`
    Digest string `json:"digest"`
}

Unknown field rejection: Use app.BindStrict() for the top-level request, and perform manual check on the PreferencesPayload to reject unknown keys. Alternatively, use json.Decoder.DisallowUnknownFields() via app.BindAndValidateStrict() for the outer struct, and add a custom validation step for the inner preferences object keys.

Component Diagram

┌─────────────────────────────────────────────────────────────────┐
│  HTTP Client                                                     │
│  (Frontend / API Consumer)                                       │
└──────────┬──────────────────────────────────────────┬────────────┘
           │ GET /preferences/{user_id}                │ PUT /preferences/{user_id}
           ▼                                           ▼
┌─────────────────────────────────────────────────────────────────┐
│  chi Router                                                      │
│  ├── auth.Middleware() ─── JWT validation                        │
│  └── /api/preferences-api/preferences/{user_id}                 │
└──────────┬──────────────────────────────────────────┬────────────┘
           │                                           │
           ▼                                           ▼
┌─────────────────────────────────────────────────────────────────┐
│  Handlers (internal/api/handlers/preferences.go)                 │
│  ├── Get(w, r) error                                             │
│  │   ├── Extract user_id from path, validate UUID                │
│  │   ├── Authorization check (own-user or admin)                 │
│  │   ├── Call service.Get()                                      │
│  │   └── Return httpresponse.OK() or httperror.NotFound()       │
│  └── Update(w, r) error                                          │
│      ├── Extract user_id from path, validate UUID                │
│      ├── Authorization check (own-user or admin)                 │
│      ├── app.BindAndValidateStrict() request body                │
│      ├── Call service.Upsert()                                   │
│      └── Return httpresponse.OK() with merged result             │
└──────────┬──────────────────────────────────────────┬────────────┘
           │                                           │
           ▼                                           ▼
┌─────────────────────────────────────────────────────────────────┐
│  Service (internal/service/preferences.go)                       │
│  ├── Get(ctx, userID) → (*Preferences, error)                   │
│  └── Upsert(ctx, userID, update) → (*Preferences, error)       │
│      ├── repo.Get() to fetch existing (or create defaults)      │
│      ├── domain.MergeFrom(update)                                │
│      ├── domain.Validate()                                       │
│      └── repo.Upsert()                                           │
└──────────┬──────────────────────────────────────────┬────────────┘
           │                                           │
           ▼                                           ▼
┌─────────────────────────────────────────────────────────────────┐
│  Port Interface (internal/port/preferences.go)                   │
│  PreferencesRepository {                                         │
│      Get(ctx, userID) → (*Preferences, error)                   │
│      Upsert(ctx, userID, prefs) → error                         │
│  }                                                               │
└──────────┬──────────────────────────────────────────┬────────────┘
           │                                           │
           ▼                                           ▼
┌─────────────────────────────────────────────────────────────────┐
│  Adapter: In-Memory (internal/adapter/memory/preferences.go)     │
│  map[UserID]*Preferences protected by sync.RWMutex              │
│  ├── Get: lookup by key, return copy or ErrPreferencesNotFound  │
│  └── Upsert: store copy (insert or replace)                     │
└─────────────────────────────────────────────────────────────────┘

Error Handling Strategy

Error Flow

Domain errors (domain.Err*)
    ↓ returned to service
Service returns domain errors unchanged
    ↓ returned to handler
Handler maps via mapDomainError()
    ↓ converts to httperror.*
app.Wrap() writes HTTP response

Error Mapping Table

Domain Error HTTP Status HTTP Code When
ErrPreferencesNotFound 404 NOT_FOUND GET for user with no stored preferences
ErrInvalidTheme 400 BAD_REQUEST Theme value not in allowed set
ErrInvalidLanguage 400 BAD_REQUEST Language not matching ^[a-z]{2}$
ErrInvalidDigest 400 BAD_REQUEST Digest value not in allowed set
ErrInvalidPreferences 400 BAD_REQUEST Generic validation failure
(binding error) 400 BAD_REQUEST Malformed JSON, missing preferences field
(unknown fields) 400 BAD_REQUEST Unknown keys in preferences object
(auth failure) 401 UNAUTHORIZED Missing or invalid JWT token
(authz failure) 403 FORBIDDEN user_id doesn't match token, not admin
(UUID validation) 400 BAD_REQUEST user_id path param not a valid UUID
(unexpected) 500 INTERNAL_ERROR Any unhandled error (logged by app.Wrap)

Validation Detail Response

For validation errors, the response includes field-level details:

{
  "error": {
    "code": "BAD_REQUEST",
    "message": "invalid preferences",
    "details": [
      { "field": "theme", "message": "must be one of: light, dark, system" }
    ]
  },
  "meta": { "request_id": "...", "timestamp": "..." }
}

Security Considerations

Authentication

  • Both endpoints require a valid JWT token via auth.Middleware().
  • Auth middleware runs before handler code — unauthenticated requests never reach service logic.
  • Auth is gated by AUTH_ENABLED config (same as existing pattern) to allow local dev without JWT.

Authorization

  • Own-user access: The handler extracts the authenticated user from context via auth.GetUser(ctx) and compares user.ID with the user_id path parameter. Mismatch returns 403 Forbidden.
  • Admin override: If user.HasRole("admin") is true, access is granted regardless of user_id match.
  • Authorization is enforced in the handler layer (not service layer) because it depends on HTTP context (path params + auth context).

Input Validation

Input Validation Layer
user_id path param UUID format regex Handler
Request body JSON Well-formed JSON, no unknown fields Handler (via app.BindAndValidateStrict)
preferences field Must be present and non-null Handler (struct tag validate:"required")
theme Must be light, dark, or system Domain
language Must match ^[a-z]{2}$ Domain
notifications.digest Must be daily, weekly, or never Domain
Unknown top-level keys Rejected Handler (strict binding)

Data Exposure

  • Preferences contain no secrets or PII beyond user_id (which the user already knows).
  • No cross-user data leakage: authorization check prevents reading/writing other users' preferences.
  • Error messages do not leak internal details.

Performance Considerations

Expected Load

  • Preferences are read frequently (every page load / session init) and written rarely (settings page changes).
  • Read-heavy workload: ~100:1 read-to-write ratio expected.

In-Memory Adapter Performance

  • O(1) lookups by UserID (map key).
  • No serialization overhead — domain structs stored directly.
  • sync.RWMutex allows concurrent reads; writes are serialized.
  • Suitable for development and testing. Production will use PostgreSQL adapter (out of scope).

Response Size

  • Preferences response is small (~200 bytes JSON). No pagination needed.
  • Single GET returns all preferences — no need for per-key endpoints.

Future Database Considerations (out of scope, noted for awareness)

  • user_id column should be the primary key (one row per user).
  • preferences can be stored as JSONB for flexibility, or as individual columns for type safety.
  • Index on user_id (primary key provides this automatically).

Migration / Rollout Plan

Step-by-Step

  1. Remove example scaffold: Delete all example-related files (domain, port, adapter, service, handlers, tests).
  2. Add domain layer: Create Preferences, NotificationSettings, PreferencesUpdate types with validation.
  3. Add port interface: Define PreferencesRepository with Get and Upsert methods.
  4. Add in-memory adapter: Implement PreferencesRepository with thread-safe map.
  5. Add service layer: Implement PreferencesService with Get and Upsert (merge + validate).
  6. Add handlers: Implement GET and PUT with auth, validation, and error mapping.
  7. Update routes: Replace example routes with preferences routes, apply auth middleware.
  8. Update OpenAPI spec: Replace example schemas/paths with preferences schemas/paths.
  9. Update main.go: Wire new repository, service, and routes.
  10. Write tests: Service tests (merge logic, validation, authorization) and handler tests (HTTP layer).

Rollout Risk

  • Zero risk to other services: Changes are entirely within preferences-api service boundary.
  • No database migration: In-memory adapter means no schema changes.
  • No API consumers yet: The example API has no consumers, so removing it has no compatibility impact.
  • Backward incompatibility: The /examples endpoints are removed entirely. This is expected — the spec explicitly requires replacing the scaffold.

File Changes Summary

Action File
Delete internal/domain/example.go
Delete internal/domain/errors.go (recreated with new errors)
Delete internal/port/example.go
Delete internal/adapter/memory/example.go
Delete internal/service/example.go
Delete internal/service/example_test.go
Delete internal/api/handlers/example.go
Delete internal/api/handlers/example_test.go
Create internal/domain/preferences.go
Create internal/domain/errors.go
Create internal/port/preferences.go
Create internal/adapter/memory/preferences.go
Create internal/service/preferences.go
Create internal/service/preferences_test.go
Create internal/api/handlers/preferences.go
Create internal/api/handlers/preferences_test.go
Modify internal/api/routes.go
Modify internal/api/spec.go
Modify cmd/server/main.go
Keep internal/api/handlers/health.go (unchanged)
Keep internal/config/config.go (unchanged)