slack5-1770574304/.sdlc/features/user-preferences/design.md
rdev-worker 8e08dbd822
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /design-feature user-preferences
2026-02-08 18:22:12 +00:00

20 KiB

Design: User Preferences API

Architecture Approach

Replace the existing example CRUD scaffold in services/preferences-api/ with a user preferences system. The change follows the same hexagonal architecture already in place (domain → service → port → adapter), reusing all existing framework packages (app, httperror, httpresponse, auth, openapi).

What changes:

  • Domain layer: Replace Example entity with UserPreferences value object containing typed preference fields and validation logic
  • Port layer: Replace ExampleRepository with PreferenceRepository interface (Get + Upsert)
  • Adapter layer: Replace in-memory example adapter with both an in-memory adapter (for tests) and a PostgreSQL adapter (for production)
  • Service layer: Replace ExampleService with PreferenceService orchestrating authorization checks and default hydration
  • Handler layer: Replace example CRUD handlers with two preference endpoints (GET + PUT)
  • Routes/Spec: Replace all example routes and OpenAPI documentation with preference endpoints
  • Config/Main: Wire PostgreSQL connection and new dependency graph

What stays the same:

  • Service name, port (8001), route prefix (/api/preferences-api/)
  • All framework conventions (handler pattern, error wrapping, response envelope)
  • Project structure (directory layout, go.mod, Makefile, Dockerfile)
  • Health endpoint

Data Model Changes

Domain Types

// domain/preferences.go

type UserPreferences struct {
    UserID        string
    Theme         Theme
    Language      string
    Notifications NotificationPreferences
    UpdatedAt     time.Time
}

type Theme string
const (
    ThemeLight  Theme = "light"
    ThemeDark   Theme = "dark"
    ThemeSystem Theme = "system"
)

type DigestFrequency string
const (
    DigestNone   DigestFrequency = "none"
    DigestDaily  DigestFrequency = "daily"
    DigestWeekly DigestFrequency = "weekly"
)

type NotificationPreferences struct {
    Email  bool
    Push   bool
    Digest DigestFrequency
}

Defaults (returned when no saved preferences exist):

  • Theme: "system"
  • Language: "en"
  • Notifications.Email: true
  • Notifications.Push: true
  • Notifications.Digest: "weekly"

Validation (pure domain logic, no framework dependencies):

  • Theme must be one of light, dark, system
  • Language must be one of the allowed BCP-47 tags: en, fr, es, de, ja
  • Digest must be one of none, daily, weekly
  • Unknown preference keys rejected at the handler layer via strict binding

Database Schema

Single table in the existing PostgreSQL instance:

CREATE TABLE IF NOT EXISTS user_preferences (
    user_id    TEXT PRIMARY KEY,
    theme      TEXT NOT NULL DEFAULT 'system',
    language   TEXT NOT NULL DEFAULT 'en',
    notify_email  BOOLEAN NOT NULL DEFAULT true,
    notify_push   BOOLEAN NOT NULL DEFAULT true,
    notify_digest TEXT NOT NULL DEFAULT 'weekly',
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Design decisions:

  • Flat columns (not JSONB) because the preference set is fixed and small — enables type safety, indexing, and simpler queries
  • user_id is TEXT to match the JWT subject field (UUIDs stored as text)
  • No created_atupdated_at serves as the only timestamp; preferences conceptually always exist (defaults)
  • No foreign key to a users table — the preferences service is independent; user existence is validated by auth

Migration Strategy

Schema creation handled at service startup via an EnsureSchema() method on the PostgreSQL adapter. This uses CREATE TABLE IF NOT EXISTS which is idempotent and safe for repeated runs. No migration framework needed for a single-table, new service.

API Changes

Endpoints

Both endpoints require authentication via auth.Middleware().

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

Retrieve preferences for the specified user.

Authorization: Authenticated user's JWT ID must match {user_id} in URL. Admin users (role admin) may read any user's preferences.

Response 200:

{
  "data": {
    "user_id": "usr_abc123",
    "theme": "dark",
    "language": "en",
    "notifications": {
      "email": true,
      "push": false,
      "digest": "daily"
    },
    "updated_at": "2026-02-08T12:00:00Z"
  },
  "meta": {
    "request_id": "req-xyz",
    "timestamp": "2026-02-08T12:00:01Z"
  }
}

Response 403: User ID mismatch (non-admin accessing another user's preferences).

Behavior for non-existent preferences: Returns 200 with default values (not 404). The updated_at field is omitted (zero value) to indicate defaults.

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

Create or fully replace preferences for the specified user (upsert semantics).

Authorization: Authenticated user's JWT ID must match {user_id}. Admin write is not permitted (spec: out of scope).

Request body:

{
  "theme": "dark",
  "language": "fr",
  "notifications": {
    "email": true,
    "push": false,
    "digest": "daily"
  }
}

Request binding: Use app.BindAndValidateStrict(r, &req) — strict mode rejects unknown JSON fields, satisfying the "unknown keys return 400" requirement.

Response 200:

{
  "data": {
    "user_id": "usr_abc123",
    "theme": "dark",
    "language": "fr",
    "notifications": {
      "email": true,
      "push": false,
      "digest": "daily"
    },
    "updated_at": "2026-02-08T12:00:05Z"
  },
  "meta": { ... }
}

Response 400: Invalid values or unknown keys, with per-field validation details.

Response 403: User ID mismatch.

Removed Endpoints

All example CRUD endpoints are removed:

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

Component Diagram

┌─────────────────────────────────────────────────────┐
│                    HTTP Client                       │
│              (Frontend / Admin Service)              │
└──────────────────────┬──────────────────────────────┘
                       │ HTTPS
                       ▼
┌──────────────────────────────────────────────────────┐
│  chi Router + Middleware Stack                        │
│  ┌─────────────┐ ┌──────────┐ ┌───────────────────┐ │
│  │ RequestID   │ │ Logger   │ │ auth.Middleware()  │ │
│  └─────────────┘ └──────────┘ └───────────────────┘ │
└──────────────────────┬───────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────┐
│  Handler Layer (internal/api/handlers/)               │
│  ┌──────────────────────────────────────────────┐    │
│  │  PreferenceHandler                            │    │
│  │  - Get(w, r) error     [GET  .../{user_id}]   │    │
│  │  - Update(w, r) error  [PUT  .../{user_id}]   │    │
│  │                                               │    │
│  │  Responsibilities:                            │    │
│  │  - Extract {user_id} from URL                 │    │
│  │  - Bind & validate request body (strict)      │    │
│  │  - Check auth: user_id == JWT user ID         │    │
│  │  - Delegate to PreferenceService              │    │
│  │  - Map domain errors to HTTP errors           │    │
│  │  - Return response envelope                   │    │
│  └──────────────────────────────────────────────┘    │
└──────────────────────┬───────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────┐
│  Service Layer (internal/service/)                    │
│  ┌──────────────────────────────────────────────┐    │
│  │  PreferenceService                            │    │
│  │  - GetPreferences(ctx, userID) (*Prefs, err)  │    │
│  │  - UpdatePreferences(ctx, userID, prefs) err  │    │
│  │                                               │    │
│  │  Responsibilities:                            │    │
│  │  - Apply defaults when no stored prefs exist  │    │
│  │  - Validate preference values (domain logic)  │    │
│  │  - Delegate persistence to repository port    │    │
│  └──────────────────────────────────────────────┘    │
└──────────────────────┬───────────────────────────────┘
                       │ calls port interface
                       ▼
┌──────────────────────────────────────────────────────┐
│  Port Layer (internal/port/)                         │
│  ┌──────────────────────────────────────────────┐    │
│  │  PreferenceRepository (interface)             │    │
│  │  - Get(ctx, userID) (*Prefs, error)           │    │
│  │  - Upsert(ctx, prefs *Prefs) error            │    │
│  └──────────────────────────────────────────────┘    │
└──────────────────────┬───────────────────────────────┘
                       │ implemented by
              ┌────────┴────────┐
              ▼                 ▼
┌────────────────────┐ ┌─────────────────────┐
│  Memory Adapter    │ │  Postgres Adapter    │
│  (tests)           │ │  (production)        │
│                    │ │                      │
│  map[string]*Prefs │ │  user_preferences    │
│                    │ │  table               │
└────────────────────┘ └─────────────────────┘

Error Handling Strategy

Domain Errors

// domain/errors.go
var (
    ErrInvalidTheme    = errors.New("invalid theme value")
    ErrInvalidLanguage = errors.New("invalid language value")
    ErrInvalidDigest   = errors.New("invalid digest frequency")
)

Domain validation functions return these errors with descriptive messages. The service layer calls domain validation before persisting.

Handler Error Mapping

Domain Error HTTP Status HTTP Code Message
ErrInvalidTheme 400 BAD_REQUEST "theme must be one of: light, dark, system"
ErrInvalidLanguage 400 BAD_REQUEST "language must be one of: en, fr, es, de, ja"
ErrInvalidDigest 400 BAD_REQUEST "notifications.digest must be one of: none, daily, weekly"
Strict bind error (unknown fields) 400 BAD_REQUEST Automatic from app.BindAndValidateStrict
Validation tag failure 400 VALIDATION_ERROR Per-field details from httpvalidation
Auth user mismatch 403 FORBIDDEN "access denied: cannot access another user's preferences"
No auth token 401 UNAUTHORIZED Automatic from auth.Middleware()
DB connection failure 500 INTERNAL_ERROR Logged; generic message to client
DB query failure 500 INTERNAL_ERROR Logged; generic message to client

Validation Approach

Two layers of validation:

  1. Struct tag validation via app.BindAndValidateStrict() — handles required fields, oneof constraints for enums, boolean type checking. Unknown JSON fields rejected automatically.
  2. Domain validation via UserPreferences.Validate() — additional business rules if needed beyond struct tags. Keeps the domain layer self-contained.

The struct tag approach handles most validation needs directly:

type UpdatePreferencesRequest struct {
    Theme         string                     `json:"theme" validate:"required,oneof=light dark system"`
    Language      string                     `json:"language" validate:"required,oneof=en fr es de ja"`
    Notifications UpdateNotificationsRequest `json:"notifications" validate:"required"`
}

type UpdateNotificationsRequest struct {
    Email  bool   `json:"email" validate:""`
    Push   bool   `json:"push" validate:""`
    Digest string `json:"digest" validate:"required,oneof=none daily weekly"`
}

Security Considerations

Authentication

  • Both endpoints sit behind auth.Middleware() — unauthenticated requests get 401 automatically.
  • JWT validation uses the existing JWTValidator with shared secret from JWT_SECRET env var.

Authorization

  • Self-access only for writes: Handler extracts auth.GetUser(ctx).ID and compares to {user_id} URL parameter. Mismatch returns 403.
  • Admin read access: For GET only, if the authenticated user has role admin (checked via user.HasRole("admin")), they may read another user's preferences. This supports server-rendered contexts per the spec.
  • No admin write: PUT strictly requires self-access. Even admins cannot modify another user's preferences.

Input Validation

  • Strict JSON binding rejects unknown fields (prevents key injection).
  • Enum validation via struct tags constrains values to allowed sets.
  • No SQL injection risk — using parameterized queries ($1, $2 placeholders).
  • User ID from URL is only used as a query parameter, never interpolated into SQL.

Data Exposure

  • Preferences contain no secrets or PII beyond the user ID.
  • Error messages do not leak internal state (DB errors logged server-side, generic message to client).

Performance Considerations

Expected Load

  • Preferences are read on every page load (high read frequency).
  • Preferences are updated infrequently (low write frequency).
  • Dataset is tiny per user (single row, ~6 columns).

Query Complexity

  • GET: Single-row lookup by primary key (user_id) — O(1) with index.
  • PUT: Single-row upsert by primary key — O(1) with index.
  • No joins, no pagination, no aggregations.

Caching Strategy

  • Not needed for MVP. Single-row PK lookups on PostgreSQL are sub-millisecond. The dataset fits entirely in PostgreSQL's buffer cache.
  • Future optimization: If load increases, add an in-process LRU cache with short TTL (e.g., 30 seconds). Cache invalidation on PUT is straightforward since writes go through the same service instance.

Connection Pooling

  • Uses DatabaseConfig defaults: 25 max open connections, 5 max idle, 5-minute lifetime. Appropriate for the expected load.

Migration / Rollout Plan

Phase 1: Replace Scaffold (Single Deployment)

Since the example CRUD endpoints have no consumers, the rollout is a clean replacement:

  1. Remove all example domain/port/adapter/service/handler files
  2. Add preference domain/port/adapter/service/handler files
  3. Update routes.go to register new endpoints
  4. Update spec.go with new OpenAPI documentation
  5. Update main.go to wire PostgreSQL adapter and new dependency graph
  6. Schema created at startup via CREATE TABLE IF NOT EXISTS

Rollback

  • Revert the deployment to previous version. The user_preferences table persists harmlessly and can be dropped manually if needed.

No Breaking Changes

  • No existing consumers depend on the example endpoints.
  • The health endpoint is preserved unchanged.
  • The service name, port, and route prefix remain the same.

File Change Summary

File Action Description
internal/domain/example.go Delete Remove example entity
internal/domain/errors.go Replace Preference-specific domain errors
internal/domain/preferences.go Create UserPreferences type, validation, defaults
internal/port/example.go Delete Remove example port
internal/port/preferences.go Create PreferenceRepository interface
internal/adapter/memory/example.go Delete Remove example memory adapter
internal/adapter/memory/preferences.go Create In-memory preference adapter (tests)
internal/adapter/postgres/preferences.go Create PostgreSQL preference adapter
internal/service/example.go Delete Remove example service
internal/service/example_test.go Delete Remove example service tests
internal/service/preferences.go Create PreferenceService with business logic
internal/service/preferences_test.go Create Service unit tests
internal/api/handlers/example.go Delete Remove example handlers
internal/api/handlers/example_test.go Delete Remove example handler tests
internal/api/handlers/preferences.go Create GET/PUT preference handlers
internal/api/handlers/preferences_test.go Create Handler tests with in-memory adapter
internal/api/routes.go Modify Replace example routes with preference routes
internal/api/spec.go Modify Replace OpenAPI spec for preference endpoints
cmd/server/main.go Modify Wire PostgreSQL adapter and preference service
internal/config/config.go Modify Ensure database config is loaded (may already be)

Key Design Decisions

  1. Flat columns over JSONB: Preferences are a fixed, small set. Flat columns give type safety, simpler queries, and no need for JSON path operations.

  2. Strict binding for unknown key rejection: Using app.BindAndValidateStrict() naturally rejects unknown JSON fields, satisfying the spec requirement without custom validation code.

  3. Defaults in domain, not DB: The DefaultPreferences() function lives in the domain layer. When the repository returns "not found", the service returns defaults. This keeps default logic testable and independent of storage.

  4. No separate "exists" check: GET returns defaults for non-existent users (200, not 404). PUT uses INSERT ... ON CONFLICT UPDATE for atomic upsert. No need for existence checks.

  5. Authorization in handler, not middleware: The user ID comparison is endpoint-specific logic (matching URL param to JWT), not a reusable middleware concern. Keeping it in the handler is simpler and more explicit.

  6. Admin read via role check: Uses the existing auth.User.HasRole("admin") mechanism. No new auth infrastructure needed.

  7. Schema creation at startup: CREATE TABLE IF NOT EXISTS in the adapter's constructor. Simple, idempotent, no migration tool dependency for a single new table.