slack5-1770544098/.sdlc/features/user-preferences/design.md
rdev-worker 96af8d3c07
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /design-feature user-preferences
2026-02-08 10:00:11 +00:00

17 KiB

Design: User Preferences API

Architecture Approach

This feature replaces the scaffold Example entity in preferences-api with a real User Preferences domain. The implementation follows the existing hexagonal architecture pattern exactly:

  • Domain layer: New UserPreferences entity with validation for known preference keys and values
  • Port layer: PreferencesRepository interface for persistence
  • Adapter layer: PostgreSQL repository implementation using pkg/database (replaces in-memory)
  • Service layer: PreferencesService with Get and Upsert operations, authorization checks
  • Handler layer: GET and PUT handlers with request binding, error mapping, auth enforcement
  • Migration: Single SQL migration to create user_preferences table with JSONB column

No new patterns are introduced. Every layer follows the conventions established by the Example scaffold, with the scaffold code removed and replaced.

What Changes

Layer Action Files
Domain Replace example.go, errors.go internal/domain/preferences.go, internal/domain/errors.go
Port Replace example.go internal/port/preferences.go
Adapter Replace adapter/memory/ with adapter/postgres/ internal/adapter/postgres/preferences.go
Service Replace example.go internal/service/preferences.go, internal/service/preferences_test.go
Handlers Replace example.go internal/api/handlers/preferences.go, internal/api/handlers/preferences_test.go
Routes Update route registration internal/api/routes.go
Spec Update OpenAPI spec internal/api/spec.go
Config Already has DatabaseConfig — no changes needed internal/config/config.go
Main Add DB connection, migrations, wire postgres adapter cmd/server/main.go
Migration New file migrations/001_create_user_preferences.sql

What Gets Removed

All Example scaffold files: domain/example.go, port/example.go, adapter/memory/example.go, service/example.go, service/example_test.go, handlers/example.go, handlers/example_test.go. The health handler remains unchanged.

Data Model Changes

Database Schema

-- migrations/001_create_user_preferences.sql
CREATE TABLE user_preferences (
    user_id     UUID PRIMARY KEY,
    preferences JSONB NOT NULL DEFAULT '{}',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Design rationale:

  • JSONB column stores preferences as a flexible key-value map while the domain layer enforces the allowed key set. This avoids schema changes when new preference keys are added in the future.
  • user_id as primary key — one row per user, no surrogate ID needed.
  • No foreign key to a users table — the preferences-api service does not own the users table. User identity comes from the JWT.

Domain Types

// internal/domain/preferences.go

type UserPreferences struct {
    UserID      string
    Preferences map[string]any
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

Allowed preference keys and validation rules (enforced in domain layer):

Key Type Valid Values
theme string "light", "dark"
language string ISO 639-1 pattern: 2 lowercase letters (e.g., en, es, fr)
notifications_enabled bool true, false

Domain validation functions:

  • ValidatePreferences(prefs map[string]any) error — rejects unknown keys and invalid values
  • ValidatePreferenceKey(key string) error — checks key is in the allowed set
  • ValidatePreferenceValue(key string, value any) error — checks value is valid for the given key

API Changes

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

Retrieves all preferences for a user. Returns empty preferences (not 404) if the user has no saved preferences.

Auth: Required (Bearer JWT). User ID from JWT must match {user_id} path parameter.

Response 200 (preferences exist):

{
  "data": {
    "user_id": "550e8400-e29b-41d4-a716-446655440000",
    "preferences": {
      "theme": "dark",
      "language": "en",
      "notifications_enabled": true
    },
    "updated_at": "2026-02-08T12:00:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}

Response 200 (no preferences saved):

{
  "data": {
    "user_id": "550e8400-e29b-41d4-a716-446655440000",
    "preferences": {},
    "updated_at": null
  },
  "meta": { ... }
}

Error responses: 400 (invalid UUID), 401 (unauthenticated), 403 (user ID mismatch).

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

Creates or updates preferences with upsert semantics. Only provided keys are changed; omitted keys are preserved (merge behavior).

Auth: Required. User ID from JWT must match {user_id}.

Request:

{
  "preferences": {
    "theme": "dark",
    "notifications_enabled": false
  }
}

Response 200 (returns full merged preferences):

{
  "data": {
    "user_id": "550e8400-e29b-41d4-a716-446655440000",
    "preferences": {
      "theme": "dark",
      "language": "en",
      "notifications_enabled": false
    },
    "updated_at": "2026-02-08T12:00:05Z"
  },
  "meta": { ... }
}

Error responses: 400 (invalid UUID, unknown key, invalid value), 401 (unauthenticated), 403 (user ID mismatch).

Request/Response DTOs

// Handler-level DTOs
type UpdatePreferencesRequest struct {
    Preferences map[string]any `json:"preferences" validate:"required"`
}

type PreferencesResponse struct {
    UserID      string         `json:"user_id"`
    Preferences map[string]any `json:"preferences"`
    UpdatedAt   *time.Time     `json:"updated_at"`
}

Component Diagram

┌──────────────────────────────────────────────────────────┐
│                     HTTP Client                          │
└────────────┬──────────────────────────────┬──────────────┘
             │ GET /preferences/{user_id}   │ PUT /preferences/{user_id}
             ▼                              ▼
┌──────────────────────────────────────────────────────────┐
│  chi Router (/api/preferences-api)                       │
│  ├── middleware.RequestID                                 │
│  ├── middleware.Tracing                                   │
│  ├── middleware.RequestLogger                             │
│  ├── middleware.Recoverer                                 │
│  └── auth.Middleware (JWT)         ◄── all pref routes   │
└────────────┬──────────────────────────────┬──────────────┘
             │                              │
             ▼                              ▼
┌──────────────────────────────────────────────────────────┐
│  handlers.Preferences                                    │
│  ├── Get(w, r) error                                     │
│  │   ├── chi.URLParam → user_id                          │
│  │   ├── auth ownership check                            │
│  │   └── httpresponse.OK(data)                           │
│  └── Update(w, r) error                                  │
│      ├── chi.URLParam → user_id                          │
│      ├── app.BindAndValidate → UpdatePreferencesRequest  │
│      ├── auth ownership check                            │
│      └── httpresponse.OK(data)                           │
└────────────┬──────────────────────────────┬──────────────┘
             │                              │
             ▼                              ▼
┌──────────────────────────────────────────────────────────┐
│  service.PreferencesService                              │
│  ├── Get(ctx, userID) → (*UserPreferences, error)        │
│  └── Update(ctx, userID, prefs) → (*UserPreferences, err)│
│      ├── domain.ValidatePreferences(prefs)               │
│      └── repo.Upsert(ctx, userID, prefs)                 │
└────────────┬──────────────────────────────┬──────────────┘
             │                              │
             ▼                              ▼
┌──────────────────────────────────────────────────────────┐
│  port.PreferencesRepository (interface)                   │
│  ├── Get(ctx, userID) → (*UserPreferences, error)        │
│  └── Upsert(ctx, userID, prefs) → (*UserPreferences, err)│
└────────────┬──────────────────────────────┬──────────────┘
             │                              │
             ▼                              ▼
┌──────────────────────────────────────────────────────────┐
│  adapter/postgres.PreferencesRepository                   │
│  ├── Get: SELECT ... WHERE user_id = $1                  │
│  └── Upsert: INSERT ... ON CONFLICT (user_id)           │
│              DO UPDATE SET preferences = merged,          │
│              updated_at = NOW()                           │
└──────────────────────────────┬───────────────────────────┘
                               │
                               ▼
                    ┌─────────────────────┐
                    │   PostgreSQL        │
                    │   user_preferences  │
                    └─────────────────────┘

Error Handling Strategy

Domain Errors

var (
    ErrInvalidPreferenceKey   = errors.New("invalid preference key")
    ErrInvalidPreferenceValue = errors.New("invalid preference value")
)

Handler Error Mapping

Domain Error HTTP Status Response
ErrInvalidPreferenceKey 400 Bad Request "unknown preference key: <key>"
ErrInvalidPreferenceValue 400 Bad Request "invalid value for <key>: <reason>"
Unauthenticated request 401 Unauthorized Handled by auth.Middleware
User ID mismatch 403 Forbidden "access denied"
Invalid UUID in path 400 Bad Request "invalid user ID format"
Missing preferences field 400 Bad Request Handled by app.BindAndValidate
Unhandled / DB error 500 Internal Logged; generic message to client via app.Wrap

Error Mapping Function

func mapDomainError(err error) error {
    switch {
    case errors.Is(err, domain.ErrInvalidPreferenceKey):
        return httperror.BadRequest(err.Error())
    case errors.Is(err, domain.ErrInvalidPreferenceValue):
        return httperror.BadRequest(err.Error())
    default:
        return err // becomes 500 via app.Wrap
    }
}

Database Failures

  • Connection errors during startup: database.MustConnect panics with descriptive message.
  • Query errors at runtime: Bubble up through the adapter as raw errors, logged by middleware, returned as 500.
  • Migration failures at startup: database.MustRunMigrations panics with descriptive message.

Security Considerations

Authentication

All preference endpoints require authentication. Auth middleware is applied to the entire preferences route group (not selectively per-route like the scaffold):

r.Group(func(r app.Router) {
    if cfg.AuthEnabled {
        r.Use(auth.Middleware(auth.MiddlewareConfig{
            Validator: auth.NewJWTValidator(auth.JWTConfig{
                Secret: []byte(cfg.JWTSecret),
                Issuer: "slack5-1770544098",
            }),
        }))
    }
    r.Get("/preferences/{user_id}", app.Wrap(prefHandler.Get))
    r.Put("/preferences/{user_id}", app.Wrap(prefHandler.Update))
})

Authorization (Ownership Check)

Handlers enforce that the authenticated user can only access their own preferences:

func (h *Preferences) checkOwnership(r *http.Request, userID string) error {
    user := auth.MustGetUser(r.Context())
    if user.ID != userID {
        return httperror.Forbidden("access denied")
    }
    return nil
}

This is checked in both GET and PUT handlers before calling the service layer.

Input Validation

  1. Path parameter: UUID format validated via uuid.Parse().
  2. Request body: app.BindAndValidate() ensures preferences field is present.
  3. Preference keys: Domain layer rejects any key not in {theme, language, notifications_enabled}.
  4. Preference values: Domain layer validates per-key:
    • theme: must be "light" or "dark"
    • language: must match ^[a-z]{2}$ (ISO 639-1)
    • notifications_enabled: must be a boolean
  5. JSONB injection: PostgreSQL parameterized queries prevent SQL injection. Go's encoding/json handles JSON marshaling safely.

Data Boundaries

  • Users cannot read or write other users' preferences (403).
  • The API does not expose internal database IDs or timestamps beyond updated_at.
  • Error messages do not leak internal details (domain errors have descriptive but safe messages).

Performance Considerations

Expected Load

User preferences are typically read on session start and written infrequently (settings changes). Expected pattern: high read, low write.

Query Performance

  • GET: Single-row lookup by primary key (user_id UUID). O(1) index lookup — no additional indexes needed.
  • PUT (Upsert): INSERT ... ON CONFLICT operates on the primary key — efficient single-row upsert.
  • No list/search endpoints: No table scans or complex queries.

Caching Strategy

Not needed for initial implementation. The query is a primary key lookup on a single small row. If needed later, HTTP-level caching (ETag/Last-Modified based on updated_at) or application-level caching can be added without architectural changes.

Data Size

Each row contains a JSONB object with at most 3 keys. Row size is trivially small (~200 bytes). Even at millions of users, the table fits comfortably in PostgreSQL's buffer cache.

Migration / Rollout Plan

Step 1: Database Migration

Create migrations/001_create_user_preferences.sql:

CREATE TABLE IF NOT EXISTS user_preferences (
    user_id     UUID PRIMARY KEY,
    preferences JSONB NOT NULL DEFAULT '{}',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Migration runs automatically at service startup via database.MustRunMigrations(). The IF NOT EXISTS clause makes it idempotent.

Step 2: Remove Scaffold, Implement Feature

All Example scaffold code is replaced with preferences code in a single feature branch. Since the scaffold has no production users, this is a clean swap with no backward compatibility concerns.

Step 3: Wire Database in Main

Update cmd/server/main.go:

  1. Read DatabaseConfig from config.
  2. Connect to PostgreSQL via database.MustConnect().
  3. Run migrations via database.MustRunMigrations().
  4. Create postgres.PreferencesRepository with the DB pool.
  5. Create PreferencesService with the postgres repository.
  6. Register shutdown hook to close DB pool.

Step 4: Deploy

Standard service deployment. The migration creates a new table with no dependencies on existing tables, so there is zero risk to existing data or services.

Rollback

If issues arise, revert the deployment to the previous version. The user_preferences table can remain (empty or with minimal data) — it causes no harm. A future migration can drop it if the feature is permanently abandoned.

Open Questions Resolution

From the spec's open questions, the design makes these decisions:

  1. Language validation strictness: Accept any valid ISO 639-1 pattern (^[a-z]{2}$). This is permissive enough to avoid maintaining a language list while still rejecting obviously invalid input.

  2. Default preferences: The API returns empty {} for users with no preferences. The frontend handles defaults. This keeps the API simple and avoids coupling to UI decisions.

  3. Rate limiting: Not implemented in this feature. Rate limiting is a cross-cutting concern best handled at the infrastructure level (API gateway/ingress) rather than per-service.

  4. Removing the scaffold: Yes — all Example scaffold code is removed and replaced with preferences code. The scaffold served its purpose as a template.