# Design: User Preferences API ## Architecture Approach Replace the existing example CRUD scaffold in `services/preferences-api/` with a preference-specific domain following the same hexagonal architecture pattern: **domain → service → port (interface) → adapter (implementation)**. ### What Changes | Layer | Action | Description | |-------|--------|-------------| | **Domain** | Replace | New `Preference` and `PreferenceKey` types with validation; remove `Example` entity | | **Port** | Replace | New `PreferenceRepository` interface with `GetByUserID` and `Upsert` methods | | **Adapter** | Replace | New PostgreSQL adapter (replaces in-memory); new migration for `user_preferences` table | | **Service** | Replace | New `PreferenceService` with get/upsert business logic, key/value validation | | **Handlers** | Replace | New `Preference` handler for GET and PUT endpoints with auth enforcement | | **Routes** | Modify | Update route registration: both endpoints require auth, add user_id ownership check | | **Spec** | Replace | New OpenAPI documentation for preference endpoints | | **Main** | Modify | Wire PostgreSQL pool, run migrations, inject into preference service | ### What Stays the Same - Service name (`preferences-api`), port (`8001`), base path (`/api/preferences-api`) - Health check endpoint and handler - Config loading pattern (extended with database config) - All `pkg/*` dependencies remain unchanged - Makefile, Dockerfile, component.yaml structure ## Data Model Changes ### Domain Types ```go // internal/domain/preference.go // AllowedKeys defines the valid preference keys and their allowed values. var AllowedKeys = map[string][]string{ "theme": {"light", "dark", "system"}, "language": {}, // validated via regex: ^[a-z]{2}$ (ISO 639-1) "notifications_enabled": {"true", "false"}, } // Preference represents a single user preference key-value pair. type Preference struct { UserID string Key string Value string } // Validate checks that Key is known and Value is valid for that key. func (p *Preference) Validate() error { ... } // ValidateKey checks if a key is in the allowed set. func ValidateKey(key string) error { ... } // ValidateValue checks if a value is valid for the given key. func ValidateValue(key, value string) error { ... } ``` ```go // internal/domain/errors.go var ( ErrUnknownKey = errors.New("unknown preference key") ErrInvalidValue = errors.New("invalid preference value") ErrForbidden = errors.New("access denied") ) ``` ### Database Schema ```sql -- migrations/001_create_user_preferences.sql CREATE TABLE IF NOT EXISTS user_preferences ( user_id UUID NOT NULL, key VARCHAR(64) NOT NULL, value TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, key) ); CREATE INDEX idx_user_preferences_user_id ON user_preferences (user_id); ``` **Design decisions:** - **Composite primary key** `(user_id, key)` — enforces one value per key per user, enables efficient upsert via `ON CONFLICT`. - **Key-value model** rather than a wide row — allows adding new preference keys without schema migration. - **Index on `user_id`** — supports efficient retrieval of all preferences for a single user. - **No foreign key to a users table** — the preferences service doesn't own user data; user existence is validated by the auth token. ### Port Interface ```go // internal/port/preference.go type PreferenceRepository interface { // GetByUserID returns all preferences for a user as a map[key]value. // Returns an empty map if the user has no preferences. GetByUserID(ctx context.Context, userID string) (map[string]string, error) // Upsert creates or updates preferences for a user. // Only the provided keys are affected; existing keys not in the map are preserved. Upsert(ctx context.Context, userID string, prefs map[string]string) error } ``` ### PostgreSQL Adapter ```go // internal/adapter/postgres/preference.go type PreferenceRepository struct { db *sqlx.DB logger *logging.Logger } func (r *PreferenceRepository) GetByUserID(ctx context.Context, userID string) (map[string]string, error) { // SELECT key, value FROM user_preferences WHERE user_id = $1 // Returns empty map if no rows } func (r *PreferenceRepository) Upsert(ctx context.Context, userID string, prefs map[string]string) error { // Uses a transaction with batch INSERT ... ON CONFLICT (user_id, key) // DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() // One statement per key within a single transaction } ``` ## API Changes ### Endpoints Both endpoints are mounted under `/api/preferences-api` and require JWT authentication. #### GET /api/preferences-api/preferences/{user_id} Retrieve all preferences for a user. **Request:** ``` GET /api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000 Authorization: Bearer ``` **Response (200 OK):** ```json { "data": { "theme": "dark", "language": "en", "notifications_enabled": "true" }, "meta": { "request_id": "abc-123", "timestamp": "2026-02-07T12:00:00Z" } } ``` **Response (200 OK, no preferences set):** ```json { "data": {}, "meta": { ... } } ``` **Error Responses:** - `400 Bad Request` — invalid UUID in path - `401 Unauthorized` — missing or invalid JWT - `403 Forbidden` — user_id does not match JWT subject #### PUT /api/preferences-api/preferences/{user_id} Create or update preferences (partial upsert). **Request:** ``` PUT /api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000 Authorization: Bearer Content-Type: application/json { "theme": "dark", "language": "fr" } ``` **Response (200 OK):** ```json { "data": { "theme": "dark", "language": "fr", "notifications_enabled": "true" }, "meta": { ... } } ``` Returns the full preference set after the update (including unchanged keys). **Error Responses:** - `400 Bad Request` — invalid UUID, unknown key, or invalid value (with descriptive message) - `401 Unauthorized` — missing or invalid JWT - `403 Forbidden` — user_id does not match JWT subject ### Request/Response DTOs ```go // Handler request DTO for PUT type UpdatePreferencesRequest struct { Preferences map[string]string // Unmarshalled from JSON body } // Handler response DTO for GET and PUT type PreferencesResponse struct { Preferences map[string]string // Serialized as flat JSON object } ``` The response `data` field is a flat `map[string]string`, not wrapped in a `preferences` key. This keeps the API simple: `data.theme`, not `data.preferences.theme`. ## Component Diagram ``` ┌──────────────────────────────────────────────────────────────────┐ │ HTTP Client │ │ Authorization: Bearer │ └──────────────────────┬───────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ Chi Router │ │ /api/preferences-api/preferences/{user_id} [GET, PUT] │ │ │ │ Middleware Stack: │ │ ┌─────────┐ ┌─────────┐ ┌───────────┐ ┌──────────┐ │ │ │RequestID│→│ Tracing │→│ Logger │→│Recoverer │ │ │ └─────────┘ └─────────┘ └───────────┘ └──────────┘ │ │ │ │ │ ┌────────┴────────┐ │ │ │ Auth Middleware │ ← pkg/auth JWT validation │ │ └────────┬────────┘ │ │ │ │ │ ┌────────┴────────┐ │ │ │ Preference │ ← Handler: ownership check, │ │ │ Handler │ bind, validate, map errors │ │ └────────┬────────┘ │ └───────────────────────┼──────────────────────────────────────────┘ │ ┌────────┴────────┐ │ Preference │ ← Service: domain validation, │ Service │ orchestrate get/upsert └────────┬────────┘ │ ┌────────┴────────┐ │ Preference │ ← Port: interface │ Repository │ └────────┬────────┘ │ ┌────────┴────────┐ │ PostgreSQL │ ← Adapter: SQL queries, │ Adapter │ ON CONFLICT upsert └────────┬────────┘ │ ┌────────┴────────┐ │ PostgreSQL │ ← user_preferences table │ Database │ └─────────────────┘ ``` ## Error Handling Strategy | Error Condition | Domain Error | HTTP Error | Status | |----------------|-------------|-----------|--------| | Invalid UUID in path | — | `httperror.BadRequest("invalid user ID format")` | 400 | | Empty request body (PUT) | — | `httperror.BadRequest("request body is required")` | 400 | | Unknown preference key | `ErrUnknownKey` | `httperror.BadRequest("unknown preference key: ")` | 400 | | Invalid preference value | `ErrInvalidValue` | `httperror.BadRequest("invalid value '' for key '': allowed values are [...]")` | 400 | | Missing/invalid JWT | — | Handled by auth middleware | 401 | | user_id ≠ JWT subject | `ErrForbidden` | `httperror.Forbidden("cannot access preferences for another user")` | 403 | | Database connection error | raw error | Passthrough → `app.Wrap` returns 500 | 500 | **Error message strategy:** Validation errors include specific, actionable messages that tell the client what went wrong and what is allowed. For example: `"invalid value 'blue' for key 'theme': allowed values are [light, dark, system]"`. ### Ownership Check Flow ``` 1. Auth middleware validates JWT → stores auth.User in context 2. Handler extracts user_id from URL path 3. Handler calls auth.GetUser(ctx) to get authenticated user 4. Handler compares user.ID == user_id path param 5. If mismatch → return httperror.Forbidden(...) 6. If match → proceed to service layer ``` This check lives in the handler, not the service, because it depends on HTTP/auth context. The service layer receives a validated `userID` string and trusts it. ## Security Considerations ### Authentication - **All preference endpoints require JWT authentication** — no public access. - Auth middleware is mandatory (not conditional on `AUTH_ENABLED` for preference routes). The config flag controls whether the example routes had auth; for preferences, auth is always required. - JWT validation uses `pkg/auth.Middleware` with `auth.NewJWTValidator`. ### Authorization - **Self-access only**: authenticated users can only read/write their own preferences. - Ownership enforced at the handler layer by comparing `auth.GetUser(ctx).ID` with `{user_id}` path parameter. - No admin override (explicitly out of scope per spec). ### Input Validation - `user_id` path parameter validated as UUID format at handler layer. - Preference keys validated against a strict allowlist — unknown keys rejected. - Preference values validated per-key (enum check for theme/notifications, regex for language). - Request body size bounded by `app.Bind` defaults (prevents oversized payloads). - No SQL injection risk: all queries use parameterized statements (`$1`, `$2`). ### Data Exposure - GET returns only the authenticated user's preferences — no cross-user data leakage. - Error messages do not leak internal state (no stack traces, no database details). - Preference values are non-sensitive (theme, language, notification toggle). ### Open Question Decisions (for design purposes) 1. **Default values**: GET returns only explicitly set keys. An empty `{}` is returned for users with no preferences. Clients are responsible for applying defaults. This avoids coupling the API to default values that may change. 2. **DELETE support**: Not included in this design (out of scope per spec). Can be added later without breaking changes. 3. **Extensibility**: New keys are added by updating the `AllowedKeys` map in `domain/preference.go`. This is a code change, which is acceptable — new keys require validation rules that belong in code. 4. **Admin access**: Not supported. Self-access only. ## Performance Considerations ### Query Performance - **GET**: Single `SELECT ... WHERE user_id = $1` on a table indexed by `user_id`. Expected < 1ms for typical preference sets (3 keys). Well within p99 < 50ms target. - **PUT**: Transaction with `INSERT ... ON CONFLICT` statements. One round-trip per upsert batch. Expected < 5ms for typical updates. ### Connection Pooling - Uses `pkg/database.Pool` with default settings (25 max open, 5 max idle). - Connection pool shared across all requests. ### No Caching Needed - Preference reads are simple primary key lookups — PostgreSQL handles these efficiently. - Caching adds complexity (invalidation, stale data) with minimal benefit for this access pattern. - If caching becomes needed later, it can be added at the service layer without changing the port interface. ### Table Size - One row per user per preference key (max 3 rows per user currently). - Even at 1M users × 3 keys = 3M rows, this is trivial for PostgreSQL. ## Migration / Rollout Plan ### Step 1: Remove Example Code - Delete all `example`-related files: `domain/example.go`, `domain/errors.go` (replace), `port/example.go`, `service/example.go`, `service/example_test.go`, `adapter/memory/example.go`, `api/handlers/example.go`, `api/handlers/example_test.go`. - This is explicitly required by the spec: "The existing example CRUD code should be replaced, not left alongside preference code." ### Step 2: Implement Domain Layer - Create `domain/preference.go` with `Preference` type, `AllowedKeys`, validation functions. - Create `domain/errors.go` with `ErrUnknownKey`, `ErrInvalidValue`, `ErrForbidden`. - Test validation logic with unit tests. ### Step 3: Implement Port and Adapter - Create `port/preference.go` with `PreferenceRepository` interface. - Create `adapter/postgres/preference.go` implementing the port. - Create `migrations/001_create_user_preferences.sql`. ### Step 4: Implement Service Layer - Create `service/preference.go` with `PreferenceService`. - Create `service/preference_test.go` with mock repository. ### Step 5: Implement Handler Layer - Create `api/handlers/preference.go` with GET/PUT handlers and ownership check. - Create `api/handlers/preference_test.go` covering success, validation, auth, and ownership cases. ### Step 6: Wire Routes and Spec - Update `api/routes.go` to register preference routes with mandatory auth. - Replace `api/spec.go` with preference endpoint documentation. - Update `cmd/server/main.go` to initialize database pool, run migrations, wire dependencies. ### Step 7: Verify - Run full test suite: `cd services/preferences-api && go test -v ./...` - Manual smoke test with curl against local instance. ### Backward Compatibility - No backward compatibility concerns — the example CRUD API has no consumers. This is a scaffold replacement. - The service name, port, and base path remain unchanged. ## File Inventory | File | Action | Purpose | |------|--------|---------| | `cmd/server/main.go` | Modify | Add DB pool, migrations, wire preference service | | `internal/domain/preference.go` | Create | Preference types, AllowedKeys, validation | | `internal/domain/errors.go` | Replace | Domain errors for preferences | | `internal/port/preference.go` | Create (replace example) | PreferenceRepository interface | | `internal/service/preference.go` | Create (replace example) | Business logic | | `internal/service/preference_test.go` | Create (replace example) | Service tests with mock | | `internal/adapter/postgres/preference.go` | Create (replace memory) | PostgreSQL adapter | | `internal/api/handlers/preference.go` | Create (replace example) | HTTP handlers | | `internal/api/handlers/preference_test.go` | Create (replace example) | Handler tests | | `internal/api/handlers/health.go` | Keep | No changes | | `internal/api/routes.go` | Modify | New routes with mandatory auth | | `internal/api/spec.go` | Replace | OpenAPI spec for preferences | | `internal/config/config.go` | Keep | Already has DB and auth config | | `migrations/001_create_user_preferences.sql` | Create | Database schema | | `internal/adapter/memory/example.go` | Delete | Replaced by postgres adapter | | `internal/domain/example.go` | Delete | Replaced by preference domain | | `internal/port/example.go` | Delete | Replaced by preference port | | `internal/service/example.go` | Delete | Replaced by preference service | | `internal/service/example_test.go` | Delete | Replaced by preference tests | | `internal/api/handlers/example.go` | Delete | Replaced by preference handlers | | `internal/api/handlers/example_test.go` | Delete | Replaced by preference handler tests |