21 KiB
Design: User Preferences API
Architecture Approach
Replace the existing example CRUD scaffold in services/preferences-api/ with a real user preferences domain. The hexagonal architecture layers remain identical in structure — only the domain model, service logic, port interface, adapter implementation, handlers, routes, and OpenAPI spec change.
What changes:
- Domain layer — Remove
Exampleentity; addPreferencevalue object andUserPreferencesaggregate with defaults/validation - Service layer — Remove
ExampleService; addPreferenceServicewith get-with-defaults and upsert-with-validation logic - Port layer — Remove
ExampleRepository; addPreferenceRepositoryinterface for DB operations - Adapter layer — Remove in-memory adapter; add PostgreSQL adapter using
pkg/database(sqlx) - Handler layer — Remove example handlers; add
GETandPUTpreference handlers - Routes — Replace
/examplesroutes with/preferences/{user_id}routes - OpenAPI spec — Replace example schemas/paths with preference schemas/paths
- Migrations — Add
001_create_user_preferences.sql - main.go — Wire database connection, run migrations, inject PostgreSQL adapter
What stays the same:
- Service port (8001), health endpoint, config structure, auth middleware pattern
- All
pkg/*dependencies used identically to the scaffold - Test patterns (mock repository for service tests, chi router for handler tests)
Data Model Changes
Domain Types
// internal/domain/preference.go
// Known preference keys with their types and defaults
type PreferenceKey string
const (
KeyTheme PreferenceKey = "theme"
KeyLanguage PreferenceKey = "language"
KeyNotificationsEnabled PreferenceKey = "notifications_enabled"
)
// PreferenceDefinition describes a known preference key
type PreferenceDefinition struct {
Key PreferenceKey
DefaultValue string
Validate func(value string) error
}
// UserPreferences is the aggregate representing all preferences for a user
type UserPreferences struct {
UserID string
Preferences map[PreferenceKey]string // key -> serialized value
}
Database Schema
Single migration file: services/preferences-api/migrations/001_create_user_preferences.sql
CREATE TABLE 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);
Each preference is a separate row. This is an EAV (entity-attribute-value) pattern that allows adding new preference keys without schema changes.
Value Serialization
All values stored as TEXT in the database. Serialization rules:
theme— stored as-is ("light","dark","system")language— stored as-is ("en","fr", etc.)notifications_enabled— stored as"true"or"false", deserialized to JSON boolean in responses
API Changes
Removed Endpoints
GET /api/preferences-api/examples— removedGET /api/preferences-api/examples/{id}— removedPOST /api/preferences-api/examples— removedPUT /api/preferences-api/examples/{id}— removedDELETE /api/preferences-api/examples/{id}— removed
New Endpoints
GET /api/preferences-api/preferences/{user_id}
Returns all preferences for a user, merging stored values with server-defined defaults.
- Path param:
user_id— UUID format, validated - Auth: In auth-protectable route group (enforcement opt-in via
AUTH_ENABLED) - Response 200:
{
"data": {
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"preferences": {
"theme": "dark",
"language": "en",
"notifications_enabled": true
}
},
"meta": { "request_id": "...", "timestamp": "..." }
}
- Response 400: Invalid
user_idformat
PUT /api/preferences-api/preferences/{user_id}
Creates or updates preferences for the given user. Only provided keys are updated; omitted keys retain their current value or default.
- Path param:
user_id— UUID format, validated - Auth: In auth-protectable route group (enforcement opt-in via
AUTH_ENABLED) - Request body:
{
"preferences": {
"theme": "dark",
"language": "fr"
}
}
- Response 200: Same shape as GET (returns full merged preferences after update)
- Response 400: Invalid
user_id, unknown preference key, or invalid preference value
Kept Endpoints
GET /api/preferences-api/health— unchanged
Component Diagram
┌─────────────────────────────────────────────────────────┐
│ HTTP Layer │
│ │
│ GET /preferences/{user_id} PUT /preferences/{user_id}│
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ PreferenceHandler │ │
│ │ - Validates user_id (UUID) │ │
│ │ - Binds PUT request body │ │
│ │ - Maps domain errors → HTTP errors │ │
│ │ - Returns envelope responses │ │
│ └──────────────┬───────────────────────────┘ │
└─────────────────┼───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Service Layer │
│ ┌──────────────────────────────────────────┐ │
│ │ PreferenceService │ │
│ │ - GetPreferences(userID): │ │
│ │ fetch stored → merge defaults │ │
│ │ - UpdatePreferences(userID, prefs): │ │
│ │ validate keys → validate values │ │
│ │ → upsert → fetch merged result │ │
│ └──────────────┬───────────────────────────┘ │
└─────────────────┼───────────────────────────────────────┘
│ uses port interface
▼
┌─────────────────────────────────────────────────────────┐
│ Port Layer (Interface) │
│ ┌──────────────────────────────────────────┐ │
│ │ PreferenceRepository (interface) │ │
│ │ - GetByUserID(ctx, userID) │ │
│ │ → []PreferenceRow, error │ │
│ │ - Upsert(ctx, userID, key, value) │ │
│ │ → error │ │
│ └──────────────────────────────────────────┘ │
└─────────────────┼───────────────────────────────────────┘
│ implemented by
▼
┌─────────────────────────────────────────────────────────┐
│ Adapter Layer (PostgreSQL) │
│ ┌──────────────────────────────────────────┐ │
│ │ PostgresPreferenceRepository │ │
│ │ - Uses sqlx via pkg/database │ │
│ │ - GetByUserID: SELECT WHERE user_id=? │ │
│ │ - Upsert: INSERT ON CONFLICT UPDATE │ │
│ └──────────────┬───────────────────────────┘ │
└─────────────────┼───────────────────────────────────────┘
│
▼
┌───────────┐
│ PostgreSQL │
│ user_ │
│ preferences│
└───────────┘
Detailed Layer Design
Domain Layer (internal/domain/)
Files to create:
preference.go— Preference types, definitions, validation, defaultserrors.go— Keep file, replace example errors with preference errors
preference.go responsibilities:
- Define
PreferenceKeyconstants for known keys - Define
PreferenceDefinitionregistry with default values and per-key validators - Provide
DefaultPreferences()returning all keys with default values - Provide
ValidateKey(key string) error— returns error if key is unknown - Provide
ValidateValue(key PreferenceKey, value string) error— runs per-key validator - Provide
MergeWithDefaults(stored map[PreferenceKey]string) map[PreferenceKey]string - Provide
SerializeForResponse(prefs map[PreferenceKey]string) map[string]any— converts"true"/"false"to booleans for JSON
Validation rules:
theme: must be one oflight,dark,systemlanguage: must match BCP 47 format (regex:^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*$)notifications_enabled: must be"true"or"false"
Domain errors:
ErrUnknownPreferenceKey— unknown key in PUT requestErrInvalidPreferenceValue— value fails validation for its keyErrInvalidUserID— user_id is not a valid UUID
Port Layer (internal/port/)
File to create:
preference.go— Replaceexample.go
type PreferenceRow struct {
UserID string
Key string
Value string
CreatedAt time.Time
UpdatedAt time.Time
}
type PreferenceRepository interface {
GetByUserID(ctx context.Context, userID string) ([]PreferenceRow, error)
Upsert(ctx context.Context, userID string, key string, value string) error
}
The interface is minimal — no delete, no list-all-users. The service layer handles merging with defaults and batch upserts by calling Upsert in a loop (or a single batch query in the adapter).
Service Layer (internal/service/)
File to create:
preference.go— Replaceexample.gopreference_test.go— Replaceexample_test.go
PreferenceService methods:
func (s *PreferenceService) GetPreferences(ctx context.Context, userID string) (*PreferencesResult, error)
- Validate
userIDis a valid UUID → returnErrInvalidUserIDif not - Call
repo.GetByUserID(ctx, userID)to get stored rows - Convert rows to
map[PreferenceKey]string - Merge with defaults via
domain.MergeWithDefaults() - Return result with serialized preferences
func (s *PreferenceService) UpdatePreferences(ctx context.Context, userID string, input map[string]any) (*PreferencesResult, error)
- Validate
userIDis a valid UUID → returnErrInvalidUserIDif not - For each key in input:
- Validate key is known → return
ErrUnknownPreferenceKeyif not - Serialize value to string (booleans to
"true"/"false") - Validate value → return
ErrInvalidPreferenceValueif invalid
- Validate key is known → return
- For each validated key-value pair, call
repo.Upsert(ctx, userID, key, value) - Fetch and return full merged preferences (same as GetPreferences)
PreferencesResult:
type PreferencesResult struct {
UserID string
Preferences map[string]any // Serialized for JSON (booleans as bool, strings as string)
}
Adapter Layer (internal/adapter/postgres/)
File to create:
preference.go— PostgreSQL implementation ofPreferenceRepository
Queries:
GetByUserID:SELECT key, value, created_at, updated_at FROM user_preferences WHERE user_id = $1Upsert:INSERT INTO user_preferences (user_id, key, value, created_at, updated_at) VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
Uses sqlx from pkg/database pool.
Handler Layer (internal/api/handlers/)
File to create:
preference.go— Replaceexample.gopreference_test.go— Replaceexample_test.go
Handler struct:
type PreferenceHandler struct {
service *service.PreferenceService
logger *logging.Logger
}
GET handler (GetPreferences):
- Extract
user_idfrom URL viachi.URLParam(r, "user_id") - Call
service.GetPreferences(ctx, userID) - Map domain errors:
ErrInvalidUserID→httperror.BadRequest - Return
httpresponse.OK(w, r, response)
PUT handler (UpdatePreferences):
- Extract
user_idfrom URL viachi.URLParam(r, "user_id") - Bind request body with
app.Bind(r, &req)(not BindAndValidate — custom validation in service) - Call
service.UpdatePreferences(ctx, userID, req.Preferences) - Map domain errors:
ErrInvalidUserID→httperror.BadRequestErrUnknownPreferenceKey→httperror.BadRequestErrInvalidPreferenceValue→httperror.BadRequest
- Return
httpresponse.OK(w, r, response)
Request type:
type UpdatePreferencesRequest struct {
Preferences map[string]any `json:"preferences"`
}
Response type:
type PreferencesResponse struct {
UserID string `json:"user_id"`
Preferences map[string]any `json:"preferences"`
}
Routes (internal/api/routes.go)
Replace example routes with:
// Public
r.Get("/api/preferences-api/health", app.Wrap(healthHandler.Check))
// Preferences (auth-protectable)
r.Route("/api/preferences-api", func(r chi.Router) {
if cfg.AuthEnabled {
r.Use(auth.Middleware(...))
}
r.Get("/preferences/{user_id}", app.Wrap(prefHandler.GetPreferences))
r.Put("/preferences/{user_id}", app.Wrap(prefHandler.UpdatePreferences))
})
Entry Point (cmd/server/main.go)
Changes:
- Add database connection via
database.MustConnect() - Embed and run migrations via
database.MustRunMigrations() - Create
postgres.NewPreferenceRepository(pool)instead of memory adapter - Create
service.NewPreferenceService(repo, logger)instead of example service - Register new routes
- Add DB pool shutdown hook via
app.OnShutdown()
OpenAPI Spec (internal/api/spec.go)
Replace example schemas with:
UserPreferencesschema — user_id (UUID) + preferences objectUpdatePreferencesRequestschema — preferences object with known keysGET /preferences/{user_id}— 200, 400PUT /preferences/{user_id}— 200, 400
Error Handling Strategy
| Error Source | Domain Error | HTTP Error | Status Code |
|---|---|---|---|
| Invalid user_id format | ErrInvalidUserID |
httperror.BadRequest |
400 |
| Unknown preference key | ErrUnknownPreferenceKey |
httperror.BadRequest |
400 |
| Invalid preference value | ErrInvalidPreferenceValue |
httperror.BadRequest |
400 |
| Malformed JSON body | (from app.Bind) |
httperror.BadRequest |
400 |
| Database connection failure | raw error | httperror.Internal (via Wrap) |
500 |
| Database query failure | raw error | httperror.Internal (via Wrap) |
500 |
| User has no stored preferences | Not an error | Returns defaults | 200 |
Key decisions:
- GET for a nonexistent user returns 200 with all defaults — not 404. This simplifies client logic and matches the spec.
- All validation errors return 400 with a descriptive message including the offending key/value.
- Database errors are not exposed to clients — Wrap converts them to generic 500.
Security Considerations
-
Authentication: Endpoints are placed in an auth-protectable route group. When
AUTH_ENABLED=true, JWT middleware is applied. When false, endpoints are open. This matches the existing scaffold pattern. -
Authorization: No user_id-to-token enforcement in this feature (per spec's open question #1). Any authenticated user can read/write any user's preferences. This is acceptable for the initial implementation and can be tightened later with a middleware check.
-
Input validation:
user_idvalidated as UUID format before any DB query — prevents injection- Preference keys validated against a whitelist — no arbitrary key creation
- Preference values validated per-key with strict rules — no freeform text in constrained fields
- Request body bound via
app.Bind()which usesjson.Decoder— safe JSON parsing
-
SQL injection: All queries use parameterized statements via sqlx (
$1,$2placeholders). No string interpolation in SQL. -
Data exposure: The API only returns preferences for the requested user_id. No list-all-users endpoint. No sensitive data in preference values (theme, language, notification toggle).
-
Rate limiting: Not in scope for this feature but can be added via middleware later.
Performance Considerations
-
Query complexity: Both queries are simple —
SELECT WHERE user_idandINSERT ON CONFLICT. The primary key(user_id, key)and the index onuser_idensure O(log n) lookups. -
Expected data volume: Each user has at most 3 preference rows (currently). Even with millions of users, the
user_idindex makes lookups fast. -
Upsert pattern: PUT calls
Upsertonce per provided key. With 1-3 keys per request, this is 1-3 simple queries. If this becomes a bottleneck, a batch upsert withunnest()can replace the loop — but premature optimization is not warranted for 3 keys. -
No caching needed: Preferences are read infrequently (page load) and the query is fast. Adding a cache layer would add complexity without meaningful benefit at this scale.
-
Connection pooling: Uses
pkg/databasepool with defaults (25 max open, 5 idle). Adequate for this workload.
Migration / Rollout Plan
-
Database migration first: The
CREATE TABLEmigration is additive — it creates a new table and doesn't modify existing tables. Safe to run with zero downtime. -
Code deployment: Replace example endpoints with preference endpoints in a single deployment. Since the example endpoints are scaffold-only (no real consumers), this is a clean swap with no backwards compatibility concerns.
-
No data migration: New table starts empty. All users get defaults on first GET. Preferences are populated as users make PUT requests.
-
Rollback: If issues arise, revert the code deployment. The
user_preferencestable can remain (harmless) or be dropped in a subsequent migration. -
Feature flag: Not needed. The endpoints are new (replacing unused scaffolds), so there are no existing consumers to break.
File Change Summary
| Action | File | Description |
|---|---|---|
| Create | migrations/001_create_user_preferences.sql |
Database schema |
| Replace | internal/domain/preference.go |
New domain (delete example.go) |
| Replace | internal/domain/errors.go |
New domain errors |
| Replace | internal/port/preference.go |
New repository interface (delete example.go) |
| Replace | internal/service/preference.go |
New service logic (delete example.go) |
| Replace | internal/service/preference_test.go |
New service tests (delete example_test.go) |
| Create | internal/adapter/postgres/preference.go |
PostgreSQL adapter (delete memory/example.go) |
| Replace | internal/api/handlers/preference.go |
New handlers (delete example.go) |
| Replace | internal/api/handlers/preference_test.go |
New handler tests (delete example_test.go) |
| Modify | internal/api/routes.go |
New route registration |
| Replace | internal/api/spec.go |
New OpenAPI spec |
| Modify | cmd/server/main.go |
Wire DB, migrations, new service |
| Keep | internal/api/handlers/health.go |
Unchanged |
| Keep | internal/config/config.go |
Unchanged (already has DB config) |
| Delete | internal/adapter/memory/example.go |
Removed (replaced by postgres) |