20 KiB
Design: User Preferences API
Architecture Approach
Replace the scaffolded example CRUD in services/preferences-api with real preference management. The existing hexagonal architecture layers remain the same; we replace the example domain/service/port/adapter/handler with preference-specific implementations and switch from the in-memory adapter to a PostgreSQL adapter.
What changes:
- Domain layer: New
UserPreferencesmodel replacesExample; validation rules for known preference keys - Port layer: New
PreferenceRepositoryinterface withGetandUpsert(replacesExampleRepository) - Service layer: New
PreferenceServicewith validation logic and delegation to repository - Adapter layer: New
postgres/preference.goadapter using sqlx + JSONB (replacesmemory/example.go) - Handler layer: New
preference.gohandler with GET/PUT endpoints (replacesexample.go) - Routes: Updated to register preference routes instead of example routes
- OpenAPI spec: Updated to document preference endpoints
- Main: Wires PostgreSQL connection pool, runs migrations, injects postgres adapter
- Migration: New
001_create_preferences.sql
What stays the same:
- Health handler and health endpoint
pkg/app,pkg/httperror,pkg/httpresponse,pkg/openapiusage patterns- Service port 8001, route prefix
/api/preferences-api - Config structure (already has
Databaseconfig)
Data Model Changes
Domain Model
// internal/domain/preference.go
// UserPreferences represents a user's stored preferences.
type UserPreferences struct {
UserID string
Preferences map[string]any
CreatedAt time.Time
UpdatedAt time.Time
}
No strongly-typed preference keys in the domain model—preferences are stored as map[string]any to support extensibility (unknown keys accepted per spec). Validation of known keys happens in the service layer.
Database Schema
-- migrations/001_create_preferences.sql
CREATE TABLE IF NOT EXISTS 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()
);
CREATE INDEX idx_preferences_updated_at ON preferences (updated_at);
Single table, UUID primary key, JSONB column for flexible key-value storage. No foreign keys (user_id is treated as an opaque UUID per spec; no cross-service validation).
API Changes
Remove Example Endpoints
All /api/preferences-api/examples* routes are removed.
Add Preference Endpoints
GET /api/preferences-api/preferences/{user_id}
- Validates
user_idis a valid UUID; returns400if not - Returns
200with{data, meta}envelope containing user preferences - Returns
200with empty preferences object{}when no row exists (not 404)
PUT /api/preferences-api/preferences/{user_id}
- Validates
user_idis a valid UUID; returns400if not - Binds and validates request body with
app.Bind() - Service layer validates known preference keys against allowed values
- Upserts preferences row (merge incoming preferences with existing ones)
- Returns
200with updated preferences in{data, meta}envelope - Returns
400with details when validation fails
Request/Response Shapes
These match the spec exactly. See spec.md for full JSON examples.
GET response data:
{
"user_id": "uuid",
"preferences": {"theme": "dark", ...},
"updated_at": "2026-02-09T12:00:00Z"
}
PUT request body:
{
"preferences": {"theme": "dark", "language": "fr"}
}
PUT response data: Same shape as GET response data.
Validation error:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid preference values",
"details": {"theme": "must be one of: light, dark, system"}
}
}
Component Diagram
┌──────────────────────────────────────┐
│ HTTP Client │
└───────┬─────────────┬────────────────┘
│ │
GET /preferences PUT /preferences
/{user_id} /{user_id}
│ │
┌───────▼─────────────▼────────────────┐
│ Preference Handler │
│ (UUID validation, request binding, │
│ domain error mapping) │
└───────┬─────────────┬────────────────┘
│ │
┌───────▼─────────────▼────────────────┐
│ PreferenceService │
│ (known-key validation, upsert │
│ orchestration, logging) │
└───────┬─────────────┬────────────────┘
│ │
┌───────▼─────────────▼────────────────┐
│ <<interface>> PreferenceRepository │
│ Get(ctx, userID) │
│ Upsert(ctx, prefs) │
└───────┬─────────────┬────────────────┘
│ │
┌────────────▼──┐ ┌──────▼─────────────┐
│ PostgreSQL │ │ In-Memory (tests) │
│ Adapter │ │ Mock │
└──────┬────────┘ └────────────────────┘
│
┌──────▼────────┐
│ PostgreSQL │
│ preferences │
│ table (JSONB)│
└───────────────┘
Detailed Layer Design
Domain Layer (internal/domain/preference.go)
type UserPreferences struct {
UserID string
Preferences map[string]any
CreatedAt time.Time
UpdatedAt time.Time
}
Domain errors in internal/domain/errors.go:
var (
ErrInvalidUserID = errors.New("invalid user ID")
ErrInvalidPreferenceValue = errors.New("invalid preference value")
)
The domain model is intentionally simple. Preferences are an opaque map; validation of known keys is a business rule in the service layer, not an invariant of the domain entity.
Port Layer (internal/port/preference.go)
type PreferenceRepository interface {
// Get returns preferences for a user.
// Returns nil UserPreferences (not error) when no row exists.
Get(ctx context.Context, userID string) (*domain.UserPreferences, error)
// Upsert creates or updates preferences for a user.
// Uses ON CONFLICT to handle both insert and update atomically.
Upsert(ctx context.Context, prefs *domain.UserPreferences) error
}
Key design decision: Get returns nil, nil for a non-existent user rather than an error. The handler converts this to a default empty-preferences response. This avoids a "not found" error that would be misleading (the spec says return 200 with empty preferences).
Service Layer (internal/service/preference.go)
type PreferenceService struct {
repo port.PreferenceRepository
logger *logging.Logger
}
type UpsertInput struct {
UserID string
Preferences map[string]any
}
func (s *PreferenceService) Get(ctx, userID) (*domain.UserPreferences, error)
func (s *PreferenceService) Upsert(ctx, input UpsertInput) (*domain.UserPreferences, error)
Validation logic in Upsert:
- Iterate over input preferences keys
- For known keys, validate values:
theme: must be"light","dark", or"system"language: must be a valid BCP-47 tag (validate withlanguage.Parsefromgolang.org/x/text/language)notifications_enabled: must be a boolean
- Unknown keys: accept any JSON value (no validation)
- If validation errors exist, return a structured error with per-field details
- If valid: fetch existing preferences, merge incoming preferences on top, upsert
Merge strategy: The PUT replaces only the keys provided. Existing keys not included in the request body remain unchanged. This gives partial-update semantics on the preference map even though the endpoint is PUT. This matches the spec's upsert behavior.
Adapter Layer (internal/adapter/postgres/preference.go)
type PreferenceRepository struct {
db *sqlx.DB
}
func (r *PreferenceRepository) Get(ctx, userID) (*domain.UserPreferences, error)
func (r *PreferenceRepository) Upsert(ctx, prefs *domain.UserPreferences) error
Get query:
SELECT user_id, preferences, created_at, updated_at
FROM preferences
WHERE user_id = $1
Returns nil, nil if no row found (sql.ErrNoRows → return nil).
Upsert query:
INSERT INTO preferences (user_id, preferences, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE
SET preferences = $2, updated_at = NOW()
RETURNING user_id, preferences, created_at, updated_at
The JSONB value stored is the complete merged preference map (merging happens in the service layer before calling Upsert). The adapter stores whatever the service gives it.
Handler Layer (internal/api/handlers/preference.go)
type Preference struct {
svc *service.PreferenceService
logger *logging.Logger
}
type UpdatePreferencesRequest struct {
Preferences map[string]any `json:"preferences"`
}
type PreferenceResponse struct {
UserID string `json:"user_id"`
Preferences map[string]any `json:"preferences"`
UpdatedAt string `json:"updated_at"`
}
GET handler flow:
- Extract
user_idfrom URL withchi.URLParam(r, "user_id") - Validate UUID with
uuid.Parse(userID)→httperror.BadRequeston failure - Call
svc.Get(ctx, userID) - If nil result (no preferences), return
httpresponse.OKwith empty defaults - If result exists, return
httpresponse.OKwith mapped response
PUT handler flow:
- Extract and validate
user_id(same as GET) - Bind request body with
app.Bind(r, &req)(not BindAndValidate—custom validation in service) - Validate
req.Preferencesis not nil →httperror.BadRequestif missing - Call
svc.Upsert(ctx, input) - Map domain errors →
httperror.BadRequestwith details for validation errors - Return
httpresponse.OKwith mapped response
Domain error mapping:
func mapDomainError(err error) error {
switch {
case errors.Is(err, domain.ErrInvalidPreferenceValue):
// Extract details from the error for the response
var valErr *service.ValidationError
if errors.As(err, &valErr) {
return httperror.WithDetails(
httperror.Validation("Invalid preference values"),
valErr.Details,
)
}
return httperror.BadRequest("invalid preference value")
default:
return err
}
}
Routes (internal/api/routes.go)
func RegisterRoutes(application *app.App, prefService *service.PreferenceService) {
prefHandler := handlers.NewPreference(prefService, logger)
application.Route("/api/preferences-api", func(r app.Router) {
r.Get("/health", healthHandler.Check)
r.Get("/preferences/{user_id}", app.Wrap(prefHandler.Get))
r.Put("/preferences/{user_id}", app.Wrap(prefHandler.Upsert))
})
}
Both GET and PUT are public routes (auth out of scope per spec). Auth middleware can be layered on later via route groups.
OpenAPI Spec (internal/api/spec.go)
Updated to document preference schemas and endpoints:
- Schema:
UserPreferences(user_id, preferences object, updated_at) - Schema:
UpdatePreferencesRequest(preferences object) - Path: GET
/api/preferences-api/preferences/{user_id} - Path: PUT
/api/preferences-api/preferences/{user_id} - Removes all example-related schemas and paths
Main (cmd/server/main.go)
Updated wiring:
- Load config (database URL)
- Connect to PostgreSQL via
database.MustConnect - Run migrations via
database.MustRunMigrations - Create
postgres.NewPreferenceRepository(pool.DB) - Create
service.NewPreferenceService(repo, logger) - Register routes with preference service
- Defer
pool.Close()
Error Handling Strategy
| Scenario | Error Source | HTTP Response |
|---|---|---|
| Invalid UUID in path | Handler (uuid.Parse) | 400 Bad Request |
| Missing request body | Handler (app.Bind) | 400 Bad Request |
Missing preferences field |
Handler (nil check) | 400 Bad Request |
| Invalid theme value | Service validation | 400 Validation Error with details |
| Invalid language tag | Service validation | 400 Validation Error with details |
| Invalid notifications_enabled | Service validation | 400 Validation Error with details |
| Multiple validation errors | Service validation | 400 Validation Error with all details |
| User has no preferences | Repository returns nil | 200 with empty preferences {} |
| Database connection failure | Adapter (sqlx) | 500 Internal Server Error |
| Database query error | Adapter (sqlx) | 500 Internal Server Error |
Validation error structure for service layer:
A custom ValidationError type wraps domain.ErrInvalidPreferenceValue and carries a Details map[string]string with per-field error messages. The handler maps this to an httperror with details.
// service/preference.go
type ValidationError struct {
Details map[string]string
}
func (e *ValidationError) Error() string { return "invalid preference values" }
func (e *ValidationError) Unwrap() error { return domain.ErrInvalidPreferenceValue }
Security Considerations
-
Authentication: Auth is out of scope per spec. Routes are public. Auth middleware can be added later by wrapping the PUT route in an auth group (pattern already exists in routes.go scaffolding).
-
Input validation:
- UUID format validated at handler level (prevents SQL injection via path parameter)
- Request body parsed via
app.Bind()(standard JSON decoder, no raw input) - Known preference values validated in service layer against allowlists
- JSONB storage naturally handles JSON escaping
-
Data boundaries:
- No cross-user data access patterns exist (each request operates on a single user_id)
- No sensitive data in preferences (theme, language, notification flag)
- No PII beyond the user_id UUID itself
-
Injection prevention:
- All database queries use parameterized queries ($1, $2 placeholders)
- JSONB values marshaled through Go's
encoding/json, not string concatenation
-
Size limits: Spec open question #4 asks about value size limits. For initial implementation, rely on PostgreSQL's built-in JSONB limits (255 MB per column). Add application-level size limits in a follow-up if needed (e.g., max 50 keys, max 1KB per value).
Performance Considerations
-
Query complexity: Both queries are single-row operations on a primary key (UUID). O(1) index lookup. No joins, no scans.
-
Expected load: Preferences are read frequently (every page load) and written infrequently (settings changes). Read-heavy workload.
-
Caching strategy: Not needed for initial implementation. The query is a simple primary key lookup—fast at the database level. If needed later, add a cache layer behind the port interface without changing the service.
-
JSONB performance: JSONB is stored in a decomposed binary format; reads are fast. We don't query individual keys within the JSONB column—always read/write the full object.
-
Connection pooling:
pkg/databaseprovides connection pooling (default 25 open, 5 idle). Adequate for expected load. -
Index: The
updated_atindex supports future analytics or cleanup queries but is not used by the GET/PUT operations. The primary key index is sufficient.
Migration / Rollout Plan
Phase 1: Database Migration
- Add
001_create_preferences.sqltomigrations/directory - Migration runs automatically on service startup via
MustRunMigrations - Non-destructive: creates a new table, does not modify existing tables
- Idempotent: uses
CREATE TABLE IF NOT EXISTS
Phase 2: Code Replacement
- Remove example domain, service, port, adapter, handler, and test files
- Add preference domain, service, port, adapter, handler, and test files
- Update routes.go to register preference endpoints
- Update spec.go for preference OpenAPI documentation
- Update main.go to wire PostgreSQL adapter and run migrations
Phase 3: Validation
- Run unit tests: handler tests with mock repository, service tests with mock repository
- Run integration tests manually against local PostgreSQL (via
docker compose) - Verify OpenAPI spec renders correctly via Scalar docs UI
Rollback
- Since the preferences table is new (no data migration), rollback is straightforward: revert to previous code and the table is unused
- The
schema_migrationstable tracks applied migrations; if needed, manually remove the entry and drop the table
Open Questions Resolution (Design Decisions)
- Unknown key value-type validation: Accept any valid JSON value. No type restriction. This keeps the system maximally extensible.
- Max preference keys: No limit in initial implementation. PostgreSQL JSONB handles large objects well. Add limit if abuse observed.
- User existence validation: No cross-service validation. Treat user_id as opaque UUID. Any valid UUID can have preferences.
- Value size limits: Rely on PostgreSQL limits initially. Monitor and add application limits if needed.
Files Changed Summary
| File | Action | Description |
|---|---|---|
internal/domain/preference.go |
Create | UserPreferences model |
internal/domain/errors.go |
Modify | Replace example errors with preference errors |
internal/port/preference.go |
Create | PreferenceRepository interface |
internal/service/preference.go |
Create | PreferenceService with validation |
internal/service/preference_test.go |
Create | Service-layer tests |
internal/adapter/postgres/preference.go |
Create | PostgreSQL adapter |
internal/api/handlers/preference.go |
Create | GET/PUT handlers |
internal/api/handlers/preference_test.go |
Create | Handler tests |
internal/api/routes.go |
Modify | Replace example routes with preference routes |
internal/api/spec.go |
Modify | Replace example spec with preference spec |
cmd/server/main.go |
Modify | Wire PostgreSQL, migrations, preference service |
migrations/001_create_preferences.sql |
Create | Preferences table DDL |
internal/domain/example.go |
Delete | No longer needed |
internal/service/example.go |
Delete | No longer needed |
internal/service/example_test.go |
Delete | No longer needed |
internal/port/example.go |
Delete | No longer needed |
internal/adapter/memory/example.go |
Delete | No longer needed |
internal/api/handlers/example.go |
Delete | No longer needed |
internal/api/handlers/example_test.go |
Delete | No longer needed |