20 KiB
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
Exampleentity withUserPreferencesvalue object containing typed preference fields and validation logic - Port layer: Replace
ExampleRepositorywithPreferenceRepositoryinterface (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
ExampleServicewithPreferenceServiceorchestrating 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
// 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:trueNotifications.Push:trueNotifications.Digest:"weekly"
Validation (pure domain logic, no framework dependencies):
Thememust be one oflight,dark,systemLanguagemust be one of the allowed BCP-47 tags:en,fr,es,de,jaDigestmust be one ofnone,daily,weekly- Unknown preference keys rejected at the handler layer via strict binding
Database Schema
Single table in the existing PostgreSQL instance:
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_idisTEXTto match the JWT subject field (UUIDs stored as text)- No
created_at—updated_atserves 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:
{
"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:
{
"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:
{
"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/examplesGET /api/preferences-api/examples/{id}POST /api/preferences-api/examplesPUT /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
// 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:
- Struct tag validation via
app.BindAndValidateStrict()— handles required fields,oneofconstraints for enums, boolean type checking. Unknown JSON fields rejected automatically. - 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:
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
JWTValidatorwith shared secret fromJWT_SECRETenv var.
Authorization
- Self-access only for writes: Handler extracts
auth.GetUser(ctx).IDand compares to{user_id}URL parameter. Mismatch returns 403. - Admin read access: For GET only, if the authenticated user has role
admin(checked viauser.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,$2placeholders). - 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
DatabaseConfigdefaults: 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:
- Remove all example domain/port/adapter/service/handler files
- Add preference domain/port/adapter/service/handler files
- Update
routes.goto register new endpoints - Update
spec.gowith new OpenAPI documentation - Update
main.goto wire PostgreSQL adapter and new dependency graph - Schema created at startup via
CREATE TABLE IF NOT EXISTS
Rollback
- Revert the deployment to previous version. The
user_preferencestable 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
-
Flat columns over JSONB: Preferences are a fixed, small set. Flat columns give type safety, simpler queries, and no need for JSON path operations.
-
Strict binding for unknown key rejection: Using
app.BindAndValidateStrict()naturally rejects unknown JSON fields, satisfying the spec requirement without custom validation code. -
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. -
No separate "exists" check: GET returns defaults for non-existent users (200, not 404). PUT uses
INSERT ... ON CONFLICT UPDATEfor atomic upsert. No need for existence checks. -
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.
-
Admin read via role check: Uses the existing
auth.User.HasRole("admin")mechanism. No new auth infrastructure needed. -
Schema creation at startup:
CREATE TABLE IF NOT EXISTSin the adapter's constructor. Simple, idempotent, no migration tool dependency for a single new table.