21 KiB
Design: User Preferences API
Architecture Approach
Replace the existing example scaffold in the preferences-api service with a user preferences system. The hexagonal architecture layers remain identical — only the domain entity, service logic, port interface, adapter, handlers, and OpenAPI spec change. No new services, packages, or infrastructure components are introduced.
Layers Changed
| Layer | What Changes |
|---|---|
| Domain | Example entity → Preferences entity with typed fields and validation |
| Port | ExampleRepository interface → PreferencesRepository with Get/Upsert by UserID |
| Adapter | In-memory map keyed by UserID instead of ExampleID |
| Service | CRUD logic → Get + Upsert with shallow merge and authorization checks |
| Handlers | 5 endpoints (CRUD) → 2 endpoints (GET + PUT) with auth-gated access |
| OpenAPI | Example schemas/paths → Preferences schemas/paths |
| Config | No changes (auth config already exists) |
| main.go | Wire PreferencesRepository and PreferencesService instead of example equivalents |
What Is Removed
All example scaffold code: domain/example.go, port/example.go, adapter/memory/example.go, service/example.go, handlers/example.go, and their tests. The domain/errors.go file is replaced with preferences-specific errors.
Data Model Changes
Domain Entity: preferences.go
package domain
import "time"
// UserID is a strongly-typed identifier for users.
type UserID string
func (id UserID) String() string { return string(id) }
func (id UserID) IsZero() bool { return id == "" }
// Preferences holds all user preferences.
type Preferences struct {
UserID UserID
Theme string
Language string
Notifications NotificationSettings
UpdatedAt time.Time
}
type NotificationSettings struct {
Email bool
Push bool
Digest string
}
Allowed Values (validated in domain)
| Field | Type | Allowed | Default |
|---|---|---|---|
theme |
string | light, dark, system |
system |
language |
string | ISO 639-1 (validated via regex ^[a-z]{2}$) |
en |
notifications.email |
bool | true, false |
true |
notifications.push |
bool | true, false |
true |
notifications.digest |
string | daily, weekly, never |
weekly |
Domain Validation
// NewDefaultPreferences returns a Preferences with all defaults applied.
func NewDefaultPreferences(userID UserID) *Preferences
// Validate checks that all field values are within allowed sets.
// Returns a domain error listing invalid fields.
func (p *Preferences) Validate() error
// MergeFrom applies a shallow merge: top-level keys from `incoming` overwrite
// corresponding fields in `p`. The Notifications struct is replaced entirely
// when provided. Fields not present in `incoming` are left unchanged.
func (p *Preferences) MergeFrom(incoming *PreferencesUpdate)
Domain Errors: errors.go
var (
ErrPreferencesNotFound = errors.New("preferences not found")
ErrInvalidTheme = errors.New("invalid theme value")
ErrInvalidLanguage = errors.New("invalid language value")
ErrInvalidDigest = errors.New("invalid digest value")
ErrInvalidPreferences = errors.New("invalid preferences")
)
Merge Semantics (Detail)
The spec requires shallow merge at top-level keys. Concretely:
- Client sends
{"preferences": {"theme": "light"}}→ onlythemechanges;languageandnotificationsare untouched. - Client sends
{"preferences": {"notifications": {"email": false}}}→ the entirenotificationsstruct is replaced with{email: false, push: false, digest: ""}. Because we replace the whole nested object, missing sub-fields get zero values — but we fill missing notification sub-fields with defaults before validation to avoid forcing the client to always send the full object. This is the most user-friendly interpretation of "replaced entirely when provided" from the spec.
Implementation: A PreferencesUpdate struct uses pointer fields to distinguish "provided" from "not provided":
type PreferencesUpdate struct {
Theme *string
Language *string
Notifications *NotificationSettingsUpdate
}
type NotificationSettingsUpdate struct {
Email *bool
Push *bool
Digest *string
}
MergeFrom only overwrites fields where the pointer is non-nil. For Notifications, if the pointer is non-nil, individual sub-fields within NotificationSettingsUpdate are merged (non-nil pointers overwrite, nil pointers keep existing values). This provides the predictable behavior the spec describes while not forcing clients to send complete notification objects.
API Changes
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/preferences-api/preferences/{user_id} |
Required | Get all preferences for a user |
| PUT | /api/preferences-api/preferences/{user_id} |
Required | Create or update preferences (merge semantics) |
Removed Endpoints
All /api/preferences-api/examples/* endpoints are removed.
GET /api/preferences-api/preferences/{user_id}
Path Parameters:
user_id— UUID format, validated withhttpvalidationUUID validator
Authorization:
- Token subject must match
user_id, OR user must haveadminrole - Returns
403 Forbiddenif neither condition is met
Responses:
200 OK— preferences found, returned in envelope403 Forbidden— user_id doesn't match token and user is not admin404 Not Found— no preferences stored for this user
Response Body (200):
{
"data": {
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"preferences": {
"theme": "dark",
"language": "en",
"notifications": {
"email": true,
"push": false,
"digest": "weekly"
}
},
"updated_at": "2026-02-08T00:00:00Z"
},
"meta": { "request_id": "...", "timestamp": "..." }
}
PUT /api/preferences-api/preferences/{user_id}
Path Parameters:
user_id— UUID format
Authorization:
- Same as GET (own-user or admin)
Request Body:
{
"preferences": {
"theme": "light",
"notifications": {
"email": false
}
}
}
Validation:
preferencesfield must be present and a JSON object →400 Bad Requestif missing- Unknown top-level keys inside
preferences(anything other thantheme,language,notifications) →400 Bad Request - Values validated against allowed sets →
400 Bad Requestwith field-level details
Responses:
200 OK— preferences created or updated, full merged result returned400 Bad Request— validation failure (missing preferences, unknown keys, invalid values)403 Forbidden— authorization failure
Response Body (200): Same shape as GET response, with merged preferences.
Handler Request/Response Types
// PUT request body
type UpdatePreferencesRequest struct {
Preferences *PreferencesPayload `json:"preferences" validate:"required"`
}
type PreferencesPayload struct {
Theme *string `json:"theme,omitempty"`
Language *string `json:"language,omitempty"`
Notifications *NotificationSettingsPayload `json:"notifications,omitempty"`
}
type NotificationSettingsPayload struct {
Email *bool `json:"email,omitempty"`
Push *bool `json:"push,omitempty"`
Digest *string `json:"digest,omitempty"`
}
// GET/PUT response body (inside envelope)
type PreferencesResponse struct {
UserID string `json:"user_id"`
Preferences PreferencesDataResponse `json:"preferences"`
UpdatedAt string `json:"updated_at"`
}
type PreferencesDataResponse struct {
Theme string `json:"theme"`
Language string `json:"language"`
Notifications NotificationSettingsResponse `json:"notifications"`
}
type NotificationSettingsResponse struct {
Email bool `json:"email"`
Push bool `json:"push"`
Digest string `json:"digest"`
}
Unknown field rejection: Use app.BindStrict() for the top-level request, and perform manual check on the PreferencesPayload to reject unknown keys. Alternatively, use json.Decoder.DisallowUnknownFields() via app.BindAndValidateStrict() for the outer struct, and add a custom validation step for the inner preferences object keys.
Component Diagram
┌─────────────────────────────────────────────────────────────────┐
│ HTTP Client │
│ (Frontend / API Consumer) │
└──────────┬──────────────────────────────────────────┬────────────┘
│ GET /preferences/{user_id} │ PUT /preferences/{user_id}
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ chi Router │
│ ├── auth.Middleware() ─── JWT validation │
│ └── /api/preferences-api/preferences/{user_id} │
└──────────┬──────────────────────────────────────────┬────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Handlers (internal/api/handlers/preferences.go) │
│ ├── Get(w, r) error │
│ │ ├── Extract user_id from path, validate UUID │
│ │ ├── Authorization check (own-user or admin) │
│ │ ├── Call service.Get() │
│ │ └── Return httpresponse.OK() or httperror.NotFound() │
│ └── Update(w, r) error │
│ ├── Extract user_id from path, validate UUID │
│ ├── Authorization check (own-user or admin) │
│ ├── app.BindAndValidateStrict() request body │
│ ├── Call service.Upsert() │
│ └── Return httpresponse.OK() with merged result │
└──────────┬──────────────────────────────────────────┬────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Service (internal/service/preferences.go) │
│ ├── Get(ctx, userID) → (*Preferences, error) │
│ └── Upsert(ctx, userID, update) → (*Preferences, error) │
│ ├── repo.Get() to fetch existing (or create defaults) │
│ ├── domain.MergeFrom(update) │
│ ├── domain.Validate() │
│ └── repo.Upsert() │
└──────────┬──────────────────────────────────────────┬────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Port Interface (internal/port/preferences.go) │
│ PreferencesRepository { │
│ Get(ctx, userID) → (*Preferences, error) │
│ Upsert(ctx, userID, prefs) → error │
│ } │
└──────────┬──────────────────────────────────────────┬────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Adapter: In-Memory (internal/adapter/memory/preferences.go) │
│ map[UserID]*Preferences protected by sync.RWMutex │
│ ├── Get: lookup by key, return copy or ErrPreferencesNotFound │
│ └── Upsert: store copy (insert or replace) │
└─────────────────────────────────────────────────────────────────┘
Error Handling Strategy
Error Flow
Domain errors (domain.Err*)
↓ returned to service
Service returns domain errors unchanged
↓ returned to handler
Handler maps via mapDomainError()
↓ converts to httperror.*
app.Wrap() writes HTTP response
Error Mapping Table
| Domain Error | HTTP Status | HTTP Code | When |
|---|---|---|---|
ErrPreferencesNotFound |
404 | NOT_FOUND |
GET for user with no stored preferences |
ErrInvalidTheme |
400 | BAD_REQUEST |
Theme value not in allowed set |
ErrInvalidLanguage |
400 | BAD_REQUEST |
Language not matching ^[a-z]{2}$ |
ErrInvalidDigest |
400 | BAD_REQUEST |
Digest value not in allowed set |
ErrInvalidPreferences |
400 | BAD_REQUEST |
Generic validation failure |
| (binding error) | 400 | BAD_REQUEST |
Malformed JSON, missing preferences field |
| (unknown fields) | 400 | BAD_REQUEST |
Unknown keys in preferences object |
| (auth failure) | 401 | UNAUTHORIZED |
Missing or invalid JWT token |
| (authz failure) | 403 | FORBIDDEN |
user_id doesn't match token, not admin |
| (UUID validation) | 400 | BAD_REQUEST |
user_id path param not a valid UUID |
| (unexpected) | 500 | INTERNAL_ERROR |
Any unhandled error (logged by app.Wrap) |
Validation Detail Response
For validation errors, the response includes field-level details:
{
"error": {
"code": "BAD_REQUEST",
"message": "invalid preferences",
"details": [
{ "field": "theme", "message": "must be one of: light, dark, system" }
]
},
"meta": { "request_id": "...", "timestamp": "..." }
}
Security Considerations
Authentication
- Both endpoints require a valid JWT token via
auth.Middleware(). - Auth middleware runs before handler code — unauthenticated requests never reach service logic.
- Auth is gated by
AUTH_ENABLEDconfig (same as existing pattern) to allow local dev without JWT.
Authorization
- Own-user access: The handler extracts the authenticated user from context via
auth.GetUser(ctx)and comparesuser.IDwith theuser_idpath parameter. Mismatch returns403 Forbidden. - Admin override: If
user.HasRole("admin")is true, access is granted regardless of user_id match. - Authorization is enforced in the handler layer (not service layer) because it depends on HTTP context (path params + auth context).
Input Validation
| Input | Validation | Layer |
|---|---|---|
user_id path param |
UUID format regex | Handler |
| Request body JSON | Well-formed JSON, no unknown fields | Handler (via app.BindAndValidateStrict) |
preferences field |
Must be present and non-null | Handler (struct tag validate:"required") |
theme |
Must be light, dark, or system |
Domain |
language |
Must match ^[a-z]{2}$ |
Domain |
notifications.digest |
Must be daily, weekly, or never |
Domain |
| Unknown top-level keys | Rejected | Handler (strict binding) |
Data Exposure
- Preferences contain no secrets or PII beyond user_id (which the user already knows).
- No cross-user data leakage: authorization check prevents reading/writing other users' preferences.
- Error messages do not leak internal details.
Performance Considerations
Expected Load
- Preferences are read frequently (every page load / session init) and written rarely (settings page changes).
- Read-heavy workload: ~100:1 read-to-write ratio expected.
In-Memory Adapter Performance
- O(1) lookups by UserID (map key).
- No serialization overhead — domain structs stored directly.
sync.RWMutexallows concurrent reads; writes are serialized.- Suitable for development and testing. Production will use PostgreSQL adapter (out of scope).
Response Size
- Preferences response is small (~200 bytes JSON). No pagination needed.
- Single GET returns all preferences — no need for per-key endpoints.
Future Database Considerations (out of scope, noted for awareness)
user_idcolumn should be the primary key (one row per user).preferencescan be stored as JSONB for flexibility, or as individual columns for type safety.- Index on
user_id(primary key provides this automatically).
Migration / Rollout Plan
Step-by-Step
- Remove example scaffold: Delete all
example-related files (domain, port, adapter, service, handlers, tests). - Add domain layer: Create
Preferences,NotificationSettings,PreferencesUpdatetypes with validation. - Add port interface: Define
PreferencesRepositorywithGetandUpsertmethods. - Add in-memory adapter: Implement
PreferencesRepositorywith thread-safe map. - Add service layer: Implement
PreferencesServicewithGetandUpsert(merge + validate). - Add handlers: Implement GET and PUT with auth, validation, and error mapping.
- Update routes: Replace example routes with preferences routes, apply auth middleware.
- Update OpenAPI spec: Replace example schemas/paths with preferences schemas/paths.
- Update main.go: Wire new repository, service, and routes.
- Write tests: Service tests (merge logic, validation, authorization) and handler tests (HTTP layer).
Rollout Risk
- Zero risk to other services: Changes are entirely within
preferences-apiservice boundary. - No database migration: In-memory adapter means no schema changes.
- No API consumers yet: The example API has no consumers, so removing it has no compatibility impact.
- Backward incompatibility: The
/examplesendpoints are removed entirely. This is expected — the spec explicitly requires replacing the scaffold.
File Changes Summary
| Action | File |
|---|---|
| Delete | internal/domain/example.go |
| Delete | internal/domain/errors.go (recreated with new errors) |
| Delete | internal/port/example.go |
| Delete | internal/adapter/memory/example.go |
| Delete | internal/service/example.go |
| Delete | internal/service/example_test.go |
| Delete | internal/api/handlers/example.go |
| Delete | internal/api/handlers/example_test.go |
| Create | internal/domain/preferences.go |
| Create | internal/domain/errors.go |
| Create | internal/port/preferences.go |
| Create | internal/adapter/memory/preferences.go |
| Create | internal/service/preferences.go |
| Create | internal/service/preferences_test.go |
| Create | internal/api/handlers/preferences.go |
| Create | internal/api/handlers/preferences_test.go |
| Modify | internal/api/routes.go |
| Modify | internal/api/spec.go |
| Modify | cmd/server/main.go |
| Keep | internal/api/handlers/health.go (unchanged) |
| Keep | internal/config/config.go (unchanged) |