slate-test-1770505673/.sdlc/features/user-preferences/design.md
rdev-worker 37e6dbe519
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
build: /design-feature user-preferences
2026-02-07 23:32:27 +00:00

18 KiB
Raw Permalink Blame History

Design: User Preferences API

Architecture Approach

Replace the existing example CRUD scaffold in services/preferences-api/ with a preference-specific domain following the same hexagonal architecture pattern: domain → service → port (interface) → adapter (implementation).

What Changes

Layer Action Description
Domain Replace New Preference and PreferenceKey types with validation; remove Example entity
Port Replace New PreferenceRepository interface with GetByUserID and Upsert methods
Adapter Replace New PostgreSQL adapter (replaces in-memory); new migration for user_preferences table
Service Replace New PreferenceService with get/upsert business logic, key/value validation
Handlers Replace New Preference handler for GET and PUT endpoints with auth enforcement
Routes Modify Update route registration: both endpoints require auth, add user_id ownership check
Spec Replace New OpenAPI documentation for preference endpoints
Main Modify Wire PostgreSQL pool, run migrations, inject into preference service

What Stays the Same

  • Service name (preferences-api), port (8001), base path (/api/preferences-api)
  • Health check endpoint and handler
  • Config loading pattern (extended with database config)
  • All pkg/* dependencies remain unchanged
  • Makefile, Dockerfile, component.yaml structure

Data Model Changes

Domain Types

// internal/domain/preference.go

// AllowedKeys defines the valid preference keys and their allowed values.
var AllowedKeys = map[string][]string{
    "theme":                 {"light", "dark", "system"},
    "language":              {}, // validated via regex: ^[a-z]{2}$ (ISO 639-1)
    "notifications_enabled": {"true", "false"},
}

// Preference represents a single user preference key-value pair.
type Preference struct {
    UserID string
    Key    string
    Value  string
}

// Validate checks that Key is known and Value is valid for that key.
func (p *Preference) Validate() error { ... }

// ValidateKey checks if a key is in the allowed set.
func ValidateKey(key string) error { ... }

// ValidateValue checks if a value is valid for the given key.
func ValidateValue(key, value string) error { ... }
// internal/domain/errors.go

var (
    ErrUnknownKey      = errors.New("unknown preference key")
    ErrInvalidValue    = errors.New("invalid preference value")
    ErrForbidden       = errors.New("access denied")
)

Database Schema

-- migrations/001_create_user_preferences.sql

CREATE TABLE IF NOT EXISTS 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);

Design decisions:

  • Composite primary key (user_id, key) — enforces one value per key per user, enables efficient upsert via ON CONFLICT.
  • Key-value model rather than a wide row — allows adding new preference keys without schema migration.
  • Index on user_id — supports efficient retrieval of all preferences for a single user.
  • No foreign key to a users table — the preferences service doesn't own user data; user existence is validated by the auth token.

Port Interface

// internal/port/preference.go

type PreferenceRepository interface {
    // GetByUserID returns all preferences for a user as a map[key]value.
    // Returns an empty map if the user has no preferences.
    GetByUserID(ctx context.Context, userID string) (map[string]string, error)

    // Upsert creates or updates preferences for a user.
    // Only the provided keys are affected; existing keys not in the map are preserved.
    Upsert(ctx context.Context, userID string, prefs map[string]string) error
}

PostgreSQL Adapter

// internal/adapter/postgres/preference.go

type PreferenceRepository struct {
    db     *sqlx.DB
    logger *logging.Logger
}

func (r *PreferenceRepository) GetByUserID(ctx context.Context, userID string) (map[string]string, error) {
    // SELECT key, value FROM user_preferences WHERE user_id = $1
    // Returns empty map if no rows
}

func (r *PreferenceRepository) Upsert(ctx context.Context, userID string, prefs map[string]string) error {
    // Uses a transaction with batch INSERT ... ON CONFLICT (user_id, key)
    // DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
    // One statement per key within a single transaction
}

API Changes

Endpoints

Both endpoints are mounted under /api/preferences-api and require JWT authentication.

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

Retrieve all preferences for a user.

Request:

GET /api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <jwt>

Response (200 OK):

{
    "data": {
        "theme": "dark",
        "language": "en",
        "notifications_enabled": "true"
    },
    "meta": {
        "request_id": "abc-123",
        "timestamp": "2026-02-07T12:00:00Z"
    }
}

Response (200 OK, no preferences set):

{
    "data": {},
    "meta": { ... }
}

Error Responses:

  • 400 Bad Request — invalid UUID in path
  • 401 Unauthorized — missing or invalid JWT
  • 403 Forbidden — user_id does not match JWT subject

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

Create or update preferences (partial upsert).

Request:

PUT /api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <jwt>
Content-Type: application/json

{
    "theme": "dark",
    "language": "fr"
}

Response (200 OK):

{
    "data": {
        "theme": "dark",
        "language": "fr",
        "notifications_enabled": "true"
    },
    "meta": { ... }
}

Returns the full preference set after the update (including unchanged keys).

Error Responses:

  • 400 Bad Request — invalid UUID, unknown key, or invalid value (with descriptive message)
  • 401 Unauthorized — missing or invalid JWT
  • 403 Forbidden — user_id does not match JWT subject

Request/Response DTOs

// Handler request DTO for PUT
type UpdatePreferencesRequest struct {
    Preferences map[string]string // Unmarshalled from JSON body
}

// Handler response DTO for GET and PUT
type PreferencesResponse struct {
    Preferences map[string]string // Serialized as flat JSON object
}

The response data field is a flat map[string]string, not wrapped in a preferences key. This keeps the API simple: data.theme, not data.preferences.theme.

Component Diagram

┌──────────────────────────────────────────────────────────────────┐
│                        HTTP Client                               │
│           Authorization: Bearer <jwt>                            │
└──────────────────────┬───────────────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────────────┐
│                     Chi Router                                    │
│  /api/preferences-api/preferences/{user_id}  [GET, PUT]          │
│                                                                   │
│  Middleware Stack:                                                 │
│  ┌─────────┐ ┌─────────┐ ┌───────────┐ ┌──────────┐            │
│  │RequestID│→│ Tracing │→│  Logger   │→│Recoverer │            │
│  └─────────┘ └─────────┘ └───────────┘ └──────────┘            │
│                       │                                           │
│              ┌────────┴────────┐                                  │
│              │ Auth Middleware  │  ← pkg/auth JWT validation      │
│              └────────┬────────┘                                  │
│                       │                                           │
│              ┌────────┴────────┐                                  │
│              │ Preference      │  ← Handler: ownership check,    │
│              │ Handler         │    bind, validate, map errors    │
│              └────────┬────────┘                                  │
└───────────────────────┼──────────────────────────────────────────┘
                        │
               ┌────────┴────────┐
               │  Preference     │  ← Service: domain validation,
               │  Service        │    orchestrate get/upsert
               └────────┬────────┘
                        │
               ┌────────┴────────┐
               │  Preference     │  ← Port: interface
               │  Repository     │
               └────────┬────────┘
                        │
               ┌────────┴────────┐
               │  PostgreSQL     │  ← Adapter: SQL queries,
               │  Adapter        │    ON CONFLICT upsert
               └────────┬────────┘
                        │
               ┌────────┴────────┐
               │   PostgreSQL    │  ← user_preferences table
               │   Database      │
               └─────────────────┘

Error Handling Strategy

Error Condition Domain Error HTTP Error Status
Invalid UUID in path httperror.BadRequest("invalid user ID format") 400
Empty request body (PUT) httperror.BadRequest("request body is required") 400
Unknown preference key ErrUnknownKey httperror.BadRequest("unknown preference key: <key>") 400
Invalid preference value ErrInvalidValue httperror.BadRequest("invalid value '<val>' for key '<key>': allowed values are [...]") 400
Missing/invalid JWT Handled by auth middleware 401
user_id ≠ JWT subject ErrForbidden httperror.Forbidden("cannot access preferences for another user") 403
Database connection error raw error Passthrough → app.Wrap returns 500 500

Error message strategy: Validation errors include specific, actionable messages that tell the client what went wrong and what is allowed. For example: "invalid value 'blue' for key 'theme': allowed values are [light, dark, system]".

Ownership Check Flow

1. Auth middleware validates JWT → stores auth.User in context
2. Handler extracts user_id from URL path
3. Handler calls auth.GetUser(ctx) to get authenticated user
4. Handler compares user.ID == user_id path param
5. If mismatch → return httperror.Forbidden(...)
6. If match → proceed to service layer

This check lives in the handler, not the service, because it depends on HTTP/auth context. The service layer receives a validated userID string and trusts it.

Security Considerations

Authentication

  • All preference endpoints require JWT authentication — no public access.
  • Auth middleware is mandatory (not conditional on AUTH_ENABLED for preference routes). The config flag controls whether the example routes had auth; for preferences, auth is always required.
  • JWT validation uses pkg/auth.Middleware with auth.NewJWTValidator.

Authorization

  • Self-access only: authenticated users can only read/write their own preferences.
  • Ownership enforced at the handler layer by comparing auth.GetUser(ctx).ID with {user_id} path parameter.
  • No admin override (explicitly out of scope per spec).

Input Validation

  • user_id path parameter validated as UUID format at handler layer.
  • Preference keys validated against a strict allowlist — unknown keys rejected.
  • Preference values validated per-key (enum check for theme/notifications, regex for language).
  • Request body size bounded by app.Bind defaults (prevents oversized payloads).
  • No SQL injection risk: all queries use parameterized statements ($1, $2).

Data Exposure

  • GET returns only the authenticated user's preferences — no cross-user data leakage.
  • Error messages do not leak internal state (no stack traces, no database details).
  • Preference values are non-sensitive (theme, language, notification toggle).

Open Question Decisions (for design purposes)

  1. Default values: GET returns only explicitly set keys. An empty {} is returned for users with no preferences. Clients are responsible for applying defaults. This avoids coupling the API to default values that may change.
  2. DELETE support: Not included in this design (out of scope per spec). Can be added later without breaking changes.
  3. Extensibility: New keys are added by updating the AllowedKeys map in domain/preference.go. This is a code change, which is acceptable — new keys require validation rules that belong in code.
  4. Admin access: Not supported. Self-access only.

Performance Considerations

Query Performance

  • GET: Single SELECT ... WHERE user_id = $1 on a table indexed by user_id. Expected < 1ms for typical preference sets (3 keys). Well within p99 < 50ms target.
  • PUT: Transaction with INSERT ... ON CONFLICT statements. One round-trip per upsert batch. Expected < 5ms for typical updates.

Connection Pooling

  • Uses pkg/database.Pool with default settings (25 max open, 5 max idle).
  • Connection pool shared across all requests.

No Caching Needed

  • Preference reads are simple primary key lookups — PostgreSQL handles these efficiently.
  • Caching adds complexity (invalidation, stale data) with minimal benefit for this access pattern.
  • If caching becomes needed later, it can be added at the service layer without changing the port interface.

Table Size

  • One row per user per preference key (max 3 rows per user currently).
  • Even at 1M users × 3 keys = 3M rows, this is trivial for PostgreSQL.

Migration / Rollout Plan

Step 1: Remove Example Code

  • Delete all example-related files: domain/example.go, domain/errors.go (replace), port/example.go, service/example.go, service/example_test.go, adapter/memory/example.go, api/handlers/example.go, api/handlers/example_test.go.
  • This is explicitly required by the spec: "The existing example CRUD code should be replaced, not left alongside preference code."

Step 2: Implement Domain Layer

  • Create domain/preference.go with Preference type, AllowedKeys, validation functions.
  • Create domain/errors.go with ErrUnknownKey, ErrInvalidValue, ErrForbidden.
  • Test validation logic with unit tests.

Step 3: Implement Port and Adapter

  • Create port/preference.go with PreferenceRepository interface.
  • Create adapter/postgres/preference.go implementing the port.
  • Create migrations/001_create_user_preferences.sql.

Step 4: Implement Service Layer

  • Create service/preference.go with PreferenceService.
  • Create service/preference_test.go with mock repository.

Step 5: Implement Handler Layer

  • Create api/handlers/preference.go with GET/PUT handlers and ownership check.
  • Create api/handlers/preference_test.go covering success, validation, auth, and ownership cases.

Step 6: Wire Routes and Spec

  • Update api/routes.go to register preference routes with mandatory auth.
  • Replace api/spec.go with preference endpoint documentation.
  • Update cmd/server/main.go to initialize database pool, run migrations, wire dependencies.

Step 7: Verify

  • Run full test suite: cd services/preferences-api && go test -v ./...
  • Manual smoke test with curl against local instance.

Backward Compatibility

  • No backward compatibility concerns — the example CRUD API has no consumers. This is a scaffold replacement.
  • The service name, port, and base path remain unchanged.

File Inventory

File Action Purpose
cmd/server/main.go Modify Add DB pool, migrations, wire preference service
internal/domain/preference.go Create Preference types, AllowedKeys, validation
internal/domain/errors.go Replace Domain errors for preferences
internal/port/preference.go Create (replace example) PreferenceRepository interface
internal/service/preference.go Create (replace example) Business logic
internal/service/preference_test.go Create (replace example) Service tests with mock
internal/adapter/postgres/preference.go Create (replace memory) PostgreSQL adapter
internal/api/handlers/preference.go Create (replace example) HTTP handlers
internal/api/handlers/preference_test.go Create (replace example) Handler tests
internal/api/handlers/health.go Keep No changes
internal/api/routes.go Modify New routes with mandatory auth
internal/api/spec.go Replace OpenAPI spec for preferences
internal/config/config.go Keep Already has DB and auth config
migrations/001_create_user_preferences.sql Create Database schema
internal/adapter/memory/example.go Delete Replaced by postgres adapter
internal/domain/example.go Delete Replaced by preference domain
internal/port/example.go Delete Replaced by preference port
internal/service/example.go Delete Replaced by preference service
internal/service/example_test.go Delete Replaced by preference tests
internal/api/handlers/example.go Delete Replaced by preference handlers
internal/api/handlers/example_test.go Delete Replaced by preference handler tests