17 KiB
Design: User Preferences API
Architecture Approach
Replace the example CRUD scaffolding in services/preferences-api with a real user preferences domain. All six layers of the hexagonal architecture change:
| Layer | What changes |
|---|---|
| Domain | Replace Example entity with UserPreferences entity. New validation for theme, language, notification fields. |
| Port | Replace ExampleRepository with PreferencesRepository (2 methods: Get, Upsert). |
| Adapter | Add internal/adapter/postgres/ with a JSONB-backed PostgreSQL implementation. Remove internal/adapter/memory/. |
| Service | Replace ExampleService with PreferencesService (2 use cases: GetPreferences, UpdatePreferences). |
| Handlers | Replace example CRUD handlers with GET /preferences/{user_id} and PUT /preferences/{user_id}. |
| API | Update routes and OpenAPI spec. Remove all example endpoint definitions. |
The existing main.go wiring, config, and health handler remain. main.go changes to connect to PostgreSQL (via pkg/database) and run migrations on startup.
Design Decisions
- Return defaults for unknown users (200, not 404): Simpler frontend DX. The service returns a default
UserPreferencesstruct when no row exists. - Reject unknown preference keys: Use
app.BindAndValidateStrict()to reject unknown JSON fields. This catches typos and prevents silent data loss. Forward compatibility can be added later when new keys are defined. - Accept any valid UUID for
user_id: No inter-service call to validate user existence. The preferences service is a simple key-value store keyed by UUID. This avoids coupling and latency. - JSONB for preferences storage: Single
preferencesJSONB column for the nested preference object. One row per user. Flexible schema that doesn't require migrations when adding new preference keys in the future. - Deep merge on PUT: The service performs a deep merge of the incoming JSON with existing preferences. Keys not included in the request body remain unchanged. Nested objects (like
notifications) are merged recursively, not replaced wholesale.
Data Model Changes
New Table: user_preferences
CREATE TABLE IF NOT EXISTS user_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()
);
Migration file: services/preferences-api/migrations/001_create_user_preferences.sql
Domain Types
// domain/preferences.go
type UserID string
type Preferences struct {
Theme string `json:"theme"`
Language string `json:"language"`
Notifications NotificationSettings `json:"notifications"`
}
type NotificationSettings struct {
Email bool `json:"email"`
Push bool `json:"push"`
Digest string `json:"digest"`
}
type UserPreferences struct {
UserID UserID
Preferences Preferences
UpdatedAt time.Time
}
Default Values
func DefaultPreferences() Preferences {
return Preferences{
Theme: "system",
Language: "en",
Notifications: NotificationSettings{
Email: true,
Push: true,
Digest: "weekly",
},
}
}
Domain Validation
Validation lives in the domain layer, called by the service layer before persistence:
func (p *Preferences) Validate() error { ... }
| Field | Rule | Error |
|---|---|---|
theme |
Must be "light", "dark", or "system" |
ErrInvalidTheme |
language |
Must be non-empty string | ErrInvalidLanguage |
notifications.email |
Boolean (validated by JSON binding) | N/A |
notifications.push |
Boolean (validated by JSON binding) | N/A |
notifications.digest |
Must be "daily", "weekly", or "never" |
ErrInvalidDigest |
API Changes
Endpoints
All routes mounted under /api/preferences-api.
GET /api/preferences-api/preferences/{user_id}
Retrieve preferences for a user. Returns defaults if no preferences are stored.
Path Parameter:
user_id(UUID, required) - Validated withuuid.Parse()
Response 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-09T12:00:00Z"
},
"meta": {
"request_id": "...",
"timestamp": "..."
}
}
Response 400: Invalid user_id format.
PUT /api/preferences-api/preferences/{user_id}
Create or update preferences (upsert with deep merge).
Path Parameter:
user_id(UUID, required)
Request Body:
{
"preferences": {
"theme": "light",
"notifications": {
"push": true
}
}
}
Only provided keys are changed. Omitted keys retain their current value (or default if no row exists).
Response 200: Full merged preference set after update.
Response 400: Invalid user_id or invalid preference values.
Request/Response Types (Handler Layer)
// UpdatePreferencesRequest is the PUT request body.
type UpdatePreferencesRequest struct {
Preferences PreferencesInput `json:"preferences" validate:"required"`
}
// PreferencesInput uses pointers to distinguish "not provided" from zero values.
type PreferencesInput struct {
Theme *string `json:"theme,omitempty"`
Language *string `json:"language,omitempty"`
Notifications *NotificationsInput `json:"notifications,omitempty"`
}
type NotificationsInput struct {
Email *bool `json:"email,omitempty"`
Push *bool `json:"push,omitempty"`
Digest *string `json:"digest,omitempty"`
}
// PreferencesResponse is the GET/PUT response shape.
type PreferencesResponse struct {
UserID string `json:"user_id"`
Preferences domain.Preferences `json:"preferences"`
UpdatedAt string `json:"updated_at"`
}
Component Diagram
┌─────────────────────────────────────────────────────────┐
│ HTTP Client │
└────────────┬────────────────────────┬───────────────────┘
│ GET /preferences/{id} │ PUT /preferences/{id}
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ api/routes.go │
│ ┌───────────────────────────────────────────────────┐ │
│ │ app.Wrap(handler.Get) app.Wrap(handler.Upsert) │ │
│ └───────────────────────────────────────────────────┘ │
└────────────┬────────────────────────┬───────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ handlers/preferences.go │
│ - Validates user_id (UUID parse) │
│ - Binds & validates request body │
│ - Calls service layer │
│ - Maps domain errors → httperror │
│ - Returns httpresponse.OK(w, r, response) │
└────────────┬────────────────────────┬───────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ service/preferences.go │
│ - GetPreferences: repo.Get → defaults if not found │
│ - UpdatePreferences: repo.Get → merge → validate → │
│ repo.Upsert │
└────────────┬────────────────────────┬───────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ port/preferences.go (interface) │
│ - Get(ctx, userID) → (*UserPreferences, error) │
│ - Upsert(ctx, *UserPreferences) → error │
└────────────┬────────────────────────┬───────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ adapter/postgres/preferences.go │
│ - Get: SELECT ... WHERE user_id = $1 │
│ - Upsert: INSERT ... ON CONFLICT (user_id) DO UPDATE │
│ Uses *database.Pool (sqlx) │
└────────────┬────────────────────────┬───────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ PostgreSQL: user_preferences table │
│ (user_id UUID PK, preferences JSONB, timestamps) │
└─────────────────────────────────────────────────────────┘
Error Handling Strategy
Domain Errors
var (
ErrInvalidTheme = errors.New("invalid theme: must be light, dark, or system")
ErrInvalidLanguage = errors.New("invalid language: must be non-empty")
ErrInvalidDigest = errors.New("invalid digest: must be daily, weekly, or never")
)
Error Mapping (handler layer)
| Domain Error | HTTP Error | Status |
|---|---|---|
ErrInvalidTheme |
httperror.BadRequest(msg) |
400 |
ErrInvalidLanguage |
httperror.BadRequest(msg) |
400 |
ErrInvalidDigest |
httperror.BadRequest(msg) |
400 |
| Invalid UUID (user_id) | httperror.BadRequest("invalid user_id format") |
400 |
| Request body parse error | Handled by app.BindAndValidate() |
400 |
| Database connection error | Unhandled → app.Wrap() returns 500 |
500 |
Key Behaviors
- GET for unknown user: Returns 200 with default preferences (not 404). No error.
- PUT with empty body: Returns 400 via
app.BindAndValidate()(thepreferencesfield isvalidate:"required"). - PUT with partial preferences: Merges with existing. Only validates provided fields.
- Database errors: Bubble up as raw errors.
app.Wrap()converts them to 500.
Security Considerations
- No authentication required for this feature (per spec: auth is out of scope). Routes are public. Auth middleware can be added later via route group.
- User ID from URL path, not session: Any caller can read/write any user's preferences. This is intentional — the preferences service is a backend store, not a user-facing endpoint. Upstream services/gateways enforce authorization.
- Input validation: All preference values are validated against allowlists. No arbitrary string storage.
- SQL injection prevention: All queries use parameterized placeholders (
$1,$2). JSONB values are marshaled byencoding/jsonand passed as parameters. - Request body size: Limited by the framework's default max body size.
- No sensitive data: Preferences (theme, language, notifications) contain no PII or secrets.
- Strict JSON binding: Unknown fields in the request body are rejected to prevent confusion.
Performance Considerations
- Single row per user: O(1) lookup by UUID primary key. No joins, no pagination needed.
- JSONB column: PostgreSQL JSONB is compact and efficient for reads. No need for GIN indexes — we query by
user_idPK only, never by preference content. - No caching layer: For MVP, direct database reads are sufficient. The query is a simple PK lookup. If latency becomes an issue, an in-memory or Redis cache can be added as a separate adapter behind the same port interface.
- Upsert atomicity:
INSERT ... ON CONFLICT DO UPDATEis a single atomic statement. No race conditions on concurrent writes for the same user. - JSONB merge in application layer: The merge happens in Go, not in SQL. This keeps the SQL simple and the merge logic testable. The full merged JSONB is written back. For this data size (~200 bytes of JSON), this is efficient.
- Expected load: Low. Preferences are read on session start and written on settings change. Well within single-instance PostgreSQL capacity.
Migration / Rollout Plan
Step 1: Remove Example Scaffolding
Delete all example-related files:
internal/domain/example.gointernal/port/example.gointernal/service/example.go,example_test.gointernal/api/handlers/example.go,example_test.gointernal/adapter/memory/example.go
Remove example routes and OpenAPI definitions from routes.go and spec.go.
Step 2: Add Preferences Domain
Create new files following the same directory structure:
internal/domain/preferences.go— entity, validation, defaultsinternal/domain/errors.go— updated with preference-specific errorsinternal/port/preferences.go—PreferencesRepositoryinterfaceinternal/service/preferences.go—PreferencesServicewithGetPreferencesandUpdatePreferencesinternal/service/preferences_test.go— unit tests with mock repositoryinternal/api/handlers/preferences.go— HTTP handlersinternal/api/handlers/preferences_test.go— handler tests
Step 3: Add PostgreSQL Adapter
internal/adapter/postgres/preferences.go— implementsPreferencesRepositorymigrations/001_create_user_preferences.sql— table creation
Step 4: Update Wiring
internal/api/routes.go— register new preference routesinternal/api/spec.go— new OpenAPI definitions for preference endpointscmd/server/main.go— connect to PostgreSQL, run migrations, wire PostgreSQL adapter
Step 5: Verify
- All unit tests pass (
go test -v ./...) - OpenAPI spec exports correctly (
--export-openapiflag) - Health endpoint still works
- Manual verification against acceptance criteria
Backward Compatibility
This is a breaking replacement of the example scaffolding, which was never a production API. No backward compatibility is needed. The example endpoints (/examples, /examples/{id}) are removed entirely.
File Inventory
| Action | File |
|---|---|
| Delete | internal/domain/example.go |
| Delete | internal/port/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 |
| Delete | internal/adapter/memory/example.go |
| Modify | internal/domain/errors.go |
| Modify | internal/api/routes.go |
| Modify | internal/api/spec.go |
| Modify | cmd/server/main.go |
| Create | internal/domain/preferences.go |
| Create | internal/port/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 |
| Create | internal/adapter/postgres/preferences.go |
| Create | migrations/001_create_user_preferences.sql |