slack5-1770606136/.sdlc/features/user-preferences/design.md
rdev-worker 414c1b5464
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /design-feature user-preferences
2026-02-09 03:15:33 +00:00

461 lines
20 KiB
Markdown

# 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 `UserPreferences` model replaces `Example`; validation rules for known preference keys
- **Port layer**: New `PreferenceRepository` interface with `Get` and `Upsert` (replaces `ExampleRepository`)
- **Service layer**: New `PreferenceService` with validation logic and delegation to repository
- **Adapter layer**: New `postgres/preference.go` adapter using sqlx + JSONB (replaces `memory/example.go`)
- **Handler layer**: New `preference.go` handler with GET/PUT endpoints (replaces `example.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/openapi` usage patterns
- Service port 8001, route prefix `/api/preferences-api`
- Config structure (already has `Database` config)
## Data Model Changes
### Domain Model
```go
// 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
```sql
-- 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_id` is a valid UUID; returns `400` if not
- Returns `200` with `{data, meta}` envelope containing user preferences
- Returns `200` with empty preferences object `{}` when no row exists (not 404)
#### PUT /api/preferences-api/preferences/{user_id}
- Validates `user_id` is a valid UUID; returns `400` if 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 `200` with updated preferences in `{data, meta}` envelope
- Returns `400` with details when validation fails
### Request/Response Shapes
These match the spec exactly. See `spec.md` for full JSON examples.
**GET response data:**
```json
{
"user_id": "uuid",
"preferences": {"theme": "dark", ...},
"updated_at": "2026-02-09T12:00:00Z"
}
```
**PUT request body:**
```json
{
"preferences": {"theme": "dark", "language": "fr"}
}
```
**PUT response data:** Same shape as GET response data.
**Validation error:**
```json
{
"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`)
```go
type UserPreferences struct {
UserID string
Preferences map[string]any
CreatedAt time.Time
UpdatedAt time.Time
}
```
Domain errors in `internal/domain/errors.go`:
```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`)
```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`)
```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`:**
1. Iterate over input preferences keys
2. For known keys, validate values:
- `theme`: must be `"light"`, `"dark"`, or `"system"`
- `language`: must be a valid BCP-47 tag (validate with `language.Parse` from `golang.org/x/text/language`)
- `notifications_enabled`: must be a boolean
3. Unknown keys: accept any JSON value (no validation)
4. If validation errors exist, return a structured error with per-field details
5. 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`)
```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:**
```sql
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:**
```sql
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`)
```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:**
1. Extract `user_id` from URL with `chi.URLParam(r, "user_id")`
2. Validate UUID with `uuid.Parse(userID)``httperror.BadRequest` on failure
3. Call `svc.Get(ctx, userID)`
4. If nil result (no preferences), return `httpresponse.OK` with empty defaults
5. If result exists, return `httpresponse.OK` with mapped response
**PUT handler flow:**
1. Extract and validate `user_id` (same as GET)
2. Bind request body with `app.Bind(r, &req)` (not BindAndValidate—custom validation in service)
3. Validate `req.Preferences` is not nil → `httperror.BadRequest` if missing
4. Call `svc.Upsert(ctx, input)`
5. Map domain errors → `httperror.BadRequest` with details for validation errors
6. Return `httpresponse.OK` with mapped response
**Domain error mapping:**
```go
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`)
```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:
1. Load config (database URL)
2. Connect to PostgreSQL via `database.MustConnect`
3. Run migrations via `database.MustRunMigrations`
4. Create `postgres.NewPreferenceRepository(pool.DB)`
5. Create `service.NewPreferenceService(repo, logger)`
6. Register routes with preference service
7. 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.
```go
// 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
1. **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).
2. **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
3. **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
4. **Injection prevention**:
- All database queries use parameterized queries ($1, $2 placeholders)
- JSONB values marshaled through Go's `encoding/json`, not string concatenation
5. **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
1. **Query complexity**: Both queries are single-row operations on a primary key (UUID). O(1) index lookup. No joins, no scans.
2. **Expected load**: Preferences are read frequently (every page load) and written infrequently (settings changes). Read-heavy workload.
3. **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.
4. **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.
5. **Connection pooling**: `pkg/database` provides connection pooling (default 25 open, 5 idle). Adequate for expected load.
6. **Index**: The `updated_at` index 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.sql` to `migrations/` 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_migrations` table tracks applied migrations; if needed, manually remove the entry and drop the table
### Open Questions Resolution (Design Decisions)
1. **Unknown key value-type validation**: Accept any valid JSON value. No type restriction. This keeps the system maximally extensible.
2. **Max preference keys**: No limit in initial implementation. PostgreSQL JSONB handles large objects well. Add limit if abuse observed.
3. **User existence validation**: No cross-service validation. Treat user_id as opaque UUID. Any valid UUID can have preferences.
4. **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 |