build: /design-feature user-preferences
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
parent
eb65fe7c94
commit
a399ce7510
363
.sdlc/features/user-preferences/design.md
Normal file
363
.sdlc/features/user-preferences/design.md
Normal file
@ -0,0 +1,363 @@
|
||||
# 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
|
||||
|
||||
1. **Return defaults for unknown users (200, not 404):** Simpler frontend DX. The service returns a default `UserPreferences` struct when no row exists.
|
||||
2. **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.
|
||||
3. **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.
|
||||
4. **JSONB for preferences storage:** Single `preferences` JSONB 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.
|
||||
5. **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`
|
||||
|
||||
```sql
|
||||
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
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
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:
|
||||
|
||||
```go
|
||||
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 with `uuid.Parse()`
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"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:**
|
||||
```json
|
||||
{
|
||||
"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)
|
||||
|
||||
```go
|
||||
// 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
|
||||
|
||||
```go
|
||||
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()` (the `preferences` field is `validate:"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 by `encoding/json` and 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_id` PK 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 UPDATE` is 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.go`
|
||||
- `internal/port/example.go`
|
||||
- `internal/service/example.go`, `example_test.go`
|
||||
- `internal/api/handlers/example.go`, `example_test.go`
|
||||
- `internal/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, defaults
|
||||
- `internal/domain/errors.go` — updated with preference-specific errors
|
||||
- `internal/port/preferences.go` — `PreferencesRepository` interface
|
||||
- `internal/service/preferences.go` — `PreferencesService` with `GetPreferences` and `UpdatePreferences`
|
||||
- `internal/service/preferences_test.go` — unit tests with mock repository
|
||||
- `internal/api/handlers/preferences.go` — HTTP handlers
|
||||
- `internal/api/handlers/preferences_test.go` — handler tests
|
||||
|
||||
### Step 3: Add PostgreSQL Adapter
|
||||
|
||||
- `internal/adapter/postgres/preferences.go` — implements `PreferencesRepository`
|
||||
- `migrations/001_create_user_preferences.sql` — table creation
|
||||
|
||||
### Step 4: Update Wiring
|
||||
|
||||
- `internal/api/routes.go` — register new preference routes
|
||||
- `internal/api/spec.go` — new OpenAPI definitions for preference endpoints
|
||||
- `cmd/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-openapi` flag)
|
||||
- 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` |
|
||||
@ -13,7 +13,7 @@ artifacts:
|
||||
status: pending
|
||||
path: audit.md
|
||||
design:
|
||||
status: pending
|
||||
status: draft
|
||||
path: design.md
|
||||
qa_plan:
|
||||
status: pending
|
||||
|
||||
Loading…
Reference in New Issue
Block a user