slack5-1770606136/.sdlc/features/user-preferences/design.md
rdev-worker 414c1b5464
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /design-feature user-preferences
2026-02-09 03:15:33 +00:00

20 KiB

Design: User Preferences API

Architecture Approach

Replace the scaffolded example CRUD in services/preferences-api with real preference management. The existing hexagonal architecture layers remain the same; we replace the example domain/service/port/adapter/handler with preference-specific implementations and switch from the in-memory adapter to a PostgreSQL adapter.

What changes:

  • Domain layer: New UserPreferences model replaces Example; validation rules for known preference keys
  • Port layer: New PreferenceRepository interface with Get and Upsert (replaces ExampleRepository)
  • Service layer: New PreferenceService with validation logic and delegation to repository
  • Adapter layer: New postgres/preference.go adapter using sqlx + JSONB (replaces memory/example.go)
  • Handler layer: New preference.go handler with GET/PUT endpoints (replaces example.go)
  • Routes: Updated to register preference routes instead of example routes
  • OpenAPI spec: Updated to document preference endpoints
  • Main: Wires PostgreSQL connection pool, runs migrations, injects postgres adapter
  • Migration: New 001_create_preferences.sql

What stays the same:

  • Health handler and health endpoint
  • pkg/app, pkg/httperror, pkg/httpresponse, pkg/openapi usage patterns
  • Service port 8001, route prefix /api/preferences-api
  • Config structure (already has Database config)

Data Model Changes

Domain Model

// internal/domain/preference.go

// UserPreferences represents a user's stored preferences.
type UserPreferences struct {
    UserID      string
    Preferences map[string]any
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

No strongly-typed preference keys in the domain model—preferences are stored as map[string]any to support extensibility (unknown keys accepted per spec). Validation of known keys happens in the service layer.

Database Schema

-- migrations/001_create_preferences.sql
CREATE TABLE IF NOT EXISTS 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()
);

CREATE INDEX idx_preferences_updated_at ON preferences (updated_at);

Single table, UUID primary key, JSONB column for flexible key-value storage. No foreign keys (user_id is treated as an opaque UUID per spec; no cross-service validation).

API Changes

Remove Example Endpoints

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

Add Preference Endpoints

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

  • Validates user_id is a valid UUID; returns 400 if not
  • Returns 200 with {data, meta} envelope containing user preferences
  • Returns 200 with empty preferences object {} when no row exists (not 404)

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

  • Validates user_id is a valid UUID; returns 400 if not
  • Binds and validates request body with app.Bind()
  • Service layer validates known preference keys against allowed values
  • Upserts preferences row (merge incoming preferences with existing ones)
  • Returns 200 with updated preferences in {data, meta} envelope
  • Returns 400 with details when validation fails

Request/Response Shapes

These match the spec exactly. See spec.md for full JSON examples.

GET response data:

{
    "user_id": "uuid",
    "preferences": {"theme": "dark", ...},
    "updated_at": "2026-02-09T12:00:00Z"
}

PUT request body:

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

PUT response data: Same shape as GET response data.

Validation error:

{
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "Invalid preference values",
        "details": {"theme": "must be one of: light, dark, system"}
    }
}

Component Diagram

                         ┌──────────────────────────────────────┐
                         │           HTTP Client                │
                         └───────┬─────────────┬────────────────┘
                                 │             │
                          GET /preferences  PUT /preferences
                            /{user_id}      /{user_id}
                                 │             │
                         ┌───────▼─────────────▼────────────────┐
                         │      Preference Handler              │
                         │  (UUID validation, request binding,  │
                         │   domain error mapping)              │
                         └───────┬─────────────┬────────────────┘
                                 │             │
                         ┌───────▼─────────────▼────────────────┐
                         │      PreferenceService               │
                         │  (known-key validation, upsert       │
                         │   orchestration, logging)            │
                         └───────┬─────────────┬────────────────┘
                                 │             │
                         ┌───────▼─────────────▼────────────────┐
                         │  <<interface>> PreferenceRepository  │
                         │      Get(ctx, userID)                │
                         │      Upsert(ctx, prefs)              │
                         └───────┬─────────────┬────────────────┘
                                 │             │
                    ┌────────────▼──┐   ┌──────▼─────────────┐
                    │  PostgreSQL   │   │  In-Memory (tests)  │
                    │  Adapter      │   │  Mock               │
                    └──────┬────────┘   └────────────────────┘
                           │
                    ┌──────▼────────┐
                    │  PostgreSQL   │
                    │  preferences  │
                    │  table (JSONB)│
                    └───────────────┘

Detailed Layer Design

Domain Layer (internal/domain/preference.go)

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

Domain errors in internal/domain/errors.go:

var (
    ErrInvalidUserID          = errors.New("invalid user ID")
    ErrInvalidPreferenceValue = errors.New("invalid preference value")
)

The domain model is intentionally simple. Preferences are an opaque map; validation of known keys is a business rule in the service layer, not an invariant of the domain entity.

Port Layer (internal/port/preference.go)

type PreferenceRepository interface {
    // Get returns preferences for a user.
    // Returns nil UserPreferences (not error) when no row exists.
    Get(ctx context.Context, userID string) (*domain.UserPreferences, error)

    // Upsert creates or updates preferences for a user.
    // Uses ON CONFLICT to handle both insert and update atomically.
    Upsert(ctx context.Context, prefs *domain.UserPreferences) error
}

Key design decision: Get returns nil, nil for a non-existent user rather than an error. The handler converts this to a default empty-preferences response. This avoids a "not found" error that would be misleading (the spec says return 200 with empty preferences).

Service Layer (internal/service/preference.go)

type PreferenceService struct {
    repo   port.PreferenceRepository
    logger *logging.Logger
}

type UpsertInput struct {
    UserID      string
    Preferences map[string]any
}

func (s *PreferenceService) Get(ctx, userID) (*domain.UserPreferences, error)
func (s *PreferenceService) Upsert(ctx, input UpsertInput) (*domain.UserPreferences, error)

Validation logic in Upsert:

  1. Iterate over input preferences keys
  2. For known keys, validate values:
    • theme: must be "light", "dark", or "system"
    • language: must be a valid BCP-47 tag (validate with language.Parse from golang.org/x/text/language)
    • notifications_enabled: must be a boolean
  3. Unknown keys: accept any JSON value (no validation)
  4. If validation errors exist, return a structured error with per-field details
  5. If valid: fetch existing preferences, merge incoming preferences on top, upsert

Merge strategy: The PUT replaces only the keys provided. Existing keys not included in the request body remain unchanged. This gives partial-update semantics on the preference map even though the endpoint is PUT. This matches the spec's upsert behavior.

Adapter Layer (internal/adapter/postgres/preference.go)

type PreferenceRepository struct {
    db *sqlx.DB
}

func (r *PreferenceRepository) Get(ctx, userID) (*domain.UserPreferences, error)
func (r *PreferenceRepository) Upsert(ctx, prefs *domain.UserPreferences) error

Get query:

SELECT user_id, preferences, created_at, updated_at
FROM preferences
WHERE user_id = $1

Returns nil, nil if no row found (sql.ErrNoRows → return nil).

Upsert query:

INSERT INTO preferences (user_id, preferences, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE
SET preferences = $2, updated_at = NOW()
RETURNING user_id, preferences, created_at, updated_at

The JSONB value stored is the complete merged preference map (merging happens in the service layer before calling Upsert). The adapter stores whatever the service gives it.

Handler Layer (internal/api/handlers/preference.go)

type Preference struct {
    svc    *service.PreferenceService
    logger *logging.Logger
}

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

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

GET handler flow:

  1. Extract user_id from URL with chi.URLParam(r, "user_id")
  2. Validate UUID with uuid.Parse(userID)httperror.BadRequest on failure
  3. Call svc.Get(ctx, userID)
  4. If nil result (no preferences), return httpresponse.OK with empty defaults
  5. If result exists, return httpresponse.OK with mapped response

PUT handler flow:

  1. Extract and validate user_id (same as GET)
  2. Bind request body with app.Bind(r, &req) (not BindAndValidate—custom validation in service)
  3. Validate req.Preferences is not nil → httperror.BadRequest if missing
  4. Call svc.Upsert(ctx, input)
  5. Map domain errors → httperror.BadRequest with details for validation errors
  6. Return httpresponse.OK with mapped response

Domain error mapping:

func mapDomainError(err error) error {
    switch {
    case errors.Is(err, domain.ErrInvalidPreferenceValue):
        // Extract details from the error for the response
        var valErr *service.ValidationError
        if errors.As(err, &valErr) {
            return httperror.WithDetails(
                httperror.Validation("Invalid preference values"),
                valErr.Details,
            )
        }
        return httperror.BadRequest("invalid preference value")
    default:
        return err
    }
}

Routes (internal/api/routes.go)

func RegisterRoutes(application *app.App, prefService *service.PreferenceService) {
    prefHandler := handlers.NewPreference(prefService, logger)

    application.Route("/api/preferences-api", func(r app.Router) {
        r.Get("/health", healthHandler.Check)
        r.Get("/preferences/{user_id}", app.Wrap(prefHandler.Get))
        r.Put("/preferences/{user_id}", app.Wrap(prefHandler.Upsert))
    })
}

Both GET and PUT are public routes (auth out of scope per spec). Auth middleware can be layered on later via route groups.

OpenAPI Spec (internal/api/spec.go)

Updated to document preference schemas and endpoints:

  • Schema: UserPreferences (user_id, preferences object, updated_at)
  • Schema: UpdatePreferencesRequest (preferences object)
  • Path: GET /api/preferences-api/preferences/{user_id}
  • Path: PUT /api/preferences-api/preferences/{user_id}
  • Removes all example-related schemas and paths

Main (cmd/server/main.go)

Updated wiring:

  1. Load config (database URL)
  2. Connect to PostgreSQL via database.MustConnect
  3. Run migrations via database.MustRunMigrations
  4. Create postgres.NewPreferenceRepository(pool.DB)
  5. Create service.NewPreferenceService(repo, logger)
  6. Register routes with preference service
  7. Defer pool.Close()

Error Handling Strategy

Scenario Error Source HTTP Response
Invalid UUID in path Handler (uuid.Parse) 400 Bad Request
Missing request body Handler (app.Bind) 400 Bad Request
Missing preferences field Handler (nil check) 400 Bad Request
Invalid theme value Service validation 400 Validation Error with details
Invalid language tag Service validation 400 Validation Error with details
Invalid notifications_enabled Service validation 400 Validation Error with details
Multiple validation errors Service validation 400 Validation Error with all details
User has no preferences Repository returns nil 200 with empty preferences {}
Database connection failure Adapter (sqlx) 500 Internal Server Error
Database query error Adapter (sqlx) 500 Internal Server Error

Validation error structure for service layer:

A custom ValidationError type wraps domain.ErrInvalidPreferenceValue and carries a Details map[string]string with per-field error messages. The handler maps this to an httperror with details.

// service/preference.go
type ValidationError struct {
    Details map[string]string
}

func (e *ValidationError) Error() string { return "invalid preference values" }
func (e *ValidationError) Unwrap() error { return domain.ErrInvalidPreferenceValue }

Security Considerations

  1. Authentication: Auth is out of scope per spec. Routes are public. Auth middleware can be added later by wrapping the PUT route in an auth group (pattern already exists in routes.go scaffolding).

  2. Input validation:

    • UUID format validated at handler level (prevents SQL injection via path parameter)
    • Request body parsed via app.Bind() (standard JSON decoder, no raw input)
    • Known preference values validated in service layer against allowlists
    • JSONB storage naturally handles JSON escaping
  3. Data boundaries:

    • No cross-user data access patterns exist (each request operates on a single user_id)
    • No sensitive data in preferences (theme, language, notification flag)
    • No PII beyond the user_id UUID itself
  4. Injection prevention:

    • All database queries use parameterized queries ($1, $2 placeholders)
    • JSONB values marshaled through Go's encoding/json, not string concatenation
  5. Size limits: Spec open question #4 asks about value size limits. For initial implementation, rely on PostgreSQL's built-in JSONB limits (255 MB per column). Add application-level size limits in a follow-up if needed (e.g., max 50 keys, max 1KB per value).

Performance Considerations

  1. Query complexity: Both queries are single-row operations on a primary key (UUID). O(1) index lookup. No joins, no scans.

  2. Expected load: Preferences are read frequently (every page load) and written infrequently (settings changes). Read-heavy workload.

  3. Caching strategy: Not needed for initial implementation. The query is a simple primary key lookup—fast at the database level. If needed later, add a cache layer behind the port interface without changing the service.

  4. JSONB performance: JSONB is stored in a decomposed binary format; reads are fast. We don't query individual keys within the JSONB column—always read/write the full object.

  5. Connection pooling: pkg/database provides connection pooling (default 25 open, 5 idle). Adequate for expected load.

  6. Index: The updated_at index supports future analytics or cleanup queries but is not used by the GET/PUT operations. The primary key index is sufficient.

Migration / Rollout Plan

Phase 1: Database Migration

  • Add 001_create_preferences.sql to migrations/ directory
  • Migration runs automatically on service startup via MustRunMigrations
  • Non-destructive: creates a new table, does not modify existing tables
  • Idempotent: uses CREATE TABLE IF NOT EXISTS

Phase 2: Code Replacement

  • Remove example domain, service, port, adapter, handler, and test files
  • Add preference domain, service, port, adapter, handler, and test files
  • Update routes.go to register preference endpoints
  • Update spec.go for preference OpenAPI documentation
  • Update main.go to wire PostgreSQL adapter and run migrations

Phase 3: Validation

  • Run unit tests: handler tests with mock repository, service tests with mock repository
  • Run integration tests manually against local PostgreSQL (via docker compose)
  • Verify OpenAPI spec renders correctly via Scalar docs UI

Rollback

  • Since the preferences table is new (no data migration), rollback is straightforward: revert to previous code and the table is unused
  • The schema_migrations table tracks applied migrations; if needed, manually remove the entry and drop the table

Open Questions Resolution (Design Decisions)

  1. Unknown key value-type validation: Accept any valid JSON value. No type restriction. This keeps the system maximally extensible.
  2. Max preference keys: No limit in initial implementation. PostgreSQL JSONB handles large objects well. Add limit if abuse observed.
  3. User existence validation: No cross-service validation. Treat user_id as opaque UUID. Any valid UUID can have preferences.
  4. Value size limits: Rely on PostgreSQL limits initially. Monitor and add application limits if needed.

Files Changed Summary

File Action Description
internal/domain/preference.go Create UserPreferences model
internal/domain/errors.go Modify Replace example errors with preference errors
internal/port/preference.go Create PreferenceRepository interface
internal/service/preference.go Create PreferenceService with validation
internal/service/preference_test.go Create Service-layer tests
internal/adapter/postgres/preference.go Create PostgreSQL adapter
internal/api/handlers/preference.go Create GET/PUT handlers
internal/api/handlers/preference_test.go Create Handler tests
internal/api/routes.go Modify Replace example routes with preference routes
internal/api/spec.go Modify Replace example spec with preference spec
cmd/server/main.go Modify Wire PostgreSQL, migrations, preference service
migrations/001_create_preferences.sql Create Preferences table DDL
internal/domain/example.go Delete No longer needed
internal/service/example.go Delete No longer needed
internal/service/example_test.go Delete No longer needed
internal/port/example.go Delete No longer needed
internal/adapter/memory/example.go Delete No longer needed
internal/api/handlers/example.go Delete No longer needed
internal/api/handlers/example_test.go Delete No longer needed