# Design: User Preferences API ## Architecture Approach This feature replaces the scaffold Example entity in `preferences-api` with a real User Preferences domain. The implementation follows the existing hexagonal architecture pattern exactly: - **Domain layer**: New `UserPreferences` entity with validation for known preference keys and values - **Port layer**: `PreferencesRepository` interface for persistence - **Adapter layer**: PostgreSQL repository implementation using `pkg/database` (replaces in-memory) - **Service layer**: `PreferencesService` with Get and Upsert operations, authorization checks - **Handler layer**: GET and PUT handlers with request binding, error mapping, auth enforcement - **Migration**: Single SQL migration to create `user_preferences` table with JSONB column No new patterns are introduced. Every layer follows the conventions established by the Example scaffold, with the scaffold code removed and replaced. ### What Changes | Layer | Action | Files | |-------|--------|-------| | Domain | Replace `example.go`, `errors.go` | `internal/domain/preferences.go`, `internal/domain/errors.go` | | Port | Replace `example.go` | `internal/port/preferences.go` | | Adapter | Replace `adapter/memory/` with `adapter/postgres/` | `internal/adapter/postgres/preferences.go` | | Service | Replace `example.go` | `internal/service/preferences.go`, `internal/service/preferences_test.go` | | Handlers | Replace `example.go` | `internal/api/handlers/preferences.go`, `internal/api/handlers/preferences_test.go` | | Routes | Update route registration | `internal/api/routes.go` | | Spec | Update OpenAPI spec | `internal/api/spec.go` | | Config | Already has `DatabaseConfig` — no changes needed | `internal/config/config.go` | | Main | Add DB connection, migrations, wire postgres adapter | `cmd/server/main.go` | | Migration | New file | `migrations/001_create_user_preferences.sql` | ### What Gets Removed All Example scaffold files: `domain/example.go`, `port/example.go`, `adapter/memory/example.go`, `service/example.go`, `service/example_test.go`, `handlers/example.go`, `handlers/example_test.go`. The health handler remains unchanged. ## Data Model Changes ### Database Schema ```sql -- migrations/001_create_user_preferences.sql CREATE TABLE 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() ); ``` Design rationale: - **JSONB column** stores preferences as a flexible key-value map while the domain layer enforces the allowed key set. This avoids schema changes when new preference keys are added in the future. - **`user_id` as primary key** — one row per user, no surrogate ID needed. - **No foreign key to a users table** — the preferences-api service does not own the users table. User identity comes from the JWT. ### Domain Types ```go // internal/domain/preferences.go type UserPreferences struct { UserID string Preferences map[string]any CreatedAt time.Time UpdatedAt time.Time } ``` **Allowed preference keys and validation rules** (enforced in domain layer): | Key | Type | Valid Values | |-----|------|-------------| | `theme` | string | `"light"`, `"dark"` | | `language` | string | ISO 639-1 pattern: 2 lowercase letters (e.g., `en`, `es`, `fr`) | | `notifications_enabled` | bool | `true`, `false` | Domain validation functions: - `ValidatePreferences(prefs map[string]any) error` — rejects unknown keys and invalid values - `ValidatePreferenceKey(key string) error` — checks key is in the allowed set - `ValidatePreferenceValue(key string, value any) error` — checks value is valid for the given key ## API Changes ### GET /api/preferences-api/preferences/{user_id} Retrieves all preferences for a user. Returns empty preferences (not 404) if the user has no saved preferences. **Auth**: Required (Bearer JWT). User ID from JWT must match `{user_id}` path parameter. **Response 200** (preferences exist): ```json { "data": { "user_id": "550e8400-e29b-41d4-a716-446655440000", "preferences": { "theme": "dark", "language": "en", "notifications_enabled": true }, "updated_at": "2026-02-08T12:00:00Z" }, "meta": { "request_id": "...", "timestamp": "..." } } ``` **Response 200** (no preferences saved): ```json { "data": { "user_id": "550e8400-e29b-41d4-a716-446655440000", "preferences": {}, "updated_at": null }, "meta": { ... } } ``` **Error responses**: 400 (invalid UUID), 401 (unauthenticated), 403 (user ID mismatch). ### PUT /api/preferences-api/preferences/{user_id} Creates or updates preferences with upsert semantics. Only provided keys are changed; omitted keys are preserved (merge behavior). **Auth**: Required. User ID from JWT must match `{user_id}`. **Request**: ```json { "preferences": { "theme": "dark", "notifications_enabled": false } } ``` **Response 200** (returns full merged preferences): ```json { "data": { "user_id": "550e8400-e29b-41d4-a716-446655440000", "preferences": { "theme": "dark", "language": "en", "notifications_enabled": false }, "updated_at": "2026-02-08T12:00:05Z" }, "meta": { ... } } ``` **Error responses**: 400 (invalid UUID, unknown key, invalid value), 401 (unauthenticated), 403 (user ID mismatch). ### Request/Response DTOs ```go // Handler-level DTOs type UpdatePreferencesRequest struct { Preferences map[string]any `json:"preferences" validate:"required"` } type PreferencesResponse struct { UserID string `json:"user_id"` Preferences map[string]any `json:"preferences"` UpdatedAt *time.Time `json:"updated_at"` } ``` ## Component Diagram ``` ┌──────────────────────────────────────────────────────────┐ │ HTTP Client │ └────────────┬──────────────────────────────┬──────────────┘ │ GET /preferences/{user_id} │ PUT /preferences/{user_id} ▼ ▼ ┌──────────────────────────────────────────────────────────┐ │ chi Router (/api/preferences-api) │ │ ├── middleware.RequestID │ │ ├── middleware.Tracing │ │ ├── middleware.RequestLogger │ │ ├── middleware.Recoverer │ │ └── auth.Middleware (JWT) ◄── all pref routes │ └────────────┬──────────────────────────────┬──────────────┘ │ │ ▼ ▼ ┌──────────────────────────────────────────────────────────┐ │ handlers.Preferences │ │ ├── Get(w, r) error │ │ │ ├── chi.URLParam → user_id │ │ │ ├── auth ownership check │ │ │ └── httpresponse.OK(data) │ │ └── Update(w, r) error │ │ ├── chi.URLParam → user_id │ │ ├── app.BindAndValidate → UpdatePreferencesRequest │ │ ├── auth ownership check │ │ └── httpresponse.OK(data) │ └────────────┬──────────────────────────────┬──────────────┘ │ │ ▼ ▼ ┌──────────────────────────────────────────────────────────┐ │ service.PreferencesService │ │ ├── Get(ctx, userID) → (*UserPreferences, error) │ │ └── Update(ctx, userID, prefs) → (*UserPreferences, err)│ │ ├── domain.ValidatePreferences(prefs) │ │ └── repo.Upsert(ctx, userID, prefs) │ └────────────┬──────────────────────────────┬──────────────┘ │ │ ▼ ▼ ┌──────────────────────────────────────────────────────────┐ │ port.PreferencesRepository (interface) │ │ ├── Get(ctx, userID) → (*UserPreferences, error) │ │ └── Upsert(ctx, userID, prefs) → (*UserPreferences, err)│ └────────────┬──────────────────────────────┬──────────────┘ │ │ ▼ ▼ ┌──────────────────────────────────────────────────────────┐ │ adapter/postgres.PreferencesRepository │ │ ├── Get: SELECT ... WHERE user_id = $1 │ │ └── Upsert: INSERT ... ON CONFLICT (user_id) │ │ DO UPDATE SET preferences = merged, │ │ updated_at = NOW() │ └──────────────────────────────┬───────────────────────────┘ │ ▼ ┌─────────────────────┐ │ PostgreSQL │ │ user_preferences │ └─────────────────────┘ ``` ## Error Handling Strategy ### Domain Errors ```go var ( ErrInvalidPreferenceKey = errors.New("invalid preference key") ErrInvalidPreferenceValue = errors.New("invalid preference value") ) ``` ### Handler Error Mapping | Domain Error | HTTP Status | Response | |-------------|------------|----------| | `ErrInvalidPreferenceKey` | 400 Bad Request | `"unknown preference key: "` | | `ErrInvalidPreferenceValue` | 400 Bad Request | `"invalid value for : "` | | Unauthenticated request | 401 Unauthorized | Handled by `auth.Middleware` | | User ID mismatch | 403 Forbidden | `"access denied"` | | Invalid UUID in path | 400 Bad Request | `"invalid user ID format"` | | Missing `preferences` field | 400 Bad Request | Handled by `app.BindAndValidate` | | Unhandled / DB error | 500 Internal | Logged; generic message to client via `app.Wrap` | ### Error Mapping Function ```go func mapDomainError(err error) error { switch { case errors.Is(err, domain.ErrInvalidPreferenceKey): return httperror.BadRequest(err.Error()) case errors.Is(err, domain.ErrInvalidPreferenceValue): return httperror.BadRequest(err.Error()) default: return err // becomes 500 via app.Wrap } } ``` ### Database Failures - Connection errors during startup: `database.MustConnect` panics with descriptive message. - Query errors at runtime: Bubble up through the adapter as raw errors, logged by middleware, returned as 500. - Migration failures at startup: `database.MustRunMigrations` panics with descriptive message. ## Security Considerations ### Authentication All preference endpoints require authentication. Auth middleware is applied to the entire preferences route group (not selectively per-route like the scaffold): ```go r.Group(func(r app.Router) { if cfg.AuthEnabled { r.Use(auth.Middleware(auth.MiddlewareConfig{ Validator: auth.NewJWTValidator(auth.JWTConfig{ Secret: []byte(cfg.JWTSecret), Issuer: "slack5-1770544098", }), })) } r.Get("/preferences/{user_id}", app.Wrap(prefHandler.Get)) r.Put("/preferences/{user_id}", app.Wrap(prefHandler.Update)) }) ``` ### Authorization (Ownership Check) Handlers enforce that the authenticated user can only access their own preferences: ```go func (h *Preferences) checkOwnership(r *http.Request, userID string) error { user := auth.MustGetUser(r.Context()) if user.ID != userID { return httperror.Forbidden("access denied") } return nil } ``` This is checked in both GET and PUT handlers before calling the service layer. ### Input Validation 1. **Path parameter**: UUID format validated via `uuid.Parse()`. 2. **Request body**: `app.BindAndValidate()` ensures `preferences` field is present. 3. **Preference keys**: Domain layer rejects any key not in `{theme, language, notifications_enabled}`. 4. **Preference values**: Domain layer validates per-key: - `theme`: must be `"light"` or `"dark"` - `language`: must match `^[a-z]{2}$` (ISO 639-1) - `notifications_enabled`: must be a boolean 5. **JSONB injection**: PostgreSQL parameterized queries prevent SQL injection. Go's `encoding/json` handles JSON marshaling safely. ### Data Boundaries - Users cannot read or write other users' preferences (403). - The API does not expose internal database IDs or timestamps beyond `updated_at`. - Error messages do not leak internal details (domain errors have descriptive but safe messages). ## Performance Considerations ### Expected Load User preferences are typically read on session start and written infrequently (settings changes). Expected pattern: **high read, low write**. ### Query Performance - **GET**: Single-row lookup by primary key (`user_id UUID`). O(1) index lookup — no additional indexes needed. - **PUT (Upsert)**: `INSERT ... ON CONFLICT` operates on the primary key — efficient single-row upsert. - **No list/search endpoints**: No table scans or complex queries. ### Caching Strategy Not needed for initial implementation. The query is a primary key lookup on a single small row. If needed later, HTTP-level caching (ETag/Last-Modified based on `updated_at`) or application-level caching can be added without architectural changes. ### Data Size Each row contains a JSONB object with at most 3 keys. Row size is trivially small (~200 bytes). Even at millions of users, the table fits comfortably in PostgreSQL's buffer cache. ## Migration / Rollout Plan ### Step 1: Database Migration Create `migrations/001_create_user_preferences.sql`: ```sql 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 runs automatically at service startup via `database.MustRunMigrations()`. The `IF NOT EXISTS` clause makes it idempotent. ### Step 2: Remove Scaffold, Implement Feature All Example scaffold code is replaced with preferences code in a single feature branch. Since the scaffold has no production users, this is a clean swap with no backward compatibility concerns. ### Step 3: Wire Database in Main Update `cmd/server/main.go`: 1. Read `DatabaseConfig` from config. 2. Connect to PostgreSQL via `database.MustConnect()`. 3. Run migrations via `database.MustRunMigrations()`. 4. Create `postgres.PreferencesRepository` with the DB pool. 5. Create `PreferencesService` with the postgres repository. 6. Register shutdown hook to close DB pool. ### Step 4: Deploy Standard service deployment. The migration creates a new table with no dependencies on existing tables, so there is zero risk to existing data or services. ### Rollback If issues arise, revert the deployment to the previous version. The `user_preferences` table can remain (empty or with minimal data) — it causes no harm. A future migration can drop it if the feature is permanently abandoned. ## Open Questions Resolution From the spec's open questions, the design makes these decisions: 1. **Language validation strictness**: Accept any valid ISO 639-1 pattern (`^[a-z]{2}$`). This is permissive enough to avoid maintaining a language list while still rejecting obviously invalid input. 2. **Default preferences**: The API returns empty `{}` for users with no preferences. The frontend handles defaults. This keeps the API simple and avoids coupling to UI decisions. 3. **Rate limiting**: Not implemented in this feature. Rate limiting is a cross-cutting concern best handled at the infrastructure level (API gateway/ingress) rather than per-service. 4. **Removing the scaffold**: Yes — all Example scaffold code is removed and replaced with preferences code. The scaffold served its purpose as a template.