# Design: User Preferences API ## Architecture Approach The feature replaces the existing example/scaffold CRUD resource in `preferences-api` with a real user preferences domain. The hexagonal architecture already in place is preserved — only the inner layers change. **What changes:** - **Domain layer** — New `UserPreferences` entity with validation, replacing `Example` - **Port layer** — New `PreferencesRepository` interface, replacing `ExampleRepository` - **Service layer** — New `PreferencesService` with get/upsert logic, replacing `ExampleService` - **Adapter layer** — New PostgreSQL adapter (replacing in-memory `Example` adapter) - **Handler layer** — Two new handlers (GET, PUT), replacing five example handlers - **Routes** — New authenticated route group at `/api/preferences-api/preferences/{user_id}` - **OpenAPI spec** — Updated with preferences schemas and endpoints - **Migrations** — New SQL migration for `user_preferences` table - **main.go** — Updated to wire database pool and new dependencies **What is removed:** - All `example` domain, port, service, adapter, handler, and test code - The in-memory adapter (production uses PostgreSQL) **What is unchanged:** - Health check handler and route - `config/config.go` (already supports DATABASE_URL, AUTH_ENABLED, JWT_SECRET) - Dockerfile, Makefile, component.yaml, go.mod structure ## Data Model Changes ### New Domain Types ```go // domain/preferences.go type UserID string type NotificationPreferences struct { Email bool Push bool SMS bool } type Preferences struct { Theme string Language string Notifications NotificationPreferences } type UserPreferences struct { UserID UserID Preferences Preferences UpdatedAt time.Time } ``` ### Default Values When no preferences exist for a user, the service returns defaults: | Key | Default | |-----|---------| | `theme` | `"system"` | | `language` | `"en"` | | `notifications.email` | `true` | | `notifications.push` | `true` | | `notifications.sms` | `false` | Defaults are defined as a function in the domain layer (`DefaultPreferences()`) — the single source of truth. ### Database Schema **Table: `user_preferences`** ```sql CREATE TABLE IF NOT EXISTS user_preferences ( user_id TEXT PRIMARY KEY, preferences JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` **Design decisions:** - `user_id` as `TEXT PRIMARY KEY` — no UUID type constraint; IDs come from the auth system - `preferences` as `JSONB` — single document per user, supporting the spec's extensibility requirement (unknown keys preserved) - `created_at` included for operational debugging even though it's not exposed in the API - No foreign key to a users table — preferences-api is a standalone service ### Migration File `migrations/001_create_user_preferences.sql` Single idempotent migration using `IF NOT EXISTS`. Embedded via `//go:embed` per project convention. ## API Changes ### Endpoints | Method | Path | Auth | Description | |--------|------|------|-------------| | `GET` | `/api/preferences-api/preferences/{user_id}` | Required | Get preferences (returns defaults if none saved) | | `PUT` | `/api/preferences-api/preferences/{user_id}` | Required | Create or replace preferences | ### GET `/api/preferences-api/preferences/{user_id}` **Authorization:** Authenticated user's ID must match `{user_id}`, else 403. **Behavior:** 1. Extract `user_id` from URL path 2. Verify authenticated user matches `user_id` 3. Query database for preferences 4. If no row exists, return default preferences 5. Return response with `{data, meta}` envelope **Response (200):** ```json { "data": { "user_id": "usr_abc123", "preferences": { "theme": "dark", "language": "en", "notifications": { "email": true, "push": true, "sms": false } }, "updated_at": "2026-02-08T10:30:00Z" }, "meta": { "request_id": "...", "timestamp": "..." } } ``` **When no preferences saved (200 with defaults):** ```json { "data": { "user_id": "usr_abc123", "preferences": { "theme": "system", "language": "en", "notifications": { "email": true, "push": true, "sms": false } }, "updated_at": "0001-01-01T00:00:00Z" }, "meta": { ... } } ``` The `updated_at` zero value signals "never saved". Alternatively, it could be omitted when returning defaults — but including it keeps the response shape consistent. ### PUT `/api/preferences-api/preferences/{user_id}` **Authorization:** Authenticated user's ID must match `{user_id}`, else 403. **Behavior:** 1. Extract `user_id` from URL path 2. Verify authenticated user matches `user_id` 3. Bind and validate request body 4. Run domain validation on known keys 5. Upsert into database (INSERT ON CONFLICT UPDATE) 6. Return saved preferences with `{data, meta}` envelope **Request body:** ```json { "preferences": { "theme": "dark", "language": "en", "notifications": { "email": true, "push": true, "sms": false } } } ``` **Validation rules (domain layer):** - `theme`: Must be one of `"light"`, `"dark"`, `"system"` — if present - `language`: Max 10 characters — if present - Unknown top-level keys in `preferences`: preserved (per spec extensibility requirement) - `notifications` sub-keys: booleans, no special validation needed (Go zero-value is `false`) **Response (200):** Same shape as GET response, with the just-saved data and current timestamp. **Error (400):** ```json { "error": { "code": "BAD_REQUEST", "message": "invalid theme: must be one of light, dark, system" }, "meta": { ... } } ``` ## Component Diagram ``` ┌──────────────────────────────────────────────────────────────────┐ │ HTTP Layer │ │ │ │ auth.Middleware() ──▶ handlers.Preferences │ │ │ │ │ GET /preferences/{user_id} ──▶ Get() ──▶ httpresponse.OK() │ │ PUT /preferences/{user_id} ──▶ Put() ──▶ httpresponse.OK() │ │ │ │ │ mapDomainError() ──▶ httperror.* │ └────────────────┬─────────────────────────────────────────────────┘ │ ┌────────────────▼─────────────────────────────────────────────────┐ │ Service Layer │ │ │ │ PreferencesService │ │ ├── GetPreferences(ctx, userID) → *UserPreferences, error │ │ │ └── returns defaults if repo returns ErrNotFound │ │ └── SetPreferences(ctx, userID, prefs) → *UserPreferences, err│ │ └── validates, then upserts via repo │ └────────────────┬─────────────────────────────────────────────────┘ │ ┌────────────────▼─────────────────────────────────────────────────┐ │ Port Layer (Interface) │ │ │ │ PreferencesRepository │ │ ├── Get(ctx, userID) → *UserPreferences, error │ │ └── Upsert(ctx, prefs *UserPreferences) → error │ └────────────────┬─────────────────────────────────────────────────┘ │ ┌────────────────▼─────────────────────────────────────────────────┐ │ Adapter Layer (PostgreSQL) │ │ │ │ postgres.PreferencesRepository │ │ ├── Get() → SELECT ... WHERE user_id = $1 │ │ └── Upsert() → INSERT ... ON CONFLICT (user_id) │ │ DO UPDATE SET preferences = $2, updated_at = $3│ │ │ │ Uses: database.Pool.DB (*sqlx.DB) │ └────────────────┬─────────────────────────────────────────────────┘ │ ┌────────────────▼─────────────────────────────────────────────────┐ │ Domain Layer (Pure) │ │ │ │ UserPreferences, Preferences, NotificationPreferences │ │ DefaultPreferences() │ │ Validate() → error │ │ ErrInvalidTheme, ErrInvalidLanguage, ErrForbidden │ └──────────────────────────────────────────────────────────────────┘ ``` ## Layer-by-Layer Implementation Details ### Domain (`internal/domain/`) **Files to create:** - `preferences.go` — Types, constructors, `DefaultPreferences()`, `Validate()` - `errors.go` — Updated with `ErrInvalidTheme`, `ErrInvalidLanguage`, `ErrForbidden`, `ErrPreferencesNotFound` **Files to delete:** - `example.go` **Validation logic in `Preferences.Validate()`:** ```go func (p *Preferences) Validate() error { if p.Theme != "" { switch p.Theme { case "light", "dark", "system": // valid default: return ErrInvalidTheme } } if len([]rune(p.Language)) > 10 { return ErrInvalidLanguage } return nil } ``` Unknown keys: The spec says unknown keys are preserved but not validated. Since we store the full JSON document in a JSONB column, unknown keys survive naturally. The `Preferences` struct uses a map for extensibility: ```go type Preferences struct { Theme string `json:"theme"` Language string `json:"language"` Notifications NotificationPreferences `json:"notifications"` Extra map[string]any `json:"-"` // captured via custom marshal/unmarshal } ``` A custom `UnmarshalJSON`/`MarshalJSON` pair on `Preferences` decodes known fields into struct fields and captures everything else into `Extra`. On marshal, known fields and `Extra` are merged back. This preserves unknown keys through the round-trip without schema migrations. ### Port (`internal/port/`) **Files to create:** - `preferences.go` — `PreferencesRepository` interface **Files to delete:** - `example.go` ```go type PreferencesRepository interface { Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) Upsert(ctx context.Context, prefs *domain.UserPreferences) error } ``` Only two methods needed — no List, Delete, or ExistsByName. The simple interface keeps the adapter thin. ### Service (`internal/service/`) **Files to create:** - `preferences.go` — `PreferencesService` - `preferences_test.go` — Unit tests **Files to delete:** - `example.go` - `example_test.go` ```go type PreferencesService struct { repo port.PreferencesRepository logger *logging.Logger } func (s *PreferencesService) GetPreferences(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) func (s *PreferencesService) SetPreferences(ctx context.Context, userID domain.UserID, prefs domain.Preferences) (*domain.UserPreferences, error) ``` **GetPreferences logic:** 1. Call `repo.Get(ctx, userID)` 2. If `ErrPreferencesNotFound`, return `DefaultPreferences()` with the given `userID` 3. Otherwise return the stored preferences **SetPreferences logic:** 1. Call `prefs.Validate()` — return domain error if invalid 2. Build `UserPreferences{UserID: userID, Preferences: prefs, UpdatedAt: time.Now().UTC()}` 3. Call `repo.Upsert(ctx, &userPrefs)` 4. Return the saved preferences Authorization (checking user_id matches authenticated user) is done in the **handler layer**, not here — the service layer doesn't know about HTTP or JWT. This follows the existing pattern where `mapDomainError()` in handlers maps domain errors to HTTP errors. ### Adapter (`internal/adapter/postgres/`) **Files to create:** - `preferences.go` — PostgreSQL implementation of `PreferencesRepository` **Files to delete:** - `adapter/memory/example.go` ```go type PreferencesRepository struct { db *sqlx.DB logger *logging.Logger } func (r *PreferencesRepository) Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) { // SELECT user_id, preferences, updated_at FROM user_preferences WHERE user_id = $1 // If no rows: return domain.ErrPreferencesNotFound // Unmarshal JSONB into domain.Preferences } func (r *PreferencesRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error { // INSERT INTO user_preferences (user_id, preferences, updated_at) // VALUES ($1, $2, $3) // ON CONFLICT (user_id) DO UPDATE SET preferences = $2, updated_at = $3 // Marshal domain.Preferences to JSON for JSONB column } ``` ### Handlers (`internal/api/handlers/`) **Files to create:** - `preferences.go` — GET and PUT handlers - `preferences_test.go` — Handler tests **Files to delete:** - `example.go` - `example_test.go` ```go type Preferences struct { svc *service.PreferencesService logger *logging.Logger } ``` **Request/Response types:** ```go type PutPreferencesRequest struct { Preferences PreferencesPayload `json:"preferences" validate:"required"` } type PreferencesPayload struct { Theme string `json:"theme,omitempty"` Language string `json:"language,omitempty"` Notifications *NotificationPreferencesPayload `json:"notifications,omitempty"` } type NotificationPreferencesPayload struct { Email bool `json:"email"` Push bool `json:"push"` SMS bool `json:"sms"` } type PreferencesResponse struct { UserID string `json:"user_id"` Preferences PreferencesPayload `json:"preferences"` UpdatedAt string `json:"updated_at"` } ``` **Handler: Get** ```go func (h *Preferences) Get(w http.ResponseWriter, r *http.Request) error { userID := chi.URLParam(r, "user_id") // Authorization check authUser := auth.GetUser(r.Context()) if authUser.ID != userID { return httperror.Forbidden("access denied: can only access own preferences") } prefs, err := h.svc.GetPreferences(r.Context(), domain.UserID(userID)) if err != nil { return mapDomainError(err) } return httpresponse.OK(w, r, toResponse(prefs)) } ``` **Handler: Put** ```go func (h *Preferences) Put(w http.ResponseWriter, r *http.Request) error { userID := chi.URLParam(r, "user_id") // Authorization check authUser := auth.GetUser(r.Context()) if authUser.ID != userID { return httperror.Forbidden("access denied: can only modify own preferences") } var req PutPreferencesRequest if err := app.BindAndValidate(r, &req); err != nil { return err } prefs, err := h.svc.SetPreferences(r.Context(), domain.UserID(userID), toDomain(req.Preferences)) if err != nil { return mapDomainError(err) } return httpresponse.OK(w, r, toResponse(prefs)) } ``` **Error mapping:** ```go func mapDomainError(err error) error { switch { case errors.Is(err, domain.ErrInvalidTheme): return httperror.BadRequest("invalid theme: must be one of light, dark, system") case errors.Is(err, domain.ErrInvalidLanguage): return httperror.BadRequest("invalid language: must be at most 10 characters") default: return err // app.Wrap() will handle as 500 } } ``` ### Routes (`internal/api/routes.go`) Replace example routes with: ```go func RegisterRoutes(application *app.App, prefsSvc *service.PreferencesService, authCfg config.Config) { logger := application.Logger() healthHandler := &handlers.Health{Logger: logger} prefsHandler := handlers.NewPreferences(prefsSvc, logger) r := application.Router() // Public routes r.Get("/api/preferences-api/health", healthHandler.Check) // Protected routes — auth required for all preference endpoints r.Route("/api/preferences-api/preferences", func(r chi.Router) { if authCfg.AuthEnabled { r.Use(auth.Middleware(auth.MiddlewareConfig{ Validator: auth.NewJWTValidator(auth.JWTConfig{ Secret: []byte(authCfg.JWTSecret), }), })) } r.Get("/{user_id}", app.Wrap(prefsHandler.Get)) r.Put("/{user_id}", app.Wrap(prefsHandler.Put)) }) } ``` ### OpenAPI Spec (`internal/api/spec.go`) Update to define: - Schema: `Preferences` (theme, language, notifications) - Schema: `NotificationPreferences` (email, push, sms) - Schema: `UserPreferencesResponse` (user_id, preferences, updated_at) - Schema: `PutPreferencesRequest` (preferences object) - Path: `GET /api/preferences-api/preferences/{user_id}` with bearer auth, 200/403 responses - Path: `PUT /api/preferences-api/preferences/{user_id}` with bearer auth, 200/400/403 responses - `{user_id}` path parameter ### main.go (`cmd/server/main.go`) Update to: 1. Connect to PostgreSQL using `database.Connect()` 2. Run migrations using `database.MustRunMigrations()` 3. Create `postgres.PreferencesRepository` with `pool.DB` 4. Create `PreferencesService` with the repo 5. Register routes with service and auth config 6. Register `pool.Close()` on shutdown ## Error Handling Strategy | Scenario | Layer | Error | HTTP Status | |----------|-------|-------|-------------| | Invalid theme value | Domain | `ErrInvalidTheme` | 400 Bad Request | | Language too long | Domain | `ErrInvalidLanguage` | 400 Bad Request | | Malformed JSON body | Handler (BindAndValidate) | Automatic | 400 Bad Request | | Missing `preferences` field | Handler (BindAndValidate) | Validation | 400 Bad Request | | User accessing another user's prefs | Handler | `httperror.Forbidden` | 403 Forbidden | | No preferences saved yet | Service | (returns defaults) | 200 OK | | Database connection failure | Adapter | raw error | 500 Internal | | Database query failure | Adapter | raw error | 500 Internal | **Key decisions:** - GET never returns 404 — missing preferences yield defaults. This simplifies the frontend (no special "first time" flow). - Authorization is checked in handlers before any service call, failing fast with 403. - Domain validation errors are specific and mapped to descriptive 400 messages. - Database errors bubble up as raw errors, caught by `app.Wrap()` and returned as 500 with the error logged server-side (not leaked to client). ## Security Considerations ### Authentication - Both endpoints require `auth.Middleware()`. Unauthenticated requests receive 401. - JWT validation via `pkg/auth.NewJWTValidator` with HMAC secret from config. ### Authorization - **Owner-only access**: The `{user_id}` in the URL path must match `auth.GetUser(ctx).ID`. This is checked in the handler before calling the service layer. - No admin override endpoint (out of scope per spec). ### Input Validation - Request body bound and validated via `app.BindAndValidate()` — rejects malformed JSON and missing required fields. - Domain-level validation for `theme` (enum) and `language` (max length). - JSONB column stores raw preferences — unknown keys preserved but size is bounded by PostgreSQL's TOAST limit (~1GB). For practical limits, the handler can check `Content-Length` against a reasonable threshold (e.g., 64KB). This addresses the spec's open question about preference size limits. ### Data Boundaries - Users can only read/write their own preferences — no cross-user data access. - Error responses never leak internal details (database errors, stack traces). - The `preferences` JSONB column is treated as opaque by the database — no SQL injection vector. ### SQL Injection - All queries use parameterized statements (`$1`, `$2`) via `sqlx` — no string concatenation. ## Performance Considerations ### Expected Load - Read-heavy workload: preferences fetched on every page load / session start. - Writes are infrequent: users change preferences rarely. ### Query Performance - **GET**: Single-row lookup by primary key (`user_id`) — O(1) with B-tree index. - **PUT**: Upsert by primary key — O(1). - No need for additional indexes. The primary key index is sufficient. ### Caching Strategy - **Not implemented in this iteration** (out of scope). The single-row PK lookup is fast enough. - If needed later: HTTP `Cache-Control` headers or an in-process cache with short TTL. ### Connection Pooling - Uses `database.Pool` with configurable pool size (default: 25 open, 5 idle). Adequate for preferences traffic. ### Payload Size - Preferences JSON is small (< 1KB typical). No pagination or streaming needed. ## Migration / Rollout Plan ### Step 1: Database Migration The `001_create_user_preferences.sql` migration runs on startup via `database.MustRunMigrations()`. It uses `CREATE TABLE IF NOT EXISTS` for idempotency. No existing tables are modified or dropped. ### Step 2: Code Deployment The service is fully backward-compatible at the infrastructure level: - Same port (8001) - Same health check path (`/api/preferences-api/health`) - Example endpoints are removed, but nothing depends on them (they're scaffold) ### Step 3: Verification - Health check confirms service starts and database is reachable - GET returns default preferences for any authenticated user (no data seeding needed) - PUT creates preferences on first save ### Rollback - Revert to previous deployment. The `user_preferences` table can remain — it won't interfere with the example scaffold code. - No destructive migrations — forward-only table creation. ## Open Questions Resolution From the spec: 1. **Authorization model**: Design uses `auth.GetUser(ctx).ID` — the `User.ID` field populated by JWT validation. This maps to the `sub` claim or `uid` custom claim (both supported by `pkg/auth.JWTClaims`). No changes to auth package needed. 2. **Unknown preference keys**: Preserved via custom JSON marshaling on the `Preferences` struct. Known keys are validated; unknown keys pass through to JSONB storage unchanged. 3. **Preference size limit**: Addressed by checking request `Content-Length` in the handler (64KB default). This prevents abuse without requiring schema changes. ## File Change Summary | Action | File | |--------|------| | **Create** | `migrations/001_create_user_preferences.sql` | | **Create** | `internal/domain/preferences.go` | | **Create** | `internal/port/preferences.go` | | **Create** | `internal/service/preferences.go` | | **Create** | `internal/service/preferences_test.go` | | **Create** | `internal/adapter/postgres/preferences.go` | | **Create** | `internal/api/handlers/preferences.go` | | **Create** | `internal/api/handlers/preferences_test.go` | | **Modify** | `internal/domain/errors.go` | | **Modify** | `internal/api/routes.go` | | **Modify** | `internal/api/spec.go` | | **Modify** | `cmd/server/main.go` | | **Delete** | `internal/domain/example.go` | | **Delete** | `internal/port/example.go` | | **Delete** | `internal/service/example.go` | | **Delete** | `internal/service/example_test.go` | | **Delete** | `internal/adapter/memory/example.go` | | **Delete** | `internal/api/handlers/example.go` | | **Delete** | `internal/api/handlers/example_test.go` |