# Design: User Preferences API ## Architecture Approach Replace the existing example CRUD scaffold in `services/preferences-api/` with a user preferences system. The change follows the same hexagonal architecture already in place (domain → service → port → adapter), reusing all existing framework packages (`app`, `httperror`, `httpresponse`, `auth`, `openapi`). **What changes:** - **Domain layer**: Replace `Example` entity with `UserPreferences` value object containing typed preference fields and validation logic - **Port layer**: Replace `ExampleRepository` with `PreferenceRepository` interface (Get + Upsert) - **Adapter layer**: Replace in-memory example adapter with both an in-memory adapter (for tests) and a PostgreSQL adapter (for production) - **Service layer**: Replace `ExampleService` with `PreferenceService` orchestrating authorization checks and default hydration - **Handler layer**: Replace example CRUD handlers with two preference endpoints (GET + PUT) - **Routes/Spec**: Replace all example routes and OpenAPI documentation with preference endpoints - **Config/Main**: Wire PostgreSQL connection and new dependency graph **What stays the same:** - Service name, port (8001), route prefix (`/api/preferences-api/`) - All framework conventions (handler pattern, error wrapping, response envelope) - Project structure (directory layout, go.mod, Makefile, Dockerfile) - Health endpoint ## Data Model Changes ### Domain Types ```go // domain/preferences.go type UserPreferences struct { UserID string Theme Theme Language string Notifications NotificationPreferences UpdatedAt time.Time } type Theme string const ( ThemeLight Theme = "light" ThemeDark Theme = "dark" ThemeSystem Theme = "system" ) type DigestFrequency string const ( DigestNone DigestFrequency = "none" DigestDaily DigestFrequency = "daily" DigestWeekly DigestFrequency = "weekly" ) type NotificationPreferences struct { Email bool Push bool Digest DigestFrequency } ``` **Defaults** (returned when no saved preferences exist): - `Theme`: `"system"` - `Language`: `"en"` - `Notifications.Email`: `true` - `Notifications.Push`: `true` - `Notifications.Digest`: `"weekly"` **Validation** (pure domain logic, no framework dependencies): - `Theme` must be one of `light`, `dark`, `system` - `Language` must be one of the allowed BCP-47 tags: `en`, `fr`, `es`, `de`, `ja` - `Digest` must be one of `none`, `daily`, `weekly` - Unknown preference keys rejected at the handler layer via strict binding ### Database Schema Single table in the existing PostgreSQL instance: ```sql CREATE TABLE IF NOT EXISTS user_preferences ( user_id TEXT PRIMARY KEY, theme TEXT NOT NULL DEFAULT 'system', language TEXT NOT NULL DEFAULT 'en', notify_email BOOLEAN NOT NULL DEFAULT true, notify_push BOOLEAN NOT NULL DEFAULT true, notify_digest TEXT NOT NULL DEFAULT 'weekly', updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` **Design decisions:** - Flat columns (not JSONB) because the preference set is fixed and small — enables type safety, indexing, and simpler queries - `user_id` is `TEXT` to match the JWT subject field (UUIDs stored as text) - No `created_at` — `updated_at` serves as the only timestamp; preferences conceptually always exist (defaults) - No foreign key to a users table — the preferences service is independent; user existence is validated by auth ### Migration Strategy Schema creation handled at service startup via an `EnsureSchema()` method on the PostgreSQL adapter. This uses `CREATE TABLE IF NOT EXISTS` which is idempotent and safe for repeated runs. No migration framework needed for a single-table, new service. ## API Changes ### Endpoints Both endpoints require authentication via `auth.Middleware()`. #### GET /api/preferences-api/preferences/{user_id} Retrieve preferences for the specified user. **Authorization:** Authenticated user's JWT `ID` must match `{user_id}` in URL. Admin users (role `admin`) may read any user's preferences. **Response 200:** ```json { "data": { "user_id": "usr_abc123", "theme": "dark", "language": "en", "notifications": { "email": true, "push": false, "digest": "daily" }, "updated_at": "2026-02-08T12:00:00Z" }, "meta": { "request_id": "req-xyz", "timestamp": "2026-02-08T12:00:01Z" } } ``` **Response 403:** User ID mismatch (non-admin accessing another user's preferences). **Behavior for non-existent preferences:** Returns 200 with default values (not 404). The `updated_at` field is omitted (zero value) to indicate defaults. #### PUT /api/preferences-api/preferences/{user_id} Create or fully replace preferences for the specified user (upsert semantics). **Authorization:** Authenticated user's JWT `ID` must match `{user_id}`. Admin write is **not** permitted (spec: out of scope). **Request body:** ```json { "theme": "dark", "language": "fr", "notifications": { "email": true, "push": false, "digest": "daily" } } ``` **Request binding:** Use `app.BindAndValidateStrict(r, &req)` — strict mode rejects unknown JSON fields, satisfying the "unknown keys return 400" requirement. **Response 200:** ```json { "data": { "user_id": "usr_abc123", "theme": "dark", "language": "fr", "notifications": { "email": true, "push": false, "digest": "daily" }, "updated_at": "2026-02-08T12:00:05Z" }, "meta": { ... } } ``` **Response 400:** Invalid values or unknown keys, with per-field validation details. **Response 403:** User ID mismatch. ### Removed Endpoints All example CRUD endpoints are removed: - `GET /api/preferences-api/examples` - `GET /api/preferences-api/examples/{id}` - `POST /api/preferences-api/examples` - `PUT /api/preferences-api/examples/{id}` - `DELETE /api/preferences-api/examples/{id}` ## Component Diagram ``` ┌─────────────────────────────────────────────────────┐ │ HTTP Client │ │ (Frontend / Admin Service) │ └──────────────────────┬──────────────────────────────┘ │ HTTPS ▼ ┌──────────────────────────────────────────────────────┐ │ chi Router + Middleware Stack │ │ ┌─────────────┐ ┌──────────┐ ┌───────────────────┐ │ │ │ RequestID │ │ Logger │ │ auth.Middleware() │ │ │ └─────────────┘ └──────────┘ └───────────────────┘ │ └──────────────────────┬───────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────┐ │ Handler Layer (internal/api/handlers/) │ │ ┌──────────────────────────────────────────────┐ │ │ │ PreferenceHandler │ │ │ │ - Get(w, r) error [GET .../{user_id}] │ │ │ │ - Update(w, r) error [PUT .../{user_id}] │ │ │ │ │ │ │ │ Responsibilities: │ │ │ │ - Extract {user_id} from URL │ │ │ │ - Bind & validate request body (strict) │ │ │ │ - Check auth: user_id == JWT user ID │ │ │ │ - Delegate to PreferenceService │ │ │ │ - Map domain errors to HTTP errors │ │ │ │ - Return response envelope │ │ │ └──────────────────────────────────────────────┘ │ └──────────────────────┬───────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────┐ │ Service Layer (internal/service/) │ │ ┌──────────────────────────────────────────────┐ │ │ │ PreferenceService │ │ │ │ - GetPreferences(ctx, userID) (*Prefs, err) │ │ │ │ - UpdatePreferences(ctx, userID, prefs) err │ │ │ │ │ │ │ │ Responsibilities: │ │ │ │ - Apply defaults when no stored prefs exist │ │ │ │ - Validate preference values (domain logic) │ │ │ │ - Delegate persistence to repository port │ │ │ └──────────────────────────────────────────────┘ │ └──────────────────────┬───────────────────────────────┘ │ calls port interface ▼ ┌──────────────────────────────────────────────────────┐ │ Port Layer (internal/port/) │ │ ┌──────────────────────────────────────────────┐ │ │ │ PreferenceRepository (interface) │ │ │ │ - Get(ctx, userID) (*Prefs, error) │ │ │ │ - Upsert(ctx, prefs *Prefs) error │ │ │ └──────────────────────────────────────────────┘ │ └──────────────────────┬───────────────────────────────┘ │ implemented by ┌────────┴────────┐ ▼ ▼ ┌────────────────────┐ ┌─────────────────────┐ │ Memory Adapter │ │ Postgres Adapter │ │ (tests) │ │ (production) │ │ │ │ │ │ map[string]*Prefs │ │ user_preferences │ │ │ │ table │ └────────────────────┘ └─────────────────────┘ ``` ## Error Handling Strategy ### Domain Errors ```go // domain/errors.go var ( ErrInvalidTheme = errors.New("invalid theme value") ErrInvalidLanguage = errors.New("invalid language value") ErrInvalidDigest = errors.New("invalid digest frequency") ) ``` Domain validation functions return these errors with descriptive messages. The service layer calls domain validation before persisting. ### Handler Error Mapping | Domain Error | HTTP Status | HTTP Code | Message | |---|---|---|---| | `ErrInvalidTheme` | 400 | `BAD_REQUEST` | "theme must be one of: light, dark, system" | | `ErrInvalidLanguage` | 400 | `BAD_REQUEST` | "language must be one of: en, fr, es, de, ja" | | `ErrInvalidDigest` | 400 | `BAD_REQUEST` | "notifications.digest must be one of: none, daily, weekly" | | Strict bind error (unknown fields) | 400 | `BAD_REQUEST` | Automatic from `app.BindAndValidateStrict` | | Validation tag failure | 400 | `VALIDATION_ERROR` | Per-field details from `httpvalidation` | | Auth user mismatch | 403 | `FORBIDDEN` | "access denied: cannot access another user's preferences" | | No auth token | 401 | `UNAUTHORIZED` | Automatic from `auth.Middleware()` | | DB connection failure | 500 | `INTERNAL_ERROR` | Logged; generic message to client | | DB query failure | 500 | `INTERNAL_ERROR` | Logged; generic message to client | ### Validation Approach Two layers of validation: 1. **Struct tag validation** via `app.BindAndValidateStrict()` — handles required fields, `oneof` constraints for enums, boolean type checking. Unknown JSON fields rejected automatically. 2. **Domain validation** via `UserPreferences.Validate()` — additional business rules if needed beyond struct tags. Keeps the domain layer self-contained. The struct tag approach handles most validation needs directly: ```go type UpdatePreferencesRequest struct { Theme string `json:"theme" validate:"required,oneof=light dark system"` Language string `json:"language" validate:"required,oneof=en fr es de ja"` Notifications UpdateNotificationsRequest `json:"notifications" validate:"required"` } type UpdateNotificationsRequest struct { Email bool `json:"email" validate:""` Push bool `json:"push" validate:""` Digest string `json:"digest" validate:"required,oneof=none daily weekly"` } ``` ## Security Considerations ### Authentication - Both endpoints sit behind `auth.Middleware()` — unauthenticated requests get 401 automatically. - JWT validation uses the existing `JWTValidator` with shared secret from `JWT_SECRET` env var. ### Authorization - **Self-access only for writes:** Handler extracts `auth.GetUser(ctx).ID` and compares to `{user_id}` URL parameter. Mismatch returns 403. - **Admin read access:** For GET only, if the authenticated user has role `admin` (checked via `user.HasRole("admin")`), they may read another user's preferences. This supports server-rendered contexts per the spec. - **No admin write:** PUT strictly requires self-access. Even admins cannot modify another user's preferences. ### Input Validation - Strict JSON binding rejects unknown fields (prevents key injection). - Enum validation via struct tags constrains values to allowed sets. - No SQL injection risk — using parameterized queries (`$1`, `$2` placeholders). - User ID from URL is only used as a query parameter, never interpolated into SQL. ### Data Exposure - Preferences contain no secrets or PII beyond the user ID. - Error messages do not leak internal state (DB errors logged server-side, generic message to client). ## Performance Considerations ### Expected Load - Preferences are read on every page load (high read frequency). - Preferences are updated infrequently (low write frequency). - Dataset is tiny per user (single row, ~6 columns). ### Query Complexity - GET: Single-row lookup by primary key (`user_id`) — O(1) with index. - PUT: Single-row upsert by primary key — O(1) with index. - No joins, no pagination, no aggregations. ### Caching Strategy - **Not needed for MVP.** Single-row PK lookups on PostgreSQL are sub-millisecond. The dataset fits entirely in PostgreSQL's buffer cache. - **Future optimization:** If load increases, add an in-process LRU cache with short TTL (e.g., 30 seconds). Cache invalidation on PUT is straightforward since writes go through the same service instance. ### Connection Pooling - Uses `DatabaseConfig` defaults: 25 max open connections, 5 max idle, 5-minute lifetime. Appropriate for the expected load. ## Migration / Rollout Plan ### Phase 1: Replace Scaffold (Single Deployment) Since the example CRUD endpoints have no consumers, the rollout is a clean replacement: 1. Remove all example domain/port/adapter/service/handler files 2. Add preference domain/port/adapter/service/handler files 3. Update `routes.go` to register new endpoints 4. Update `spec.go` with new OpenAPI documentation 5. Update `main.go` to wire PostgreSQL adapter and new dependency graph 6. Schema created at startup via `CREATE TABLE IF NOT EXISTS` ### Rollback - Revert the deployment to previous version. The `user_preferences` table persists harmlessly and can be dropped manually if needed. ### No Breaking Changes - No existing consumers depend on the example endpoints. - The health endpoint is preserved unchanged. - The service name, port, and route prefix remain the same. ## File Change Summary | File | Action | Description | |---|---|---| | `internal/domain/example.go` | **Delete** | Remove example entity | | `internal/domain/errors.go` | **Replace** | Preference-specific domain errors | | `internal/domain/preferences.go` | **Create** | `UserPreferences` type, validation, defaults | | `internal/port/example.go` | **Delete** | Remove example port | | `internal/port/preferences.go` | **Create** | `PreferenceRepository` interface | | `internal/adapter/memory/example.go` | **Delete** | Remove example memory adapter | | `internal/adapter/memory/preferences.go` | **Create** | In-memory preference adapter (tests) | | `internal/adapter/postgres/preferences.go` | **Create** | PostgreSQL preference adapter | | `internal/service/example.go` | **Delete** | Remove example service | | `internal/service/example_test.go` | **Delete** | Remove example service tests | | `internal/service/preferences.go` | **Create** | `PreferenceService` with business logic | | `internal/service/preferences_test.go` | **Create** | Service unit tests | | `internal/api/handlers/example.go` | **Delete** | Remove example handlers | | `internal/api/handlers/example_test.go` | **Delete** | Remove example handler tests | | `internal/api/handlers/preferences.go` | **Create** | GET/PUT preference handlers | | `internal/api/handlers/preferences_test.go` | **Create** | Handler tests with in-memory adapter | | `internal/api/routes.go` | **Modify** | Replace example routes with preference routes | | `internal/api/spec.go` | **Modify** | Replace OpenAPI spec for preference endpoints | | `cmd/server/main.go` | **Modify** | Wire PostgreSQL adapter and preference service | | `internal/config/config.go` | **Modify** | Ensure database config is loaded (may already be) | ## Key Design Decisions 1. **Flat columns over JSONB**: Preferences are a fixed, small set. Flat columns give type safety, simpler queries, and no need for JSON path operations. 2. **Strict binding for unknown key rejection**: Using `app.BindAndValidateStrict()` naturally rejects unknown JSON fields, satisfying the spec requirement without custom validation code. 3. **Defaults in domain, not DB**: The `DefaultPreferences()` function lives in the domain layer. When the repository returns "not found", the service returns defaults. This keeps default logic testable and independent of storage. 4. **No separate "exists" check**: GET returns defaults for non-existent users (200, not 404). PUT uses `INSERT ... ON CONFLICT UPDATE` for atomic upsert. No need for existence checks. 5. **Authorization in handler, not middleware**: The user ID comparison is endpoint-specific logic (matching URL param to JWT), not a reusable middleware concern. Keeping it in the handler is simpler and more explicit. 6. **Admin read via role check**: Uses the existing `auth.User.HasRole("admin")` mechanism. No new auth infrastructure needed. 7. **Schema creation at startup**: `CREATE TABLE IF NOT EXISTS` in the adapter's constructor. Simple, idempotent, no migration tool dependency for a single new table.