25 KiB
Design: User Preferences API
Architecture Approach
The feature replaces the existing example/scaffold CRUD resource in preferences-api with a real user preferences domain. The hexagonal architecture already in place is preserved — only the inner layers change.
What changes:
- Domain layer — New
UserPreferencesentity with validation, replacingExample - Port layer — New
PreferencesRepositoryinterface, replacingExampleRepository - Service layer — New
PreferencesServicewith get/upsert logic, replacingExampleService - Adapter layer — New PostgreSQL adapter (replacing in-memory
Exampleadapter) - Handler layer — Two new handlers (GET, PUT), replacing five example handlers
- Routes — New authenticated route group at
/api/preferences-api/preferences/{user_id} - OpenAPI spec — Updated with preferences schemas and endpoints
- Migrations — New SQL migration for
user_preferencestable - main.go — Updated to wire database pool and new dependencies
What is removed:
- All
exampledomain, port, service, adapter, handler, and test code - The in-memory adapter (production uses PostgreSQL)
What is unchanged:
- Health check handler and route
config/config.go(already supports DATABASE_URL, AUTH_ENABLED, JWT_SECRET)- Dockerfile, Makefile, component.yaml, go.mod structure
Data Model Changes
New Domain Types
// domain/preferences.go
type UserID string
type NotificationPreferences struct {
Email bool
Push bool
SMS bool
}
type Preferences struct {
Theme string
Language string
Notifications NotificationPreferences
}
type UserPreferences struct {
UserID UserID
Preferences Preferences
UpdatedAt time.Time
}
Default Values
When no preferences exist for a user, the service returns defaults:
| Key | Default |
|---|---|
theme |
"system" |
language |
"en" |
notifications.email |
true |
notifications.push |
true |
notifications.sms |
false |
Defaults are defined as a function in the domain layer (DefaultPreferences()) — the single source of truth.
Database Schema
Table: user_preferences
CREATE TABLE IF NOT EXISTS user_preferences (
user_id TEXT PRIMARY KEY,
preferences JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Design decisions:
user_idasTEXT PRIMARY KEY— no UUID type constraint; IDs come from the auth systempreferencesasJSONB— single document per user, supporting the spec's extensibility requirement (unknown keys preserved)created_atincluded for operational debugging even though it's not exposed in the API- No foreign key to a users table — preferences-api is a standalone service
Migration File
migrations/001_create_user_preferences.sql
Single idempotent migration using IF NOT EXISTS. Embedded via //go:embed per project convention.
API Changes
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/preferences-api/preferences/{user_id} |
Required | Get preferences (returns defaults if none saved) |
PUT |
/api/preferences-api/preferences/{user_id} |
Required | Create or replace preferences |
GET /api/preferences-api/preferences/{user_id}
Authorization: Authenticated user's ID must match {user_id}, else 403.
Behavior:
- Extract
user_idfrom URL path - Verify authenticated user matches
user_id - Query database for preferences
- If no row exists, return default preferences
- Return response with
{data, meta}envelope
Response (200):
{
"data": {
"user_id": "usr_abc123",
"preferences": {
"theme": "dark",
"language": "en",
"notifications": {
"email": true,
"push": true,
"sms": false
}
},
"updated_at": "2026-02-08T10:30:00Z"
},
"meta": {
"request_id": "...",
"timestamp": "..."
}
}
When no preferences saved (200 with defaults):
{
"data": {
"user_id": "usr_abc123",
"preferences": {
"theme": "system",
"language": "en",
"notifications": {
"email": true,
"push": true,
"sms": false
}
},
"updated_at": "0001-01-01T00:00:00Z"
},
"meta": { ... }
}
The updated_at zero value signals "never saved". Alternatively, it could be omitted when returning defaults — but including it keeps the response shape consistent.
PUT /api/preferences-api/preferences/{user_id}
Authorization: Authenticated user's ID must match {user_id}, else 403.
Behavior:
- Extract
user_idfrom URL path - Verify authenticated user matches
user_id - Bind and validate request body
- Run domain validation on known keys
- Upsert into database (INSERT ON CONFLICT UPDATE)
- Return saved preferences with
{data, meta}envelope
Request body:
{
"preferences": {
"theme": "dark",
"language": "en",
"notifications": {
"email": true,
"push": true,
"sms": false
}
}
}
Validation rules (domain layer):
theme: Must be one of"light","dark","system"— if presentlanguage: Max 10 characters — if present- Unknown top-level keys in
preferences: preserved (per spec extensibility requirement) notificationssub-keys: booleans, no special validation needed (Go zero-value isfalse)
Response (200): Same shape as GET response, with the just-saved data and current timestamp.
Error (400):
{
"error": {
"code": "BAD_REQUEST",
"message": "invalid theme: must be one of light, dark, system"
},
"meta": { ... }
}
Component Diagram
┌──────────────────────────────────────────────────────────────────┐
│ HTTP Layer │
│ │
│ auth.Middleware() ──▶ handlers.Preferences │
│ │ │
│ GET /preferences/{user_id} ──▶ Get() ──▶ httpresponse.OK() │
│ PUT /preferences/{user_id} ──▶ Put() ──▶ httpresponse.OK() │
│ │ │
│ mapDomainError() ──▶ httperror.* │
└────────────────┬─────────────────────────────────────────────────┘
│
┌────────────────▼─────────────────────────────────────────────────┐
│ Service Layer │
│ │
│ PreferencesService │
│ ├── GetPreferences(ctx, userID) → *UserPreferences, error │
│ │ └── returns defaults if repo returns ErrNotFound │
│ └── SetPreferences(ctx, userID, prefs) → *UserPreferences, err│
│ └── validates, then upserts via repo │
└────────────────┬─────────────────────────────────────────────────┘
│
┌────────────────▼─────────────────────────────────────────────────┐
│ Port Layer (Interface) │
│ │
│ PreferencesRepository │
│ ├── Get(ctx, userID) → *UserPreferences, error │
│ └── Upsert(ctx, prefs *UserPreferences) → error │
└────────────────┬─────────────────────────────────────────────────┘
│
┌────────────────▼─────────────────────────────────────────────────┐
│ Adapter Layer (PostgreSQL) │
│ │
│ postgres.PreferencesRepository │
│ ├── Get() → SELECT ... WHERE user_id = $1 │
│ └── Upsert() → INSERT ... ON CONFLICT (user_id) │
│ DO UPDATE SET preferences = $2, updated_at = $3│
│ │
│ Uses: database.Pool.DB (*sqlx.DB) │
└────────────────┬─────────────────────────────────────────────────┘
│
┌────────────────▼─────────────────────────────────────────────────┐
│ Domain Layer (Pure) │
│ │
│ UserPreferences, Preferences, NotificationPreferences │
│ DefaultPreferences() │
│ Validate() → error │
│ ErrInvalidTheme, ErrInvalidLanguage, ErrForbidden │
└──────────────────────────────────────────────────────────────────┘
Layer-by-Layer Implementation Details
Domain (internal/domain/)
Files to create:
preferences.go— Types, constructors,DefaultPreferences(),Validate()errors.go— Updated withErrInvalidTheme,ErrInvalidLanguage,ErrForbidden,ErrPreferencesNotFound
Files to delete:
example.go
Validation logic in Preferences.Validate():
func (p *Preferences) Validate() error {
if p.Theme != "" {
switch p.Theme {
case "light", "dark", "system":
// valid
default:
return ErrInvalidTheme
}
}
if len([]rune(p.Language)) > 10 {
return ErrInvalidLanguage
}
return nil
}
Unknown keys: The spec says unknown keys are preserved but not validated. Since we store the full JSON document in a JSONB column, unknown keys survive naturally. The Preferences struct uses a map for extensibility:
type Preferences struct {
Theme string `json:"theme"`
Language string `json:"language"`
Notifications NotificationPreferences `json:"notifications"`
Extra map[string]any `json:"-"` // captured via custom marshal/unmarshal
}
A custom UnmarshalJSON/MarshalJSON pair on Preferences decodes known fields into struct fields and captures everything else into Extra. On marshal, known fields and Extra are merged back. This preserves unknown keys through the round-trip without schema migrations.
Port (internal/port/)
Files to create:
preferences.go—PreferencesRepositoryinterface
Files to delete:
example.go
type PreferencesRepository interface {
Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error)
Upsert(ctx context.Context, prefs *domain.UserPreferences) error
}
Only two methods needed — no List, Delete, or ExistsByName. The simple interface keeps the adapter thin.
Service (internal/service/)
Files to create:
preferences.go—PreferencesServicepreferences_test.go— Unit tests
Files to delete:
example.goexample_test.go
type PreferencesService struct {
repo port.PreferencesRepository
logger *logging.Logger
}
func (s *PreferencesService) GetPreferences(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error)
func (s *PreferencesService) SetPreferences(ctx context.Context, userID domain.UserID, prefs domain.Preferences) (*domain.UserPreferences, error)
GetPreferences logic:
- Call
repo.Get(ctx, userID) - If
ErrPreferencesNotFound, returnDefaultPreferences()with the givenuserID - Otherwise return the stored preferences
SetPreferences logic:
- Call
prefs.Validate()— return domain error if invalid - Build
UserPreferences{UserID: userID, Preferences: prefs, UpdatedAt: time.Now().UTC()} - Call
repo.Upsert(ctx, &userPrefs) - Return the saved preferences
Authorization (checking user_id matches authenticated user) is done in the handler layer, not here — the service layer doesn't know about HTTP or JWT. This follows the existing pattern where mapDomainError() in handlers maps domain errors to HTTP errors.
Adapter (internal/adapter/postgres/)
Files to create:
preferences.go— PostgreSQL implementation ofPreferencesRepository
Files to delete:
adapter/memory/example.go
type PreferencesRepository struct {
db *sqlx.DB
logger *logging.Logger
}
func (r *PreferencesRepository) Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) {
// SELECT user_id, preferences, updated_at FROM user_preferences WHERE user_id = $1
// If no rows: return domain.ErrPreferencesNotFound
// Unmarshal JSONB into domain.Preferences
}
func (r *PreferencesRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error {
// INSERT INTO user_preferences (user_id, preferences, updated_at)
// VALUES ($1, $2, $3)
// ON CONFLICT (user_id) DO UPDATE SET preferences = $2, updated_at = $3
// Marshal domain.Preferences to JSON for JSONB column
}
Handlers (internal/api/handlers/)
Files to create:
preferences.go— GET and PUT handlerspreferences_test.go— Handler tests
Files to delete:
example.goexample_test.go
type Preferences struct {
svc *service.PreferencesService
logger *logging.Logger
}
Request/Response types:
type PutPreferencesRequest struct {
Preferences PreferencesPayload `json:"preferences" validate:"required"`
}
type PreferencesPayload struct {
Theme string `json:"theme,omitempty"`
Language string `json:"language,omitempty"`
Notifications *NotificationPreferencesPayload `json:"notifications,omitempty"`
}
type NotificationPreferencesPayload struct {
Email bool `json:"email"`
Push bool `json:"push"`
SMS bool `json:"sms"`
}
type PreferencesResponse struct {
UserID string `json:"user_id"`
Preferences PreferencesPayload `json:"preferences"`
UpdatedAt string `json:"updated_at"`
}
Handler: Get
func (h *Preferences) Get(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
// Authorization check
authUser := auth.GetUser(r.Context())
if authUser.ID != userID {
return httperror.Forbidden("access denied: can only access own preferences")
}
prefs, err := h.svc.GetPreferences(r.Context(), domain.UserID(userID))
if err != nil {
return mapDomainError(err)
}
return httpresponse.OK(w, r, toResponse(prefs))
}
Handler: Put
func (h *Preferences) Put(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
// Authorization check
authUser := auth.GetUser(r.Context())
if authUser.ID != userID {
return httperror.Forbidden("access denied: can only modify own preferences")
}
var req PutPreferencesRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
prefs, err := h.svc.SetPreferences(r.Context(), domain.UserID(userID), toDomain(req.Preferences))
if err != nil {
return mapDomainError(err)
}
return httpresponse.OK(w, r, toResponse(prefs))
}
Error mapping:
func mapDomainError(err error) error {
switch {
case errors.Is(err, domain.ErrInvalidTheme):
return httperror.BadRequest("invalid theme: must be one of light, dark, system")
case errors.Is(err, domain.ErrInvalidLanguage):
return httperror.BadRequest("invalid language: must be at most 10 characters")
default:
return err // app.Wrap() will handle as 500
}
}
Routes (internal/api/routes.go)
Replace example routes with:
func RegisterRoutes(application *app.App, prefsSvc *service.PreferencesService, authCfg config.Config) {
logger := application.Logger()
healthHandler := &handlers.Health{Logger: logger}
prefsHandler := handlers.NewPreferences(prefsSvc, logger)
r := application.Router()
// Public routes
r.Get("/api/preferences-api/health", healthHandler.Check)
// Protected routes — auth required for all preference endpoints
r.Route("/api/preferences-api/preferences", func(r chi.Router) {
if authCfg.AuthEnabled {
r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: auth.NewJWTValidator(auth.JWTConfig{
Secret: []byte(authCfg.JWTSecret),
}),
}))
}
r.Get("/{user_id}", app.Wrap(prefsHandler.Get))
r.Put("/{user_id}", app.Wrap(prefsHandler.Put))
})
}
OpenAPI Spec (internal/api/spec.go)
Update to define:
- Schema:
Preferences(theme, language, notifications) - Schema:
NotificationPreferences(email, push, sms) - Schema:
UserPreferencesResponse(user_id, preferences, updated_at) - Schema:
PutPreferencesRequest(preferences object) - Path:
GET /api/preferences-api/preferences/{user_id}with bearer auth, 200/403 responses - Path:
PUT /api/preferences-api/preferences/{user_id}with bearer auth, 200/400/403 responses {user_id}path parameter
main.go (cmd/server/main.go)
Update to:
- Connect to PostgreSQL using
database.Connect() - Run migrations using
database.MustRunMigrations() - Create
postgres.PreferencesRepositorywithpool.DB - Create
PreferencesServicewith the repo - Register routes with service and auth config
- Register
pool.Close()on shutdown
Error Handling Strategy
| Scenario | Layer | Error | HTTP Status |
|---|---|---|---|
| Invalid theme value | Domain | ErrInvalidTheme |
400 Bad Request |
| Language too long | Domain | ErrInvalidLanguage |
400 Bad Request |
| Malformed JSON body | Handler (BindAndValidate) | Automatic | 400 Bad Request |
Missing preferences field |
Handler (BindAndValidate) | Validation | 400 Bad Request |
| User accessing another user's prefs | Handler | httperror.Forbidden |
403 Forbidden |
| No preferences saved yet | Service | (returns defaults) | 200 OK |
| Database connection failure | Adapter | raw error | 500 Internal |
| Database query failure | Adapter | raw error | 500 Internal |
Key decisions:
- GET never returns 404 — missing preferences yield defaults. This simplifies the frontend (no special "first time" flow).
- Authorization is checked in handlers before any service call, failing fast with 403.
- Domain validation errors are specific and mapped to descriptive 400 messages.
- Database errors bubble up as raw errors, caught by
app.Wrap()and returned as 500 with the error logged server-side (not leaked to client).
Security Considerations
Authentication
- Both endpoints require
auth.Middleware(). Unauthenticated requests receive 401. - JWT validation via
pkg/auth.NewJWTValidatorwith HMAC secret from config.
Authorization
- Owner-only access: The
{user_id}in the URL path must matchauth.GetUser(ctx).ID. This is checked in the handler before calling the service layer. - No admin override endpoint (out of scope per spec).
Input Validation
- Request body bound and validated via
app.BindAndValidate()— rejects malformed JSON and missing required fields. - Domain-level validation for
theme(enum) andlanguage(max length). - JSONB column stores raw preferences — unknown keys preserved but size is bounded by PostgreSQL's TOAST limit (~1GB). For practical limits, the handler can check
Content-Lengthagainst a reasonable threshold (e.g., 64KB). This addresses the spec's open question about preference size limits.
Data Boundaries
- Users can only read/write their own preferences — no cross-user data access.
- Error responses never leak internal details (database errors, stack traces).
- The
preferencesJSONB column is treated as opaque by the database — no SQL injection vector.
SQL Injection
- All queries use parameterized statements (
$1,$2) viasqlx— no string concatenation.
Performance Considerations
Expected Load
- Read-heavy workload: preferences fetched on every page load / session start.
- Writes are infrequent: users change preferences rarely.
Query Performance
- GET: Single-row lookup by primary key (
user_id) — O(1) with B-tree index. - PUT: Upsert by primary key — O(1).
- No need for additional indexes. The primary key index is sufficient.
Caching Strategy
- Not implemented in this iteration (out of scope). The single-row PK lookup is fast enough.
- If needed later: HTTP
Cache-Controlheaders or an in-process cache with short TTL.
Connection Pooling
- Uses
database.Poolwith configurable pool size (default: 25 open, 5 idle). Adequate for preferences traffic.
Payload Size
- Preferences JSON is small (< 1KB typical). No pagination or streaming needed.
Migration / Rollout Plan
Step 1: Database Migration
The 001_create_user_preferences.sql migration runs on startup via database.MustRunMigrations(). It uses CREATE TABLE IF NOT EXISTS for idempotency. No existing tables are modified or dropped.
Step 2: Code Deployment
The service is fully backward-compatible at the infrastructure level:
- Same port (8001)
- Same health check path (
/api/preferences-api/health) - Example endpoints are removed, but nothing depends on them (they're scaffold)
Step 3: Verification
- Health check confirms service starts and database is reachable
- GET returns default preferences for any authenticated user (no data seeding needed)
- PUT creates preferences on first save
Rollback
- Revert to previous deployment. The
user_preferencestable can remain — it won't interfere with the example scaffold code. - No destructive migrations — forward-only table creation.
Open Questions Resolution
From the spec:
-
Authorization model: Design uses
auth.GetUser(ctx).ID— theUser.IDfield populated by JWT validation. This maps to thesubclaim oruidcustom claim (both supported bypkg/auth.JWTClaims). No changes to auth package needed. -
Unknown preference keys: Preserved via custom JSON marshaling on the
Preferencesstruct. Known keys are validated; unknown keys pass through to JSONB storage unchanged. -
Preference size limit: Addressed by checking request
Content-Lengthin the handler (64KB default). This prevents abuse without requiring schema changes.
File Change Summary
| Action | File |
|---|---|
| Create | migrations/001_create_user_preferences.sql |
| Create | internal/domain/preferences.go |
| Create | internal/port/preferences.go |
| Create | internal/service/preferences.go |
| Create | internal/service/preferences_test.go |
| Create | internal/adapter/postgres/preferences.go |
| Create | internal/api/handlers/preferences.go |
| Create | internal/api/handlers/preferences_test.go |
| Modify | internal/domain/errors.go |
| Modify | internal/api/routes.go |
| Modify | internal/api/spec.go |
| Modify | cmd/server/main.go |
| Delete | internal/domain/example.go |
| Delete | internal/port/example.go |
| Delete | internal/service/example.go |
| Delete | internal/service/example_test.go |
| Delete | internal/adapter/memory/example.go |
| Delete | internal/api/handlers/example.go |
| Delete | internal/api/handlers/example_test.go |