slack5-1770541397/.sdlc/features/user-preferences/design.md
rdev-worker 100b3c4035
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /design-feature user-preferences
2026-02-08 09:12:44 +00:00

25 KiB

Design: User Preferences API

Architecture Approach

The feature replaces the existing example/scaffold CRUD resource in preferences-api with a real user preferences domain. The hexagonal architecture already in place is preserved — only the inner layers change.

What changes:

  • Domain layer — New UserPreferences entity with validation, replacing Example
  • Port layer — New PreferencesRepository interface, replacing ExampleRepository
  • Service layer — New PreferencesService with get/upsert logic, replacing ExampleService
  • Adapter layer — New PostgreSQL adapter (replacing in-memory Example adapter)
  • Handler layer — Two new handlers (GET, PUT), replacing five example handlers
  • Routes — New authenticated route group at /api/preferences-api/preferences/{user_id}
  • OpenAPI spec — Updated with preferences schemas and endpoints
  • Migrations — New SQL migration for user_preferences table
  • main.go — Updated to wire database pool and new dependencies

What is removed:

  • All example domain, port, service, adapter, handler, and test code
  • The in-memory adapter (production uses PostgreSQL)

What is unchanged:

  • Health check handler and route
  • config/config.go (already supports DATABASE_URL, AUTH_ENABLED, JWT_SECRET)
  • Dockerfile, Makefile, component.yaml, go.mod structure

Data Model Changes

New Domain Types

// domain/preferences.go

type UserID string

type NotificationPreferences struct {
    Email bool
    Push  bool
    SMS   bool
}

type Preferences struct {
    Theme         string
    Language      string
    Notifications NotificationPreferences
}

type UserPreferences struct {
    UserID      UserID
    Preferences Preferences
    UpdatedAt   time.Time
}

Default Values

When no preferences exist for a user, the service returns defaults:

Key Default
theme "system"
language "en"
notifications.email true
notifications.push true
notifications.sms false

Defaults are defined as a function in the domain layer (DefaultPreferences()) — the single source of truth.

Database Schema

Table: user_preferences

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

Design decisions:

  • user_id as TEXT PRIMARY KEY — no UUID type constraint; IDs come from the auth system
  • preferences as JSONB — single document per user, supporting the spec's extensibility requirement (unknown keys preserved)
  • created_at included for operational debugging even though it's not exposed in the API
  • No foreign key to a users table — preferences-api is a standalone service

Migration File

migrations/001_create_user_preferences.sql

Single idempotent migration using IF NOT EXISTS. Embedded via //go:embed per project convention.

API Changes

Endpoints

Method Path Auth Description
GET /api/preferences-api/preferences/{user_id} Required Get preferences (returns defaults if none saved)
PUT /api/preferences-api/preferences/{user_id} Required Create or replace preferences

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

Authorization: Authenticated user's ID must match {user_id}, else 403.

Behavior:

  1. Extract user_id from URL path
  2. Verify authenticated user matches user_id
  3. Query database for preferences
  4. If no row exists, return default preferences
  5. Return response with {data, meta} envelope

Response (200):

{
  "data": {
    "user_id": "usr_abc123",
    "preferences": {
      "theme": "dark",
      "language": "en",
      "notifications": {
        "email": true,
        "push": true,
        "sms": false
      }
    },
    "updated_at": "2026-02-08T10:30:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}

When no preferences saved (200 with defaults):

{
  "data": {
    "user_id": "usr_abc123",
    "preferences": {
      "theme": "system",
      "language": "en",
      "notifications": {
        "email": true,
        "push": true,
        "sms": false
      }
    },
    "updated_at": "0001-01-01T00:00:00Z"
  },
  "meta": { ... }
}

The updated_at zero value signals "never saved". Alternatively, it could be omitted when returning defaults — but including it keeps the response shape consistent.

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

Authorization: Authenticated user's ID must match {user_id}, else 403.

Behavior:

  1. Extract user_id from URL path
  2. Verify authenticated user matches user_id
  3. Bind and validate request body
  4. Run domain validation on known keys
  5. Upsert into database (INSERT ON CONFLICT UPDATE)
  6. Return saved preferences with {data, meta} envelope

Request body:

{
  "preferences": {
    "theme": "dark",
    "language": "en",
    "notifications": {
      "email": true,
      "push": true,
      "sms": false
    }
  }
}

Validation rules (domain layer):

  • theme: Must be one of "light", "dark", "system" — if present
  • language: Max 10 characters — if present
  • Unknown top-level keys in preferences: preserved (per spec extensibility requirement)
  • notifications sub-keys: booleans, no special validation needed (Go zero-value is false)

Response (200): Same shape as GET response, with the just-saved data and current timestamp.

Error (400):

{
  "error": {
    "code": "BAD_REQUEST",
    "message": "invalid theme: must be one of light, dark, system"
  },
  "meta": { ... }
}

Component Diagram

┌──────────────────────────────────────────────────────────────────┐
│                        HTTP Layer                                │
│                                                                  │
│  auth.Middleware() ──▶ handlers.Preferences                      │
│                         │                                        │
│  GET  /preferences/{user_id}  ──▶ Get()  ──▶ httpresponse.OK()  │
│  PUT  /preferences/{user_id}  ──▶ Put()  ──▶ httpresponse.OK()  │
│                         │                                        │
│              mapDomainError()  ──▶ httperror.*                   │
└────────────────┬─────────────────────────────────────────────────┘
                 │
┌────────────────▼─────────────────────────────────────────────────┐
│                    Service Layer                                  │
│                                                                  │
│  PreferencesService                                              │
│    ├── GetPreferences(ctx, userID)  → *UserPreferences, error    │
│    │     └── returns defaults if repo returns ErrNotFound         │
│    └── SetPreferences(ctx, userID, prefs) → *UserPreferences, err│
│          └── validates, then upserts via repo                    │
└────────────────┬─────────────────────────────────────────────────┘
                 │
┌────────────────▼─────────────────────────────────────────────────┐
│                     Port Layer (Interface)                        │
│                                                                  │
│  PreferencesRepository                                           │
│    ├── Get(ctx, userID)  → *UserPreferences, error               │
│    └── Upsert(ctx, prefs *UserPreferences) → error               │
└────────────────┬─────────────────────────────────────────────────┘
                 │
┌────────────────▼─────────────────────────────────────────────────┐
│                   Adapter Layer (PostgreSQL)                      │
│                                                                  │
│  postgres.PreferencesRepository                                  │
│    ├── Get()   → SELECT ... WHERE user_id = $1                   │
│    └── Upsert() → INSERT ... ON CONFLICT (user_id)               │
│                   DO UPDATE SET preferences = $2, updated_at = $3│
│                                                                  │
│  Uses: database.Pool.DB (*sqlx.DB)                               │
└────────────────┬─────────────────────────────────────────────────┘
                 │
┌────────────────▼─────────────────────────────────────────────────┐
│                     Domain Layer (Pure)                           │
│                                                                  │
│  UserPreferences, Preferences, NotificationPreferences           │
│  DefaultPreferences()                                            │
│  Validate() → error                                              │
│  ErrInvalidTheme, ErrInvalidLanguage, ErrForbidden               │
└──────────────────────────────────────────────────────────────────┘

Layer-by-Layer Implementation Details

Domain (internal/domain/)

Files to create:

  • preferences.go — Types, constructors, DefaultPreferences(), Validate()
  • errors.go — Updated with ErrInvalidTheme, ErrInvalidLanguage, ErrForbidden, ErrPreferencesNotFound

Files to delete:

  • example.go

Validation logic in Preferences.Validate():

func (p *Preferences) Validate() error {
    if p.Theme != "" {
        switch p.Theme {
        case "light", "dark", "system":
            // valid
        default:
            return ErrInvalidTheme
        }
    }
    if len([]rune(p.Language)) > 10 {
        return ErrInvalidLanguage
    }
    return nil
}

Unknown keys: The spec says unknown keys are preserved but not validated. Since we store the full JSON document in a JSONB column, unknown keys survive naturally. The Preferences struct uses a map for extensibility:

type Preferences struct {
    Theme         string                  `json:"theme"`
    Language      string                  `json:"language"`
    Notifications NotificationPreferences `json:"notifications"`
    Extra         map[string]any          `json:"-"` // captured via custom marshal/unmarshal
}

A custom UnmarshalJSON/MarshalJSON pair on Preferences decodes known fields into struct fields and captures everything else into Extra. On marshal, known fields and Extra are merged back. This preserves unknown keys through the round-trip without schema migrations.

Port (internal/port/)

Files to create:

  • preferences.goPreferencesRepository interface

Files to delete:

  • example.go
type PreferencesRepository interface {
    Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error)
    Upsert(ctx context.Context, prefs *domain.UserPreferences) error
}

Only two methods needed — no List, Delete, or ExistsByName. The simple interface keeps the adapter thin.

Service (internal/service/)

Files to create:

  • preferences.goPreferencesService
  • preferences_test.go — Unit tests

Files to delete:

  • example.go
  • example_test.go
type PreferencesService struct {
    repo   port.PreferencesRepository
    logger *logging.Logger
}

func (s *PreferencesService) GetPreferences(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error)
func (s *PreferencesService) SetPreferences(ctx context.Context, userID domain.UserID, prefs domain.Preferences) (*domain.UserPreferences, error)

GetPreferences logic:

  1. Call repo.Get(ctx, userID)
  2. If ErrPreferencesNotFound, return DefaultPreferences() with the given userID
  3. Otherwise return the stored preferences

SetPreferences logic:

  1. Call prefs.Validate() — return domain error if invalid
  2. Build UserPreferences{UserID: userID, Preferences: prefs, UpdatedAt: time.Now().UTC()}
  3. Call repo.Upsert(ctx, &userPrefs)
  4. Return the saved preferences

Authorization (checking user_id matches authenticated user) is done in the handler layer, not here — the service layer doesn't know about HTTP or JWT. This follows the existing pattern where mapDomainError() in handlers maps domain errors to HTTP errors.

Adapter (internal/adapter/postgres/)

Files to create:

  • preferences.go — PostgreSQL implementation of PreferencesRepository

Files to delete:

  • adapter/memory/example.go
type PreferencesRepository struct {
    db     *sqlx.DB
    logger *logging.Logger
}

func (r *PreferencesRepository) Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) {
    // SELECT user_id, preferences, updated_at FROM user_preferences WHERE user_id = $1
    // If no rows: return domain.ErrPreferencesNotFound
    // Unmarshal JSONB into domain.Preferences
}

func (r *PreferencesRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error {
    // INSERT INTO user_preferences (user_id, preferences, updated_at)
    // VALUES ($1, $2, $3)
    // ON CONFLICT (user_id) DO UPDATE SET preferences = $2, updated_at = $3
    // Marshal domain.Preferences to JSON for JSONB column
}

Handlers (internal/api/handlers/)

Files to create:

  • preferences.go — GET and PUT handlers
  • preferences_test.go — Handler tests

Files to delete:

  • example.go
  • example_test.go
type Preferences struct {
    svc    *service.PreferencesService
    logger *logging.Logger
}

Request/Response types:

type PutPreferencesRequest struct {
    Preferences PreferencesPayload `json:"preferences" validate:"required"`
}

type PreferencesPayload struct {
    Theme         string                      `json:"theme,omitempty"`
    Language      string                      `json:"language,omitempty"`
    Notifications *NotificationPreferencesPayload `json:"notifications,omitempty"`
}

type NotificationPreferencesPayload struct {
    Email bool `json:"email"`
    Push  bool `json:"push"`
    SMS   bool `json:"sms"`
}

type PreferencesResponse struct {
    UserID      string             `json:"user_id"`
    Preferences PreferencesPayload `json:"preferences"`
    UpdatedAt   string             `json:"updated_at"`
}

Handler: Get

func (h *Preferences) Get(w http.ResponseWriter, r *http.Request) error {
    userID := chi.URLParam(r, "user_id")

    // Authorization check
    authUser := auth.GetUser(r.Context())
    if authUser.ID != userID {
        return httperror.Forbidden("access denied: can only access own preferences")
    }

    prefs, err := h.svc.GetPreferences(r.Context(), domain.UserID(userID))
    if err != nil {
        return mapDomainError(err)
    }

    return httpresponse.OK(w, r, toResponse(prefs))
}

Handler: Put

func (h *Preferences) Put(w http.ResponseWriter, r *http.Request) error {
    userID := chi.URLParam(r, "user_id")

    // Authorization check
    authUser := auth.GetUser(r.Context())
    if authUser.ID != userID {
        return httperror.Forbidden("access denied: can only modify own preferences")
    }

    var req PutPreferencesRequest
    if err := app.BindAndValidate(r, &req); err != nil {
        return err
    }

    prefs, err := h.svc.SetPreferences(r.Context(), domain.UserID(userID), toDomain(req.Preferences))
    if err != nil {
        return mapDomainError(err)
    }

    return httpresponse.OK(w, r, toResponse(prefs))
}

Error mapping:

func mapDomainError(err error) error {
    switch {
    case errors.Is(err, domain.ErrInvalidTheme):
        return httperror.BadRequest("invalid theme: must be one of light, dark, system")
    case errors.Is(err, domain.ErrInvalidLanguage):
        return httperror.BadRequest("invalid language: must be at most 10 characters")
    default:
        return err // app.Wrap() will handle as 500
    }
}

Routes (internal/api/routes.go)

Replace example routes with:

func RegisterRoutes(application *app.App, prefsSvc *service.PreferencesService, authCfg config.Config) {
    logger := application.Logger()

    healthHandler := &handlers.Health{Logger: logger}
    prefsHandler := handlers.NewPreferences(prefsSvc, logger)

    r := application.Router()

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

    // Protected routes — auth required for all preference endpoints
    r.Route("/api/preferences-api/preferences", func(r chi.Router) {
        if authCfg.AuthEnabled {
            r.Use(auth.Middleware(auth.MiddlewareConfig{
                Validator: auth.NewJWTValidator(auth.JWTConfig{
                    Secret: []byte(authCfg.JWTSecret),
                }),
            }))
        }
        r.Get("/{user_id}", app.Wrap(prefsHandler.Get))
        r.Put("/{user_id}", app.Wrap(prefsHandler.Put))
    })
}

OpenAPI Spec (internal/api/spec.go)

Update to define:

  • Schema: Preferences (theme, language, notifications)
  • Schema: NotificationPreferences (email, push, sms)
  • Schema: UserPreferencesResponse (user_id, preferences, updated_at)
  • Schema: PutPreferencesRequest (preferences object)
  • Path: GET /api/preferences-api/preferences/{user_id} with bearer auth, 200/403 responses
  • Path: PUT /api/preferences-api/preferences/{user_id} with bearer auth, 200/400/403 responses
  • {user_id} path parameter

main.go (cmd/server/main.go)

Update to:

  1. Connect to PostgreSQL using database.Connect()
  2. Run migrations using database.MustRunMigrations()
  3. Create postgres.PreferencesRepository with pool.DB
  4. Create PreferencesService with the repo
  5. Register routes with service and auth config
  6. Register pool.Close() on shutdown

Error Handling Strategy

Scenario Layer Error HTTP Status
Invalid theme value Domain ErrInvalidTheme 400 Bad Request
Language too long Domain ErrInvalidLanguage 400 Bad Request
Malformed JSON body Handler (BindAndValidate) Automatic 400 Bad Request
Missing preferences field Handler (BindAndValidate) Validation 400 Bad Request
User accessing another user's prefs Handler httperror.Forbidden 403 Forbidden
No preferences saved yet Service (returns defaults) 200 OK
Database connection failure Adapter raw error 500 Internal
Database query failure Adapter raw error 500 Internal

Key decisions:

  • GET never returns 404 — missing preferences yield defaults. This simplifies the frontend (no special "first time" flow).
  • Authorization is checked in handlers before any service call, failing fast with 403.
  • Domain validation errors are specific and mapped to descriptive 400 messages.
  • Database errors bubble up as raw errors, caught by app.Wrap() and returned as 500 with the error logged server-side (not leaked to client).

Security Considerations

Authentication

  • Both endpoints require auth.Middleware(). Unauthenticated requests receive 401.
  • JWT validation via pkg/auth.NewJWTValidator with HMAC secret from config.

Authorization

  • Owner-only access: The {user_id} in the URL path must match auth.GetUser(ctx).ID. This is checked in the handler before calling the service layer.
  • No admin override endpoint (out of scope per spec).

Input Validation

  • Request body bound and validated via app.BindAndValidate() — rejects malformed JSON and missing required fields.
  • Domain-level validation for theme (enum) and language (max length).
  • JSONB column stores raw preferences — unknown keys preserved but size is bounded by PostgreSQL's TOAST limit (~1GB). For practical limits, the handler can check Content-Length against a reasonable threshold (e.g., 64KB). This addresses the spec's open question about preference size limits.

Data Boundaries

  • Users can only read/write their own preferences — no cross-user data access.
  • Error responses never leak internal details (database errors, stack traces).
  • The preferences JSONB column is treated as opaque by the database — no SQL injection vector.

SQL Injection

  • All queries use parameterized statements ($1, $2) via sqlx — no string concatenation.

Performance Considerations

Expected Load

  • Read-heavy workload: preferences fetched on every page load / session start.
  • Writes are infrequent: users change preferences rarely.

Query Performance

  • GET: Single-row lookup by primary key (user_id) — O(1) with B-tree index.
  • PUT: Upsert by primary key — O(1).
  • No need for additional indexes. The primary key index is sufficient.

Caching Strategy

  • Not implemented in this iteration (out of scope). The single-row PK lookup is fast enough.
  • If needed later: HTTP Cache-Control headers or an in-process cache with short TTL.

Connection Pooling

  • Uses database.Pool with configurable pool size (default: 25 open, 5 idle). Adequate for preferences traffic.

Payload Size

  • Preferences JSON is small (< 1KB typical). No pagination or streaming needed.

Migration / Rollout Plan

Step 1: Database Migration

The 001_create_user_preferences.sql migration runs on startup via database.MustRunMigrations(). It uses CREATE TABLE IF NOT EXISTS for idempotency. No existing tables are modified or dropped.

Step 2: Code Deployment

The service is fully backward-compatible at the infrastructure level:

  • Same port (8001)
  • Same health check path (/api/preferences-api/health)
  • Example endpoints are removed, but nothing depends on them (they're scaffold)

Step 3: Verification

  • Health check confirms service starts and database is reachable
  • GET returns default preferences for any authenticated user (no data seeding needed)
  • PUT creates preferences on first save

Rollback

  • Revert to previous deployment. The user_preferences table can remain — it won't interfere with the example scaffold code.
  • No destructive migrations — forward-only table creation.

Open Questions Resolution

From the spec:

  1. Authorization model: Design uses auth.GetUser(ctx).ID — the User.ID field populated by JWT validation. This maps to the sub claim or uid custom claim (both supported by pkg/auth.JWTClaims). No changes to auth package needed.

  2. Unknown preference keys: Preserved via custom JSON marshaling on the Preferences struct. Known keys are validated; unknown keys pass through to JSONB storage unchanged.

  3. Preference size limit: Addressed by checking request Content-Length in the handler (64KB default). This prevents abuse without requiring schema changes.

File Change Summary

Action File
Create migrations/001_create_user_preferences.sql
Create internal/domain/preferences.go
Create internal/port/preferences.go
Create internal/service/preferences.go
Create internal/service/preferences_test.go
Create internal/adapter/postgres/preferences.go
Create internal/api/handlers/preferences.go
Create internal/api/handlers/preferences_test.go
Modify internal/domain/errors.go
Modify internal/api/routes.go
Modify internal/api/spec.go
Modify cmd/server/main.go
Delete internal/domain/example.go
Delete internal/port/example.go
Delete internal/service/example.go
Delete internal/service/example_test.go
Delete internal/adapter/memory/example.go
Delete internal/api/handlers/example.go
Delete internal/api/handlers/example_test.go