slack5-1770603014/.sdlc/features/user-preferences/design.md
rdev-worker a399ce7510
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /design-feature user-preferences
2026-02-09 02:23:42 +00:00

17 KiB

Design: User Preferences API

Architecture Approach

Replace the example CRUD scaffolding in services/preferences-api with a real user preferences domain. All six layers of the hexagonal architecture change:

Layer What changes
Domain Replace Example entity with UserPreferences entity. New validation for theme, language, notification fields.
Port Replace ExampleRepository with PreferencesRepository (2 methods: Get, Upsert).
Adapter Add internal/adapter/postgres/ with a JSONB-backed PostgreSQL implementation. Remove internal/adapter/memory/.
Service Replace ExampleService with PreferencesService (2 use cases: GetPreferences, UpdatePreferences).
Handlers Replace example CRUD handlers with GET /preferences/{user_id} and PUT /preferences/{user_id}.
API Update routes and OpenAPI spec. Remove all example endpoint definitions.

The existing main.go wiring, config, and health handler remain. main.go changes to connect to PostgreSQL (via pkg/database) and run migrations on startup.

Design Decisions

  1. Return defaults for unknown users (200, not 404): Simpler frontend DX. The service returns a default UserPreferences struct when no row exists.
  2. Reject unknown preference keys: Use app.BindAndValidateStrict() to reject unknown JSON fields. This catches typos and prevents silent data loss. Forward compatibility can be added later when new keys are defined.
  3. Accept any valid UUID for user_id: No inter-service call to validate user existence. The preferences service is a simple key-value store keyed by UUID. This avoids coupling and latency.
  4. JSONB for preferences storage: Single preferences JSONB column for the nested preference object. One row per user. Flexible schema that doesn't require migrations when adding new preference keys in the future.
  5. Deep merge on PUT: The service performs a deep merge of the incoming JSON with existing preferences. Keys not included in the request body remain unchanged. Nested objects (like notifications) are merged recursively, not replaced wholesale.

Data Model Changes

New Table: user_preferences

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

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

Domain Types

// domain/preferences.go

type UserID string

type Preferences struct {
    Theme         string                `json:"theme"`
    Language      string                `json:"language"`
    Notifications NotificationSettings  `json:"notifications"`
}

type NotificationSettings struct {
    Email  bool   `json:"email"`
    Push   bool   `json:"push"`
    Digest string `json:"digest"`
}

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

Default Values

func DefaultPreferences() Preferences {
    return Preferences{
        Theme:    "system",
        Language: "en",
        Notifications: NotificationSettings{
            Email:  true,
            Push:   true,
            Digest: "weekly",
        },
    }
}

Domain Validation

Validation lives in the domain layer, called by the service layer before persistence:

func (p *Preferences) Validate() error { ... }
Field Rule Error
theme Must be "light", "dark", or "system" ErrInvalidTheme
language Must be non-empty string ErrInvalidLanguage
notifications.email Boolean (validated by JSON binding) N/A
notifications.push Boolean (validated by JSON binding) N/A
notifications.digest Must be "daily", "weekly", or "never" ErrInvalidDigest

API Changes

Endpoints

All routes mounted under /api/preferences-api.

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

Retrieve preferences for a user. Returns defaults if no preferences are stored.

Path Parameter:

  • user_id (UUID, required) - Validated with uuid.Parse()

Response 200:

{
  "data": {
    "user_id": "550e8400-e29b-41d4-a716-446655440000",
    "preferences": {
      "theme": "dark",
      "language": "en",
      "notifications": {
        "email": true,
        "push": false,
        "digest": "weekly"
      }
    },
    "updated_at": "2026-02-09T12:00:00Z"
  },
  "meta": {
    "request_id": "...",
    "timestamp": "..."
  }
}

Response 400: Invalid user_id format.

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

Create or update preferences (upsert with deep merge).

Path Parameter:

  • user_id (UUID, required)

Request Body:

{
  "preferences": {
    "theme": "light",
    "notifications": {
      "push": true
    }
  }
}

Only provided keys are changed. Omitted keys retain their current value (or default if no row exists).

Response 200: Full merged preference set after update.

Response 400: Invalid user_id or invalid preference values.

Request/Response Types (Handler Layer)

// UpdatePreferencesRequest is the PUT request body.
type UpdatePreferencesRequest struct {
    Preferences PreferencesInput `json:"preferences" validate:"required"`
}

// PreferencesInput uses pointers to distinguish "not provided" from zero values.
type PreferencesInput struct {
    Theme         *string              `json:"theme,omitempty"`
    Language      *string              `json:"language,omitempty"`
    Notifications *NotificationsInput  `json:"notifications,omitempty"`
}

type NotificationsInput struct {
    Email  *bool   `json:"email,omitempty"`
    Push   *bool   `json:"push,omitempty"`
    Digest *string `json:"digest,omitempty"`
}

// PreferencesResponse is the GET/PUT response shape.
type PreferencesResponse struct {
    UserID      string               `json:"user_id"`
    Preferences domain.Preferences   `json:"preferences"`
    UpdatedAt   string               `json:"updated_at"`
}

Component Diagram

┌─────────────────────────────────────────────────────────┐
│                     HTTP Client                         │
└────────────┬────────────────────────┬───────────────────┘
             │ GET /preferences/{id}  │ PUT /preferences/{id}
             ▼                        ▼
┌─────────────────────────────────────────────────────────┐
│  api/routes.go                                          │
│  ┌───────────────────────────────────────────────────┐  │
│  │  app.Wrap(handler.Get)   app.Wrap(handler.Upsert) │  │
│  └───────────────────────────────────────────────────┘  │
└────────────┬────────────────────────┬───────────────────┘
             │                        │
             ▼                        ▼
┌─────────────────────────────────────────────────────────┐
│  handlers/preferences.go                                │
│  - Validates user_id (UUID parse)                       │
│  - Binds & validates request body                       │
│  - Calls service layer                                  │
│  - Maps domain errors → httperror                       │
│  - Returns httpresponse.OK(w, r, response)              │
└────────────┬────────────────────────┬───────────────────┘
             │                        │
             ▼                        ▼
┌─────────────────────────────────────────────────────────┐
│  service/preferences.go                                 │
│  - GetPreferences: repo.Get → defaults if not found     │
│  - UpdatePreferences: repo.Get → merge → validate →     │
│    repo.Upsert                                          │
└────────────┬────────────────────────┬───────────────────┘
             │                        │
             ▼                        ▼
┌─────────────────────────────────────────────────────────┐
│  port/preferences.go (interface)                        │
│  - Get(ctx, userID) → (*UserPreferences, error)         │
│  - Upsert(ctx, *UserPreferences) → error                │
└────────────┬────────────────────────┬───────────────────┘
             │                        │
             ▼                        ▼
┌─────────────────────────────────────────────────────────┐
│  adapter/postgres/preferences.go                        │
│  - Get: SELECT ... WHERE user_id = $1                   │
│  - Upsert: INSERT ... ON CONFLICT (user_id) DO UPDATE   │
│  Uses *database.Pool (sqlx)                             │
└────────────┬────────────────────────┬───────────────────┘
             │                        │
             ▼                        ▼
┌─────────────────────────────────────────────────────────┐
│  PostgreSQL: user_preferences table                     │
│  (user_id UUID PK, preferences JSONB, timestamps)       │
└─────────────────────────────────────────────────────────┘

Error Handling Strategy

Domain Errors

var (
    ErrInvalidTheme    = errors.New("invalid theme: must be light, dark, or system")
    ErrInvalidLanguage = errors.New("invalid language: must be non-empty")
    ErrInvalidDigest   = errors.New("invalid digest: must be daily, weekly, or never")
)

Error Mapping (handler layer)

Domain Error HTTP Error Status
ErrInvalidTheme httperror.BadRequest(msg) 400
ErrInvalidLanguage httperror.BadRequest(msg) 400
ErrInvalidDigest httperror.BadRequest(msg) 400
Invalid UUID (user_id) httperror.BadRequest("invalid user_id format") 400
Request body parse error Handled by app.BindAndValidate() 400
Database connection error Unhandled → app.Wrap() returns 500 500

Key Behaviors

  • GET for unknown user: Returns 200 with default preferences (not 404). No error.
  • PUT with empty body: Returns 400 via app.BindAndValidate() (the preferences field is validate:"required").
  • PUT with partial preferences: Merges with existing. Only validates provided fields.
  • Database errors: Bubble up as raw errors. app.Wrap() converts them to 500.

Security Considerations

  • No authentication required for this feature (per spec: auth is out of scope). Routes are public. Auth middleware can be added later via route group.
  • User ID from URL path, not session: Any caller can read/write any user's preferences. This is intentional — the preferences service is a backend store, not a user-facing endpoint. Upstream services/gateways enforce authorization.
  • Input validation: All preference values are validated against allowlists. No arbitrary string storage.
  • SQL injection prevention: All queries use parameterized placeholders ($1, $2). JSONB values are marshaled by encoding/json and passed as parameters.
  • Request body size: Limited by the framework's default max body size.
  • No sensitive data: Preferences (theme, language, notifications) contain no PII or secrets.
  • Strict JSON binding: Unknown fields in the request body are rejected to prevent confusion.

Performance Considerations

  • Single row per user: O(1) lookup by UUID primary key. No joins, no pagination needed.
  • JSONB column: PostgreSQL JSONB is compact and efficient for reads. No need for GIN indexes — we query by user_id PK only, never by preference content.
  • No caching layer: For MVP, direct database reads are sufficient. The query is a simple PK lookup. If latency becomes an issue, an in-memory or Redis cache can be added as a separate adapter behind the same port interface.
  • Upsert atomicity: INSERT ... ON CONFLICT DO UPDATE is a single atomic statement. No race conditions on concurrent writes for the same user.
  • JSONB merge in application layer: The merge happens in Go, not in SQL. This keeps the SQL simple and the merge logic testable. The full merged JSONB is written back. For this data size (~200 bytes of JSON), this is efficient.
  • Expected load: Low. Preferences are read on session start and written on settings change. Well within single-instance PostgreSQL capacity.

Migration / Rollout Plan

Step 1: Remove Example Scaffolding

Delete all example-related files:

  • internal/domain/example.go
  • internal/port/example.go
  • internal/service/example.go, example_test.go
  • internal/api/handlers/example.go, example_test.go
  • internal/adapter/memory/example.go

Remove example routes and OpenAPI definitions from routes.go and spec.go.

Step 2: Add Preferences Domain

Create new files following the same directory structure:

  • internal/domain/preferences.go — entity, validation, defaults
  • internal/domain/errors.go — updated with preference-specific errors
  • internal/port/preferences.goPreferencesRepository interface
  • internal/service/preferences.goPreferencesService with GetPreferences and UpdatePreferences
  • internal/service/preferences_test.go — unit tests with mock repository
  • internal/api/handlers/preferences.go — HTTP handlers
  • internal/api/handlers/preferences_test.go — handler tests

Step 3: Add PostgreSQL Adapter

  • internal/adapter/postgres/preferences.go — implements PreferencesRepository
  • migrations/001_create_user_preferences.sql — table creation

Step 4: Update Wiring

  • internal/api/routes.go — register new preference routes
  • internal/api/spec.go — new OpenAPI definitions for preference endpoints
  • cmd/server/main.go — connect to PostgreSQL, run migrations, wire PostgreSQL adapter

Step 5: Verify

  • All unit tests pass (go test -v ./...)
  • OpenAPI spec exports correctly (--export-openapi flag)
  • Health endpoint still works
  • Manual verification against acceptance criteria

Backward Compatibility

This is a breaking replacement of the example scaffolding, which was never a production API. No backward compatibility is needed. The example endpoints (/examples, /examples/{id}) are removed entirely.

File Inventory

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