# Design: User Preferences API ## Architecture Approach Replace the existing `example` scaffold in the `preferences-api` service with a user preferences system. The hexagonal architecture layers remain identical — only the domain entity, service logic, port interface, adapter, handlers, and OpenAPI spec change. No new services, packages, or infrastructure components are introduced. ### Layers Changed | Layer | What Changes | |-------|-------------| | **Domain** | `Example` entity → `Preferences` entity with typed fields and validation | | **Port** | `ExampleRepository` interface → `PreferencesRepository` with Get/Upsert by UserID | | **Adapter** | In-memory map keyed by `UserID` instead of `ExampleID` | | **Service** | CRUD logic → Get + Upsert with shallow merge and authorization checks | | **Handlers** | 5 endpoints (CRUD) → 2 endpoints (GET + PUT) with auth-gated access | | **OpenAPI** | Example schemas/paths → Preferences schemas/paths | | **Config** | No changes (auth config already exists) | | **main.go** | Wire `PreferencesRepository` and `PreferencesService` instead of example equivalents | ### What Is Removed All `example` scaffold code: `domain/example.go`, `port/example.go`, `adapter/memory/example.go`, `service/example.go`, `handlers/example.go`, and their tests. The `domain/errors.go` file is replaced with preferences-specific errors. ## Data Model Changes ### Domain Entity: `preferences.go` ```go package domain import "time" // UserID is a strongly-typed identifier for users. type UserID string func (id UserID) String() string { return string(id) } func (id UserID) IsZero() bool { return id == "" } // Preferences holds all user preferences. type Preferences struct { UserID UserID Theme string Language string Notifications NotificationSettings UpdatedAt time.Time } type NotificationSettings struct { Email bool Push bool Digest string } ``` ### Allowed Values (validated in domain) | Field | Type | Allowed | Default | |-------|------|---------|---------| | `theme` | string | `light`, `dark`, `system` | `system` | | `language` | string | ISO 639-1 (validated via regex `^[a-z]{2}$`) | `en` | | `notifications.email` | bool | `true`, `false` | `true` | | `notifications.push` | bool | `true`, `false` | `true` | | `notifications.digest` | string | `daily`, `weekly`, `never` | `weekly` | ### Domain Validation ```go // NewDefaultPreferences returns a Preferences with all defaults applied. func NewDefaultPreferences(userID UserID) *Preferences // Validate checks that all field values are within allowed sets. // Returns a domain error listing invalid fields. func (p *Preferences) Validate() error // MergeFrom applies a shallow merge: top-level keys from `incoming` overwrite // corresponding fields in `p`. The Notifications struct is replaced entirely // when provided. Fields not present in `incoming` are left unchanged. func (p *Preferences) MergeFrom(incoming *PreferencesUpdate) ``` ### Domain Errors: `errors.go` ```go var ( ErrPreferencesNotFound = errors.New("preferences not found") ErrInvalidTheme = errors.New("invalid theme value") ErrInvalidLanguage = errors.New("invalid language value") ErrInvalidDigest = errors.New("invalid digest value") ErrInvalidPreferences = errors.New("invalid preferences") ) ``` ### Merge Semantics (Detail) The spec requires **shallow merge at top-level keys**. Concretely: 1. Client sends `{"preferences": {"theme": "light"}}` → only `theme` changes; `language` and `notifications` are untouched. 2. Client sends `{"preferences": {"notifications": {"email": false}}}` → the entire `notifications` struct is replaced with `{email: false, push: false, digest: ""}`. Because we replace the whole nested object, missing sub-fields get zero values — but we **fill missing notification sub-fields with defaults** before validation to avoid forcing the client to always send the full object. This is the most user-friendly interpretation of "replaced entirely when provided" from the spec. Implementation: A `PreferencesUpdate` struct uses pointer fields to distinguish "provided" from "not provided": ```go type PreferencesUpdate struct { Theme *string Language *string Notifications *NotificationSettingsUpdate } type NotificationSettingsUpdate struct { Email *bool Push *bool Digest *string } ``` `MergeFrom` only overwrites fields where the pointer is non-nil. For `Notifications`, if the pointer is non-nil, individual sub-fields within `NotificationSettingsUpdate` are merged (non-nil pointers overwrite, nil pointers keep existing values). This provides the predictable behavior the spec describes while not forcing clients to send complete notification objects. ## API Changes ### Endpoints | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/preferences-api/preferences/{user_id}` | Required | Get all preferences for a user | | PUT | `/api/preferences-api/preferences/{user_id}` | Required | Create or update preferences (merge semantics) | ### Removed Endpoints All `/api/preferences-api/examples/*` endpoints are removed. ### GET `/api/preferences-api/preferences/{user_id}` **Path Parameters:** - `user_id` — UUID format, validated with `httpvalidation` UUID validator **Authorization:** - Token subject must match `user_id`, OR user must have `admin` role - Returns `403 Forbidden` if neither condition is met **Responses:** - `200 OK` — preferences found, returned in envelope - `403 Forbidden` — user_id doesn't match token and user is not admin - `404 Not Found` — no preferences stored for this user **Response Body (200):** ```json { "data": { "user_id": "550e8400-e29b-41d4-a716-446655440000", "preferences": { "theme": "dark", "language": "en", "notifications": { "email": true, "push": false, "digest": "weekly" } }, "updated_at": "2026-02-08T00:00:00Z" }, "meta": { "request_id": "...", "timestamp": "..." } } ``` ### PUT `/api/preferences-api/preferences/{user_id}` **Path Parameters:** - `user_id` — UUID format **Authorization:** - Same as GET (own-user or admin) **Request Body:** ```json { "preferences": { "theme": "light", "notifications": { "email": false } } } ``` **Validation:** - `preferences` field must be present and a JSON object → `400 Bad Request` if missing - Unknown top-level keys inside `preferences` (anything other than `theme`, `language`, `notifications`) → `400 Bad Request` - Values validated against allowed sets → `400 Bad Request` with field-level details **Responses:** - `200 OK` — preferences created or updated, full merged result returned - `400 Bad Request` — validation failure (missing preferences, unknown keys, invalid values) - `403 Forbidden` — authorization failure **Response Body (200):** Same shape as GET response, with merged preferences. ### Handler Request/Response Types ```go // PUT request body type UpdatePreferencesRequest struct { Preferences *PreferencesPayload `json:"preferences" validate:"required"` } type PreferencesPayload struct { Theme *string `json:"theme,omitempty"` Language *string `json:"language,omitempty"` Notifications *NotificationSettingsPayload `json:"notifications,omitempty"` } type NotificationSettingsPayload struct { Email *bool `json:"email,omitempty"` Push *bool `json:"push,omitempty"` Digest *string `json:"digest,omitempty"` } // GET/PUT response body (inside envelope) type PreferencesResponse struct { UserID string `json:"user_id"` Preferences PreferencesDataResponse `json:"preferences"` UpdatedAt string `json:"updated_at"` } type PreferencesDataResponse struct { Theme string `json:"theme"` Language string `json:"language"` Notifications NotificationSettingsResponse `json:"notifications"` } type NotificationSettingsResponse struct { Email bool `json:"email"` Push bool `json:"push"` Digest string `json:"digest"` } ``` **Unknown field rejection:** Use `app.BindStrict()` for the top-level request, and perform manual check on the `PreferencesPayload` to reject unknown keys. Alternatively, use `json.Decoder.DisallowUnknownFields()` via `app.BindAndValidateStrict()` for the outer struct, and add a custom validation step for the inner `preferences` object keys. ## Component Diagram ``` ┌─────────────────────────────────────────────────────────────────┐ │ HTTP Client │ │ (Frontend / API Consumer) │ └──────────┬──────────────────────────────────────────┬────────────┘ │ GET /preferences/{user_id} │ PUT /preferences/{user_id} ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ chi Router │ │ ├── auth.Middleware() ─── JWT validation │ │ └── /api/preferences-api/preferences/{user_id} │ └──────────┬──────────────────────────────────────────┬────────────┘ │ │ ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Handlers (internal/api/handlers/preferences.go) │ │ ├── Get(w, r) error │ │ │ ├── Extract user_id from path, validate UUID │ │ │ ├── Authorization check (own-user or admin) │ │ │ ├── Call service.Get() │ │ │ └── Return httpresponse.OK() or httperror.NotFound() │ │ └── Update(w, r) error │ │ ├── Extract user_id from path, validate UUID │ │ ├── Authorization check (own-user or admin) │ │ ├── app.BindAndValidateStrict() request body │ │ ├── Call service.Upsert() │ │ └── Return httpresponse.OK() with merged result │ └──────────┬──────────────────────────────────────────┬────────────┘ │ │ ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Service (internal/service/preferences.go) │ │ ├── Get(ctx, userID) → (*Preferences, error) │ │ └── Upsert(ctx, userID, update) → (*Preferences, error) │ │ ├── repo.Get() to fetch existing (or create defaults) │ │ ├── domain.MergeFrom(update) │ │ ├── domain.Validate() │ │ └── repo.Upsert() │ └──────────┬──────────────────────────────────────────┬────────────┘ │ │ ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Port Interface (internal/port/preferences.go) │ │ PreferencesRepository { │ │ Get(ctx, userID) → (*Preferences, error) │ │ Upsert(ctx, userID, prefs) → error │ │ } │ └──────────┬──────────────────────────────────────────┬────────────┘ │ │ ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Adapter: In-Memory (internal/adapter/memory/preferences.go) │ │ map[UserID]*Preferences protected by sync.RWMutex │ │ ├── Get: lookup by key, return copy or ErrPreferencesNotFound │ │ └── Upsert: store copy (insert or replace) │ └─────────────────────────────────────────────────────────────────┘ ``` ## Error Handling Strategy ### Error Flow ``` Domain errors (domain.Err*) ↓ returned to service Service returns domain errors unchanged ↓ returned to handler Handler maps via mapDomainError() ↓ converts to httperror.* app.Wrap() writes HTTP response ``` ### Error Mapping Table | Domain Error | HTTP Status | HTTP Code | When | |-------------|-------------|-----------|------| | `ErrPreferencesNotFound` | 404 | `NOT_FOUND` | GET for user with no stored preferences | | `ErrInvalidTheme` | 400 | `BAD_REQUEST` | Theme value not in allowed set | | `ErrInvalidLanguage` | 400 | `BAD_REQUEST` | Language not matching `^[a-z]{2}$` | | `ErrInvalidDigest` | 400 | `BAD_REQUEST` | Digest value not in allowed set | | `ErrInvalidPreferences` | 400 | `BAD_REQUEST` | Generic validation failure | | (binding error) | 400 | `BAD_REQUEST` | Malformed JSON, missing `preferences` field | | (unknown fields) | 400 | `BAD_REQUEST` | Unknown keys in `preferences` object | | (auth failure) | 401 | `UNAUTHORIZED` | Missing or invalid JWT token | | (authz failure) | 403 | `FORBIDDEN` | user_id doesn't match token, not admin | | (UUID validation) | 400 | `BAD_REQUEST` | `user_id` path param not a valid UUID | | (unexpected) | 500 | `INTERNAL_ERROR` | Any unhandled error (logged by `app.Wrap`) | ### Validation Detail Response For validation errors, the response includes field-level details: ```json { "error": { "code": "BAD_REQUEST", "message": "invalid preferences", "details": [ { "field": "theme", "message": "must be one of: light, dark, system" } ] }, "meta": { "request_id": "...", "timestamp": "..." } } ``` ## Security Considerations ### Authentication - Both endpoints require a valid JWT token via `auth.Middleware()`. - Auth middleware runs before handler code — unauthenticated requests never reach service logic. - Auth is gated by `AUTH_ENABLED` config (same as existing pattern) to allow local dev without JWT. ### Authorization - **Own-user access**: The handler extracts the authenticated user from context via `auth.GetUser(ctx)` and compares `user.ID` with the `user_id` path parameter. Mismatch returns `403 Forbidden`. - **Admin override**: If `user.HasRole("admin")` is true, access is granted regardless of user_id match. - Authorization is enforced in the handler layer (not service layer) because it depends on HTTP context (path params + auth context). ### Input Validation | Input | Validation | Layer | |-------|-----------|-------| | `user_id` path param | UUID format regex | Handler | | Request body JSON | Well-formed JSON, no unknown fields | Handler (via `app.BindAndValidateStrict`) | | `preferences` field | Must be present and non-null | Handler (struct tag `validate:"required"`) | | `theme` | Must be `light`, `dark`, or `system` | Domain | | `language` | Must match `^[a-z]{2}$` | Domain | | `notifications.digest` | Must be `daily`, `weekly`, or `never` | Domain | | Unknown top-level keys | Rejected | Handler (strict binding) | ### Data Exposure - Preferences contain no secrets or PII beyond user_id (which the user already knows). - No cross-user data leakage: authorization check prevents reading/writing other users' preferences. - Error messages do not leak internal details. ## Performance Considerations ### Expected Load - Preferences are read frequently (every page load / session init) and written rarely (settings page changes). - Read-heavy workload: ~100:1 read-to-write ratio expected. ### In-Memory Adapter Performance - O(1) lookups by UserID (map key). - No serialization overhead — domain structs stored directly. - `sync.RWMutex` allows concurrent reads; writes are serialized. - Suitable for development and testing. Production will use PostgreSQL adapter (out of scope). ### Response Size - Preferences response is small (~200 bytes JSON). No pagination needed. - Single GET returns all preferences — no need for per-key endpoints. ### Future Database Considerations (out of scope, noted for awareness) - `user_id` column should be the primary key (one row per user). - `preferences` can be stored as JSONB for flexibility, or as individual columns for type safety. - Index on `user_id` (primary key provides this automatically). ## Migration / Rollout Plan ### Step-by-Step 1. **Remove example scaffold**: Delete all `example`-related files (domain, port, adapter, service, handlers, tests). 2. **Add domain layer**: Create `Preferences`, `NotificationSettings`, `PreferencesUpdate` types with validation. 3. **Add port interface**: Define `PreferencesRepository` with `Get` and `Upsert` methods. 4. **Add in-memory adapter**: Implement `PreferencesRepository` with thread-safe map. 5. **Add service layer**: Implement `PreferencesService` with `Get` and `Upsert` (merge + validate). 6. **Add handlers**: Implement GET and PUT with auth, validation, and error mapping. 7. **Update routes**: Replace example routes with preferences routes, apply auth middleware. 8. **Update OpenAPI spec**: Replace example schemas/paths with preferences schemas/paths. 9. **Update main.go**: Wire new repository, service, and routes. 10. **Write tests**: Service tests (merge logic, validation, authorization) and handler tests (HTTP layer). ### Rollout Risk - **Zero risk to other services**: Changes are entirely within `preferences-api` service boundary. - **No database migration**: In-memory adapter means no schema changes. - **No API consumers yet**: The example API has no consumers, so removing it has no compatibility impact. - **Backward incompatibility**: The `/examples` endpoints are removed entirely. This is expected — the spec explicitly requires replacing the scaffold. ### File Changes Summary | Action | File | |--------|------| | Delete | `internal/domain/example.go` | | Delete | `internal/domain/errors.go` (recreated with new errors) | | Delete | `internal/port/example.go` | | Delete | `internal/adapter/memory/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` | | Create | `internal/domain/preferences.go` | | Create | `internal/domain/errors.go` | | Create | `internal/port/preferences.go` | | Create | `internal/adapter/memory/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` | | Modify | `internal/api/routes.go` | | Modify | `internal/api/spec.go` | | Modify | `cmd/server/main.go` | | Keep | `internal/api/handlers/health.go` (unchanged) | | Keep | `internal/config/config.go` (unchanged) |