slack5-1770529463/.sdlc/features/user-preferences/design.md
rdev-worker 2da48d43f8
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /design-feature user-preferences
2026-02-08 05:56:48 +00:00

21 KiB

Design: User Preferences API

Architecture Approach

Replace the existing example CRUD scaffold in services/preferences-api/ with a real user preferences domain. The hexagonal architecture layers remain identical in structure — only the domain model, service logic, port interface, adapter implementation, handlers, routes, and OpenAPI spec change.

What changes:

  • Domain layer — Remove Example entity; add Preference value object and UserPreferences aggregate with defaults/validation
  • Service layer — Remove ExampleService; add PreferenceService with get-with-defaults and upsert-with-validation logic
  • Port layer — Remove ExampleRepository; add PreferenceRepository interface for DB operations
  • Adapter layer — Remove in-memory adapter; add PostgreSQL adapter using pkg/database (sqlx)
  • Handler layer — Remove example handlers; add GET and PUT preference handlers
  • Routes — Replace /examples routes with /preferences/{user_id} routes
  • OpenAPI spec — Replace example schemas/paths with preference schemas/paths
  • Migrations — Add 001_create_user_preferences.sql
  • main.go — Wire database connection, run migrations, inject PostgreSQL adapter

What stays the same:

  • Service port (8001), health endpoint, config structure, auth middleware pattern
  • All pkg/* dependencies used identically to the scaffold
  • Test patterns (mock repository for service tests, chi router for handler tests)

Data Model Changes

Domain Types

// internal/domain/preference.go

// Known preference keys with their types and defaults
type PreferenceKey string

const (
    KeyTheme                PreferenceKey = "theme"
    KeyLanguage             PreferenceKey = "language"
    KeyNotificationsEnabled PreferenceKey = "notifications_enabled"
)

// PreferenceDefinition describes a known preference key
type PreferenceDefinition struct {
    Key          PreferenceKey
    DefaultValue string
    Validate     func(value string) error
}

// UserPreferences is the aggregate representing all preferences for a user
type UserPreferences struct {
    UserID      string
    Preferences map[PreferenceKey]string // key -> serialized value
}

Database Schema

Single migration file: services/preferences-api/migrations/001_create_user_preferences.sql

CREATE TABLE user_preferences (
    user_id     UUID NOT NULL,
    key         VARCHAR(64) NOT NULL,
    value       TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (user_id, key)
);

CREATE INDEX idx_user_preferences_user_id ON user_preferences (user_id);

Each preference is a separate row. This is an EAV (entity-attribute-value) pattern that allows adding new preference keys without schema changes.

Value Serialization

All values stored as TEXT in the database. Serialization rules:

  • theme — stored as-is ("light", "dark", "system")
  • language — stored as-is ("en", "fr", etc.)
  • notifications_enabled — stored as "true" or "false", deserialized to JSON boolean in responses

API Changes

Removed Endpoints

  • GET /api/preferences-api/examples — removed
  • GET /api/preferences-api/examples/{id} — removed
  • POST /api/preferences-api/examples — removed
  • PUT /api/preferences-api/examples/{id} — removed
  • DELETE /api/preferences-api/examples/{id} — removed

New Endpoints

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

Returns all preferences for a user, merging stored values with server-defined defaults.

  • Path param: user_id — UUID format, validated
  • Auth: In auth-protectable route group (enforcement opt-in via AUTH_ENABLED)
  • Response 200:
{
  "data": {
    "user_id": "550e8400-e29b-41d4-a716-446655440000",
    "preferences": {
      "theme": "dark",
      "language": "en",
      "notifications_enabled": true
    }
  },
  "meta": { "request_id": "...", "timestamp": "..." }
}
  • Response 400: Invalid user_id format

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

Creates or updates preferences for the given user. Only provided keys are updated; omitted keys retain their current value or default.

  • Path param: user_id — UUID format, validated
  • Auth: In auth-protectable route group (enforcement opt-in via AUTH_ENABLED)
  • Request body:
{
  "preferences": {
    "theme": "dark",
    "language": "fr"
  }
}
  • Response 200: Same shape as GET (returns full merged preferences after update)
  • Response 400: Invalid user_id, unknown preference key, or invalid preference value

Kept Endpoints

  • GET /api/preferences-api/health — unchanged

Component Diagram

┌─────────────────────────────────────────────────────────┐
│                    HTTP Layer                            │
│                                                         │
│  GET /preferences/{user_id}    PUT /preferences/{user_id}│
│         │                              │                │
│         ▼                              ▼                │
│  ┌──────────────────────────────────────────┐           │
│  │         PreferenceHandler                │           │
│  │  - Validates user_id (UUID)              │           │
│  │  - Binds PUT request body                │           │
│  │  - Maps domain errors → HTTP errors      │           │
│  │  - Returns envelope responses            │           │
│  └──────────────┬───────────────────────────┘           │
└─────────────────┼───────────────────────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────────────────────┐
│              Service Layer                              │
│  ┌──────────────────────────────────────────┐           │
│  │        PreferenceService                 │           │
│  │  - GetPreferences(userID):               │           │
│  │      fetch stored → merge defaults       │           │
│  │  - UpdatePreferences(userID, prefs):     │           │
│  │      validate keys → validate values     │           │
│  │      → upsert → fetch merged result      │           │
│  └──────────────┬───────────────────────────┘           │
└─────────────────┼───────────────────────────────────────┘
                  │ uses port interface
                  ▼
┌─────────────────────────────────────────────────────────┐
│              Port Layer (Interface)                      │
│  ┌──────────────────────────────────────────┐           │
│  │      PreferenceRepository (interface)    │           │
│  │  - GetByUserID(ctx, userID)              │           │
│  │      → []PreferenceRow, error            │           │
│  │  - Upsert(ctx, userID, key, value)       │           │
│  │      → error                             │           │
│  └──────────────────────────────────────────┘           │
└─────────────────┼───────────────────────────────────────┘
                  │ implemented by
                  ▼
┌─────────────────────────────────────────────────────────┐
│            Adapter Layer (PostgreSQL)                    │
│  ┌──────────────────────────────────────────┐           │
│  │     PostgresPreferenceRepository         │           │
│  │  - Uses sqlx via pkg/database            │           │
│  │  - GetByUserID: SELECT WHERE user_id=?   │           │
│  │  - Upsert: INSERT ON CONFLICT UPDATE     │           │
│  └──────────────┬───────────────────────────┘           │
└─────────────────┼───────────────────────────────────────┘
                  │
                  ▼
            ┌───────────┐
            │ PostgreSQL │
            │ user_      │
            │ preferences│
            └───────────┘

Detailed Layer Design

Domain Layer (internal/domain/)

Files to create:

  • preference.go — Preference types, definitions, validation, defaults
  • errors.go — Keep file, replace example errors with preference errors

preference.go responsibilities:

  1. Define PreferenceKey constants for known keys
  2. Define PreferenceDefinition registry with default values and per-key validators
  3. Provide DefaultPreferences() returning all keys with default values
  4. Provide ValidateKey(key string) error — returns error if key is unknown
  5. Provide ValidateValue(key PreferenceKey, value string) error — runs per-key validator
  6. Provide MergeWithDefaults(stored map[PreferenceKey]string) map[PreferenceKey]string
  7. Provide SerializeForResponse(prefs map[PreferenceKey]string) map[string]any — converts "true"/"false" to booleans for JSON

Validation rules:

  • theme: must be one of light, dark, system
  • language: must match BCP 47 format (regex: ^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*$)
  • notifications_enabled: must be "true" or "false"

Domain errors:

  • ErrUnknownPreferenceKey — unknown key in PUT request
  • ErrInvalidPreferenceValue — value fails validation for its key
  • ErrInvalidUserID — user_id is not a valid UUID

Port Layer (internal/port/)

File to create:

  • preference.go — Replace example.go
type PreferenceRow struct {
    UserID    string
    Key       string
    Value     string
    CreatedAt time.Time
    UpdatedAt time.Time
}

type PreferenceRepository interface {
    GetByUserID(ctx context.Context, userID string) ([]PreferenceRow, error)
    Upsert(ctx context.Context, userID string, key string, value string) error
}

The interface is minimal — no delete, no list-all-users. The service layer handles merging with defaults and batch upserts by calling Upsert in a loop (or a single batch query in the adapter).

Service Layer (internal/service/)

File to create:

  • preference.go — Replace example.go
  • preference_test.go — Replace example_test.go

PreferenceService methods:

func (s *PreferenceService) GetPreferences(ctx context.Context, userID string) (*PreferencesResult, error)
  1. Validate userID is a valid UUID → return ErrInvalidUserID if not
  2. Call repo.GetByUserID(ctx, userID) to get stored rows
  3. Convert rows to map[PreferenceKey]string
  4. Merge with defaults via domain.MergeWithDefaults()
  5. Return result with serialized preferences
func (s *PreferenceService) UpdatePreferences(ctx context.Context, userID string, input map[string]any) (*PreferencesResult, error)
  1. Validate userID is a valid UUID → return ErrInvalidUserID if not
  2. For each key in input:
    • Validate key is known → return ErrUnknownPreferenceKey if not
    • Serialize value to string (booleans to "true"/"false")
    • Validate value → return ErrInvalidPreferenceValue if invalid
  3. For each validated key-value pair, call repo.Upsert(ctx, userID, key, value)
  4. Fetch and return full merged preferences (same as GetPreferences)

PreferencesResult:

type PreferencesResult struct {
    UserID      string
    Preferences map[string]any // Serialized for JSON (booleans as bool, strings as string)
}

Adapter Layer (internal/adapter/postgres/)

File to create:

  • preference.go — PostgreSQL implementation of PreferenceRepository

Queries:

  • GetByUserID: SELECT key, value, created_at, updated_at FROM user_preferences WHERE user_id = $1
  • Upsert: INSERT INTO user_preferences (user_id, key, value, created_at, updated_at) VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()

Uses sqlx from pkg/database pool.

Handler Layer (internal/api/handlers/)

File to create:

  • preference.go — Replace example.go
  • preference_test.go — Replace example_test.go

Handler struct:

type PreferenceHandler struct {
    service *service.PreferenceService
    logger  *logging.Logger
}

GET handler (GetPreferences):

  1. Extract user_id from URL via chi.URLParam(r, "user_id")
  2. Call service.GetPreferences(ctx, userID)
  3. Map domain errors: ErrInvalidUserIDhttperror.BadRequest
  4. Return httpresponse.OK(w, r, response)

PUT handler (UpdatePreferences):

  1. Extract user_id from URL via chi.URLParam(r, "user_id")
  2. Bind request body with app.Bind(r, &req) (not BindAndValidate — custom validation in service)
  3. Call service.UpdatePreferences(ctx, userID, req.Preferences)
  4. Map domain errors:
    • ErrInvalidUserIDhttperror.BadRequest
    • ErrUnknownPreferenceKeyhttperror.BadRequest
    • ErrInvalidPreferenceValuehttperror.BadRequest
  5. Return httpresponse.OK(w, r, response)

Request type:

type UpdatePreferencesRequest struct {
    Preferences map[string]any `json:"preferences"`
}

Response type:

type PreferencesResponse struct {
    UserID      string         `json:"user_id"`
    Preferences map[string]any `json:"preferences"`
}

Routes (internal/api/routes.go)

Replace example routes with:

// Public
r.Get("/api/preferences-api/health", app.Wrap(healthHandler.Check))

// Preferences (auth-protectable)
r.Route("/api/preferences-api", func(r chi.Router) {
    if cfg.AuthEnabled {
        r.Use(auth.Middleware(...))
    }
    r.Get("/preferences/{user_id}", app.Wrap(prefHandler.GetPreferences))
    r.Put("/preferences/{user_id}", app.Wrap(prefHandler.UpdatePreferences))
})

Entry Point (cmd/server/main.go)

Changes:

  1. Add database connection via database.MustConnect()
  2. Embed and run migrations via database.MustRunMigrations()
  3. Create postgres.NewPreferenceRepository(pool) instead of memory adapter
  4. Create service.NewPreferenceService(repo, logger) instead of example service
  5. Register new routes
  6. Add DB pool shutdown hook via app.OnShutdown()

OpenAPI Spec (internal/api/spec.go)

Replace example schemas with:

  • UserPreferences schema — user_id (UUID) + preferences object
  • UpdatePreferencesRequest schema — preferences object with known keys
  • GET /preferences/{user_id} — 200, 400
  • PUT /preferences/{user_id} — 200, 400

Error Handling Strategy

Error Source Domain Error HTTP Error Status Code
Invalid user_id format ErrInvalidUserID httperror.BadRequest 400
Unknown preference key ErrUnknownPreferenceKey httperror.BadRequest 400
Invalid preference value ErrInvalidPreferenceValue httperror.BadRequest 400
Malformed JSON body (from app.Bind) httperror.BadRequest 400
Database connection failure raw error httperror.Internal (via Wrap) 500
Database query failure raw error httperror.Internal (via Wrap) 500
User has no stored preferences Not an error Returns defaults 200

Key decisions:

  • GET for a nonexistent user returns 200 with all defaults — not 404. This simplifies client logic and matches the spec.
  • All validation errors return 400 with a descriptive message including the offending key/value.
  • Database errors are not exposed to clients — Wrap converts them to generic 500.

Security Considerations

  1. Authentication: Endpoints are placed in an auth-protectable route group. When AUTH_ENABLED=true, JWT middleware is applied. When false, endpoints are open. This matches the existing scaffold pattern.

  2. Authorization: No user_id-to-token enforcement in this feature (per spec's open question #1). Any authenticated user can read/write any user's preferences. This is acceptable for the initial implementation and can be tightened later with a middleware check.

  3. Input validation:

    • user_id validated as UUID format before any DB query — prevents injection
    • Preference keys validated against a whitelist — no arbitrary key creation
    • Preference values validated per-key with strict rules — no freeform text in constrained fields
    • Request body bound via app.Bind() which uses json.Decoder — safe JSON parsing
  4. SQL injection: All queries use parameterized statements via sqlx ($1, $2 placeholders). No string interpolation in SQL.

  5. Data exposure: The API only returns preferences for the requested user_id. No list-all-users endpoint. No sensitive data in preference values (theme, language, notification toggle).

  6. Rate limiting: Not in scope for this feature but can be added via middleware later.

Performance Considerations

  1. Query complexity: Both queries are simple — SELECT WHERE user_id and INSERT ON CONFLICT. The primary key (user_id, key) and the index on user_id ensure O(log n) lookups.

  2. Expected data volume: Each user has at most 3 preference rows (currently). Even with millions of users, the user_id index makes lookups fast.

  3. Upsert pattern: PUT calls Upsert once per provided key. With 1-3 keys per request, this is 1-3 simple queries. If this becomes a bottleneck, a batch upsert with unnest() can replace the loop — but premature optimization is not warranted for 3 keys.

  4. No caching needed: Preferences are read infrequently (page load) and the query is fast. Adding a cache layer would add complexity without meaningful benefit at this scale.

  5. Connection pooling: Uses pkg/database pool with defaults (25 max open, 5 idle). Adequate for this workload.

Migration / Rollout Plan

  1. Database migration first: The CREATE TABLE migration is additive — it creates a new table and doesn't modify existing tables. Safe to run with zero downtime.

  2. Code deployment: Replace example endpoints with preference endpoints in a single deployment. Since the example endpoints are scaffold-only (no real consumers), this is a clean swap with no backwards compatibility concerns.

  3. No data migration: New table starts empty. All users get defaults on first GET. Preferences are populated as users make PUT requests.

  4. Rollback: If issues arise, revert the code deployment. The user_preferences table can remain (harmless) or be dropped in a subsequent migration.

  5. Feature flag: Not needed. The endpoints are new (replacing unused scaffolds), so there are no existing consumers to break.

File Change Summary

Action File Description
Create migrations/001_create_user_preferences.sql Database schema
Replace internal/domain/preference.go New domain (delete example.go)
Replace internal/domain/errors.go New domain errors
Replace internal/port/preference.go New repository interface (delete example.go)
Replace internal/service/preference.go New service logic (delete example.go)
Replace internal/service/preference_test.go New service tests (delete example_test.go)
Create internal/adapter/postgres/preference.go PostgreSQL adapter (delete memory/example.go)
Replace internal/api/handlers/preference.go New handlers (delete example.go)
Replace internal/api/handlers/preference_test.go New handler tests (delete example_test.go)
Modify internal/api/routes.go New route registration
Replace internal/api/spec.go New OpenAPI spec
Modify cmd/server/main.go Wire DB, migrations, new service
Keep internal/api/handlers/health.go Unchanged
Keep internal/config/config.go Unchanged (already has DB config)
Delete internal/adapter/memory/example.go Removed (replaced by postgres)