18 KiB
Design: User Preferences API
Architecture Approach
Replace the existing example CRUD scaffold in services/preferences-api/ with a preference-specific domain following the same hexagonal architecture pattern: domain → service → port (interface) → adapter (implementation).
What Changes
| Layer | Action | Description |
|---|---|---|
| Domain | Replace | New Preference and PreferenceKey types with validation; remove Example entity |
| Port | Replace | New PreferenceRepository interface with GetByUserID and Upsert methods |
| Adapter | Replace | New PostgreSQL adapter (replaces in-memory); new migration for user_preferences table |
| Service | Replace | New PreferenceService with get/upsert business logic, key/value validation |
| Handlers | Replace | New Preference handler for GET and PUT endpoints with auth enforcement |
| Routes | Modify | Update route registration: both endpoints require auth, add user_id ownership check |
| Spec | Replace | New OpenAPI documentation for preference endpoints |
| Main | Modify | Wire PostgreSQL pool, run migrations, inject into preference service |
What Stays the Same
- Service name (
preferences-api), port (8001), base path (/api/preferences-api) - Health check endpoint and handler
- Config loading pattern (extended with database config)
- All
pkg/*dependencies remain unchanged - Makefile, Dockerfile, component.yaml structure
Data Model Changes
Domain Types
// internal/domain/preference.go
// AllowedKeys defines the valid preference keys and their allowed values.
var AllowedKeys = map[string][]string{
"theme": {"light", "dark", "system"},
"language": {}, // validated via regex: ^[a-z]{2}$ (ISO 639-1)
"notifications_enabled": {"true", "false"},
}
// Preference represents a single user preference key-value pair.
type Preference struct {
UserID string
Key string
Value string
}
// Validate checks that Key is known and Value is valid for that key.
func (p *Preference) Validate() error { ... }
// ValidateKey checks if a key is in the allowed set.
func ValidateKey(key string) error { ... }
// ValidateValue checks if a value is valid for the given key.
func ValidateValue(key, value string) error { ... }
// internal/domain/errors.go
var (
ErrUnknownKey = errors.New("unknown preference key")
ErrInvalidValue = errors.New("invalid preference value")
ErrForbidden = errors.New("access denied")
)
Database Schema
-- migrations/001_create_user_preferences.sql
CREATE TABLE IF NOT EXISTS 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);
Design decisions:
- Composite primary key
(user_id, key)— enforces one value per key per user, enables efficient upsert viaON CONFLICT. - Key-value model rather than a wide row — allows adding new preference keys without schema migration.
- Index on
user_id— supports efficient retrieval of all preferences for a single user. - No foreign key to a users table — the preferences service doesn't own user data; user existence is validated by the auth token.
Port Interface
// internal/port/preference.go
type PreferenceRepository interface {
// GetByUserID returns all preferences for a user as a map[key]value.
// Returns an empty map if the user has no preferences.
GetByUserID(ctx context.Context, userID string) (map[string]string, error)
// Upsert creates or updates preferences for a user.
// Only the provided keys are affected; existing keys not in the map are preserved.
Upsert(ctx context.Context, userID string, prefs map[string]string) error
}
PostgreSQL Adapter
// internal/adapter/postgres/preference.go
type PreferenceRepository struct {
db *sqlx.DB
logger *logging.Logger
}
func (r *PreferenceRepository) GetByUserID(ctx context.Context, userID string) (map[string]string, error) {
// SELECT key, value FROM user_preferences WHERE user_id = $1
// Returns empty map if no rows
}
func (r *PreferenceRepository) Upsert(ctx context.Context, userID string, prefs map[string]string) error {
// Uses a transaction with batch INSERT ... ON CONFLICT (user_id, key)
// DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
// One statement per key within a single transaction
}
API Changes
Endpoints
Both endpoints are mounted under /api/preferences-api and require JWT authentication.
GET /api/preferences-api/preferences/{user_id}
Retrieve all preferences for a user.
Request:
GET /api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <jwt>
Response (200 OK):
{
"data": {
"theme": "dark",
"language": "en",
"notifications_enabled": "true"
},
"meta": {
"request_id": "abc-123",
"timestamp": "2026-02-07T12:00:00Z"
}
}
Response (200 OK, no preferences set):
{
"data": {},
"meta": { ... }
}
Error Responses:
400 Bad Request— invalid UUID in path401 Unauthorized— missing or invalid JWT403 Forbidden— user_id does not match JWT subject
PUT /api/preferences-api/preferences/{user_id}
Create or update preferences (partial upsert).
Request:
PUT /api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <jwt>
Content-Type: application/json
{
"theme": "dark",
"language": "fr"
}
Response (200 OK):
{
"data": {
"theme": "dark",
"language": "fr",
"notifications_enabled": "true"
},
"meta": { ... }
}
Returns the full preference set after the update (including unchanged keys).
Error Responses:
400 Bad Request— invalid UUID, unknown key, or invalid value (with descriptive message)401 Unauthorized— missing or invalid JWT403 Forbidden— user_id does not match JWT subject
Request/Response DTOs
// Handler request DTO for PUT
type UpdatePreferencesRequest struct {
Preferences map[string]string // Unmarshalled from JSON body
}
// Handler response DTO for GET and PUT
type PreferencesResponse struct {
Preferences map[string]string // Serialized as flat JSON object
}
The response data field is a flat map[string]string, not wrapped in a preferences key. This keeps the API simple: data.theme, not data.preferences.theme.
Component Diagram
┌──────────────────────────────────────────────────────────────────┐
│ HTTP Client │
│ Authorization: Bearer <jwt> │
└──────────────────────┬───────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Chi Router │
│ /api/preferences-api/preferences/{user_id} [GET, PUT] │
│ │
│ Middleware Stack: │
│ ┌─────────┐ ┌─────────┐ ┌───────────┐ ┌──────────┐ │
│ │RequestID│→│ Tracing │→│ Logger │→│Recoverer │ │
│ └─────────┘ └─────────┘ └───────────┘ └──────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ Auth Middleware │ ← pkg/auth JWT validation │
│ └────────┬────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ Preference │ ← Handler: ownership check, │
│ │ Handler │ bind, validate, map errors │
│ └────────┬────────┘ │
└───────────────────────┼──────────────────────────────────────────┘
│
┌────────┴────────┐
│ Preference │ ← Service: domain validation,
│ Service │ orchestrate get/upsert
└────────┬────────┘
│
┌────────┴────────┐
│ Preference │ ← Port: interface
│ Repository │
└────────┬────────┘
│
┌────────┴────────┐
│ PostgreSQL │ ← Adapter: SQL queries,
│ Adapter │ ON CONFLICT upsert
└────────┬────────┘
│
┌────────┴────────┐
│ PostgreSQL │ ← user_preferences table
│ Database │
└─────────────────┘
Error Handling Strategy
| Error Condition | Domain Error | HTTP Error | Status |
|---|---|---|---|
| Invalid UUID in path | — | httperror.BadRequest("invalid user ID format") |
400 |
| Empty request body (PUT) | — | httperror.BadRequest("request body is required") |
400 |
| Unknown preference key | ErrUnknownKey |
httperror.BadRequest("unknown preference key: <key>") |
400 |
| Invalid preference value | ErrInvalidValue |
httperror.BadRequest("invalid value '<val>' for key '<key>': allowed values are [...]") |
400 |
| Missing/invalid JWT | — | Handled by auth middleware | 401 |
| user_id ≠ JWT subject | ErrForbidden |
httperror.Forbidden("cannot access preferences for another user") |
403 |
| Database connection error | raw error | Passthrough → app.Wrap returns 500 |
500 |
Error message strategy: Validation errors include specific, actionable messages that tell the client what went wrong and what is allowed. For example: "invalid value 'blue' for key 'theme': allowed values are [light, dark, system]".
Ownership Check Flow
1. Auth middleware validates JWT → stores auth.User in context
2. Handler extracts user_id from URL path
3. Handler calls auth.GetUser(ctx) to get authenticated user
4. Handler compares user.ID == user_id path param
5. If mismatch → return httperror.Forbidden(...)
6. If match → proceed to service layer
This check lives in the handler, not the service, because it depends on HTTP/auth context. The service layer receives a validated userID string and trusts it.
Security Considerations
Authentication
- All preference endpoints require JWT authentication — no public access.
- Auth middleware is mandatory (not conditional on
AUTH_ENABLEDfor preference routes). The config flag controls whether the example routes had auth; for preferences, auth is always required. - JWT validation uses
pkg/auth.Middlewarewithauth.NewJWTValidator.
Authorization
- Self-access only: authenticated users can only read/write their own preferences.
- Ownership enforced at the handler layer by comparing
auth.GetUser(ctx).IDwith{user_id}path parameter. - No admin override (explicitly out of scope per spec).
Input Validation
user_idpath parameter validated as UUID format at handler layer.- Preference keys validated against a strict allowlist — unknown keys rejected.
- Preference values validated per-key (enum check for theme/notifications, regex for language).
- Request body size bounded by
app.Binddefaults (prevents oversized payloads). - No SQL injection risk: all queries use parameterized statements (
$1,$2).
Data Exposure
- GET returns only the authenticated user's preferences — no cross-user data leakage.
- Error messages do not leak internal state (no stack traces, no database details).
- Preference values are non-sensitive (theme, language, notification toggle).
Open Question Decisions (for design purposes)
- Default values: GET returns only explicitly set keys. An empty
{}is returned for users with no preferences. Clients are responsible for applying defaults. This avoids coupling the API to default values that may change. - DELETE support: Not included in this design (out of scope per spec). Can be added later without breaking changes.
- Extensibility: New keys are added by updating the
AllowedKeysmap indomain/preference.go. This is a code change, which is acceptable — new keys require validation rules that belong in code. - Admin access: Not supported. Self-access only.
Performance Considerations
Query Performance
- GET: Single
SELECT ... WHERE user_id = $1on a table indexed byuser_id. Expected < 1ms for typical preference sets (3 keys). Well within p99 < 50ms target. - PUT: Transaction with
INSERT ... ON CONFLICTstatements. One round-trip per upsert batch. Expected < 5ms for typical updates.
Connection Pooling
- Uses
pkg/database.Poolwith default settings (25 max open, 5 max idle). - Connection pool shared across all requests.
No Caching Needed
- Preference reads are simple primary key lookups — PostgreSQL handles these efficiently.
- Caching adds complexity (invalidation, stale data) with minimal benefit for this access pattern.
- If caching becomes needed later, it can be added at the service layer without changing the port interface.
Table Size
- One row per user per preference key (max 3 rows per user currently).
- Even at 1M users × 3 keys = 3M rows, this is trivial for PostgreSQL.
Migration / Rollout Plan
Step 1: Remove Example Code
- Delete all
example-related files:domain/example.go,domain/errors.go(replace),port/example.go,service/example.go,service/example_test.go,adapter/memory/example.go,api/handlers/example.go,api/handlers/example_test.go. - This is explicitly required by the spec: "The existing example CRUD code should be replaced, not left alongside preference code."
Step 2: Implement Domain Layer
- Create
domain/preference.gowithPreferencetype,AllowedKeys, validation functions. - Create
domain/errors.gowithErrUnknownKey,ErrInvalidValue,ErrForbidden. - Test validation logic with unit tests.
Step 3: Implement Port and Adapter
- Create
port/preference.gowithPreferenceRepositoryinterface. - Create
adapter/postgres/preference.goimplementing the port. - Create
migrations/001_create_user_preferences.sql.
Step 4: Implement Service Layer
- Create
service/preference.gowithPreferenceService. - Create
service/preference_test.gowith mock repository.
Step 5: Implement Handler Layer
- Create
api/handlers/preference.gowith GET/PUT handlers and ownership check. - Create
api/handlers/preference_test.gocovering success, validation, auth, and ownership cases.
Step 6: Wire Routes and Spec
- Update
api/routes.goto register preference routes with mandatory auth. - Replace
api/spec.gowith preference endpoint documentation. - Update
cmd/server/main.goto initialize database pool, run migrations, wire dependencies.
Step 7: Verify
- Run full test suite:
cd services/preferences-api && go test -v ./... - Manual smoke test with curl against local instance.
Backward Compatibility
- No backward compatibility concerns — the example CRUD API has no consumers. This is a scaffold replacement.
- The service name, port, and base path remain unchanged.
File Inventory
| File | Action | Purpose |
|---|---|---|
cmd/server/main.go |
Modify | Add DB pool, migrations, wire preference service |
internal/domain/preference.go |
Create | Preference types, AllowedKeys, validation |
internal/domain/errors.go |
Replace | Domain errors for preferences |
internal/port/preference.go |
Create (replace example) | PreferenceRepository interface |
internal/service/preference.go |
Create (replace example) | Business logic |
internal/service/preference_test.go |
Create (replace example) | Service tests with mock |
internal/adapter/postgres/preference.go |
Create (replace memory) | PostgreSQL adapter |
internal/api/handlers/preference.go |
Create (replace example) | HTTP handlers |
internal/api/handlers/preference_test.go |
Create (replace example) | Handler tests |
internal/api/handlers/health.go |
Keep | No changes |
internal/api/routes.go |
Modify | New routes with mandatory auth |
internal/api/spec.go |
Replace | OpenAPI spec for preferences |
internal/config/config.go |
Keep | Already has DB and auth config |
migrations/001_create_user_preferences.sql |
Create | Database schema |
internal/adapter/memory/example.go |
Delete | Replaced by postgres adapter |
internal/domain/example.go |
Delete | Replaced by preference domain |
internal/port/example.go |
Delete | Replaced by preference port |
internal/service/example.go |
Delete | Replaced by preference service |
internal/service/example_test.go |
Delete | Replaced by preference tests |
internal/api/handlers/example.go |
Delete | Replaced by preference handlers |
internal/api/handlers/example_test.go |
Delete | Replaced by preference handler tests |