17 KiB
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
UserPreferencesentity with validation for known preference keys and values - Port layer:
PreferencesRepositoryinterface for persistence - Adapter layer: PostgreSQL repository implementation using
pkg/database(replaces in-memory) - Service layer:
PreferencesServicewith 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_preferencestable 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
-- 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_idas 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
// 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 valuesValidatePreferenceKey(key string) error— checks key is in the allowed setValidatePreferenceValue(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):
{
"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):
{
"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:
{
"preferences": {
"theme": "dark",
"notifications_enabled": false
}
}
Response 200 (returns full merged preferences):
{
"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
// 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
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: <key>" |
ErrInvalidPreferenceValue |
400 Bad Request | "invalid value for <key>: <reason>" |
| 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
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.MustConnectpanics 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.MustRunMigrationspanics 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):
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:
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
- Path parameter: UUID format validated via
uuid.Parse(). - Request body:
app.BindAndValidate()ensurespreferencesfield is present. - Preference keys: Domain layer rejects any key not in
{theme, language, notifications_enabled}. - 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
- JSONB injection: PostgreSQL parameterized queries prevent SQL injection. Go's
encoding/jsonhandles 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 CONFLICToperates 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:
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:
- Read
DatabaseConfigfrom config. - Connect to PostgreSQL via
database.MustConnect(). - Run migrations via
database.MustRunMigrations(). - Create
postgres.PreferencesRepositorywith the DB pool. - Create
PreferencesServicewith the postgres repository. - 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:
-
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. -
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. -
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.
-
Removing the scaffold: Yes — all Example scaffold code is removed and replaced with preferences code. The scaffold served its purpose as a template.