24 KiB
Design: Core Data Models & Persistence
Architecture Approach
This feature replaces the in-memory example adapter with PostgreSQL-backed persistence for four new domain entities: Project, Task, Label, and Assignment. Every layer of the hexagonal architecture is affected:
| Layer | What Changes |
|---|---|
Domain (internal/domain/) |
New entity files: project.go, task.go, label.go, assignment.go; extended errors.go |
Ports (internal/port/) |
Four new repository interfaces |
Adapters (internal/adapter/postgres/) |
New package with four Postgres repository implementations |
Services (internal/service/) |
Four new service files with business logic |
Handlers (internal/api/handlers/) |
Four new handler files with request/response DTOs |
Routes (internal/api/routes.go) |
Extended to register all new endpoints |
OpenAPI (internal/api/spec.go) |
Extended with schemas and paths for all entities |
Migrations (migrations/) |
Four SQL migration files |
Wiring (cmd/server/main.go) |
Database connection, migration execution, adapter creation, shutdown hook |
The existing Example entity and its layers remain untouched; new entities follow the identical established patterns.
Data Model Changes
New Database Tables
All tables use UUID primary keys, UTC timestamps, and snake_case naming.
projects
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
description VARCHAR(1000) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_projects_name UNIQUE (name),
CONSTRAINT chk_projects_status CHECK (status IN ('active', 'archived'))
);
tasks
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title VARCHAR(300) NOT NULL,
description VARCHAR(5000) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'todo',
priority VARCHAR(10) NOT NULL DEFAULT 'medium',
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_tasks_status CHECK (status IN ('todo', 'in_progress', 'done')),
CONSTRAINT chk_tasks_priority CHECK (priority IN ('low', 'medium', 'high'))
);
CREATE INDEX idx_tasks_project_id ON tasks(project_id);
CREATE INDEX idx_tasks_status ON tasks(status);
labels
CREATE TABLE labels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL,
color VARCHAR(7) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_labels_project_name UNIQUE (project_id, name)
);
CREATE INDEX idx_labels_project_id ON labels(project_id);
assignments
CREATE TABLE assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
assignee VARCHAR(200) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_assignments_task_assignee UNIQUE (task_id, assignee)
);
CREATE INDEX idx_assignments_task_id ON assignments(task_id);
Migration Files
| File | Purpose |
|---|---|
001_create_projects.sql |
projects table with unique name constraint |
002_create_tasks.sql |
tasks table with FK to projects, status/priority checks, indexes |
003_create_labels.sql |
labels table with FK to projects, unique (project_id, name) |
004_create_assignments.sql |
assignments table with FK to tasks, unique (task_id, assignee) |
Migrations are embedded via //go:embed migrations/*.sql and executed with database.MustRunMigrations(). Each runs in a transaction.
Domain Types
Each entity gets a strongly-typed ID and pure struct following the Example pattern:
// domain/project.go
type ProjectID string
type ProjectStatus string
const (
ProjectStatusActive ProjectStatus = "active"
ProjectStatusArchived ProjectStatus = "archived"
)
type Project struct {
ID ProjectID
Name string
Description string
Status ProjectStatus
CreatedAt time.Time
UpdatedAt time.Time
}
Similar patterns for Task (with TaskStatus, TaskPriority), Label, and Assignment. Constructors validate required fields and set timestamps. Update methods validate mutable fields and bump UpdatedAt.
API Changes
New Endpoints
All endpoints are mounted under /api/studio-api and follow existing patterns.
Projects
| Method | Path | Handler | Success | Description |
|---|---|---|---|---|
POST |
/projects |
Create |
201 | Create project |
GET |
/projects |
List |
200 | List all projects |
GET |
/projects/{id} |
Get |
200 | Get project by ID |
PUT |
/projects/{id} |
Update |
200 | Update project |
DELETE |
/projects/{id} |
Delete |
204 | Delete project (cascades) |
Tasks (nested under projects for create/list, flat for get/update/delete)
| Method | Path | Handler | Success | Description |
|---|---|---|---|---|
POST |
/projects/{projectId}/tasks |
Create |
201 | Create task in project |
GET |
/projects/{projectId}/tasks |
ListByProject |
200 | List tasks in project |
GET |
/tasks/{id} |
Get |
200 | Get task by ID |
PUT |
/tasks/{id} |
Update |
200 | Update task |
DELETE |
/tasks/{id} |
Delete |
204 | Delete task (cascades assignments) |
Labels (nested under projects for create/list, flat for get/update/delete)
| Method | Path | Handler | Success | Description |
|---|---|---|---|---|
POST |
/projects/{projectId}/labels |
Create |
201 | Create label in project |
GET |
/projects/{projectId}/labels |
ListByProject |
200 | List labels in project |
GET |
/labels/{id} |
Get |
200 | Get label by ID |
PUT |
/labels/{id} |
Update |
200 | Update label |
DELETE |
/labels/{id} |
Delete |
204 | Delete label |
Assignments (nested under tasks)
| Method | Path | Handler | Success | Description |
|---|---|---|---|---|
POST |
/tasks/{taskId}/assignments |
Create |
201 | Assign user to task |
GET |
/tasks/{taskId}/assignments |
ListByTask |
200 | List task assignments |
DELETE |
/assignments/{id} |
Delete |
204 | Remove assignment |
Request/Response Shapes
All responses use the {data, meta} envelope via httpresponse.OK/Created/NoContent.
CreateProjectRequest:
{
"name": "My Project", // required, 1-200 chars
"description": "Description" // optional, max 1000 chars
}
ProjectResponse:
{
"data": {
"id": "uuid",
"name": "My Project",
"description": "Description",
"status": "active",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
}
CreateTaskRequest:
{
"title": "Task title", // required, 1-300 chars
"description": "Details", // optional, max 5000 chars
"priority": "high", // optional, default "medium"
"position": 1 // optional, default 0
}
TaskResponse:
{
"data": {
"id": "uuid",
"project_id": "uuid",
"title": "Task title",
"description": "Details",
"status": "todo",
"priority": "high",
"position": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
}
CreateLabelRequest:
{
"name": "Bug", // required, 1-50 chars
"color": "#FF5733" // required, hex color
}
CreateAssignmentRequest:
{
"assignee": "user@example.com" // required, 1-200 chars
}
UpdateProjectRequest: same shape as create, plus optional status field ("active" or "archived").
UpdateTaskRequest: title, description, status, priority, position -- all mutable fields.
UpdateLabelRequest: name, color -- all mutable fields.
Error Responses
| HTTP Status | When |
|---|---|
| 400 | Invalid UUID format, validation failure, invalid status/priority |
| 404 | Entity not found |
| 409 | Duplicate project name, duplicate label name per project, duplicate assignment |
| 422 | Struct validation failure (from app.BindAndValidate) |
| 500 | Unexpected infrastructure error |
Component Diagram
┌─────────────────────────────────────────────────────────────┐
│ HTTP Client │
└────────────────────────────┬────────────────────────────────┘
│
┌────────▼────────┐
│ chi Router │
│ /api/studio-api│
└────────┬────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ Project │ │ Task │ │ Label │ ...
│ Handler │ │ Handler │ │ Handler │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ Project │ │ Task │ │ Label │ ...
│ Service │ │ Service │ │ Service │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ Project │ │ Task │ │ Label │ ...
│ Repository │ │ Repository │ │ Repository │
│ (port) │ │ (port) │ │ (port) │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
┌───────▼──────────────────▼──────────────────▼───────┐
│ Postgres Adapter Layer │
│ adapter/postgres/project.go │
│ adapter/postgres/task.go │
│ adapter/postgres/label.go │
│ adapter/postgres/assignment.go │
└──────────────────────┬──────────────────────────────┘
│
┌────────▼────────┐
│ database.Pool │
│ (*sqlx.DB) │
└────────┬────────┘
│
┌────────▼────────┐
│ PostgreSQL │
│ studio-db │
└─────────────────┘
Dependency Injection (main.go)
config.Load()
→ database.MustConnect(ctx, cfg.Database.URL, opts)
→ database.MustRunMigrations(ctx, pool, migrationsFS, "migrations")
→ postgres.NewProjectRepository(pool.DB)
→ postgres.NewTaskRepository(pool.DB)
→ postgres.NewLabelRepository(pool.DB)
→ postgres.NewAssignmentRepository(pool.DB)
→ service.NewProjectService(projectRepo, logger)
→ service.NewTaskService(taskRepo, projectRepo, logger)
→ service.NewLabelService(labelRepo, projectRepo, logger)
→ service.NewAssignmentService(assignmentRepo, taskRepo, logger)
→ api.RegisterRoutes(application, services...)
→ application.OnShutdown(pool.Close)
→ application.Run()
File Structure
services/studio-api/
├── cmd/server/main.go # Updated: DB connect, migrations, adapter wiring
├── migrations/
│ ├── 001_create_projects.sql
│ ├── 002_create_tasks.sql
│ ├── 003_create_labels.sql
│ └── 004_create_assignments.sql
├── internal/
│ ├── domain/
│ │ ├── errors.go # Extended: new sentinel errors
│ │ ├── project.go # New: Project entity
│ │ ├── task.go # New: Task entity
│ │ ├── label.go # New: Label entity
│ │ └── assignment.go # New: Assignment entity
│ ├── port/
│ │ ├── project.go # New: ProjectRepository interface
│ │ ├── task.go # New: TaskRepository interface
│ │ ├── label.go # New: LabelRepository interface
│ │ └── assignment.go # New: AssignmentRepository interface
│ ├── adapter/
│ │ └── postgres/
│ │ ├── project.go # New: Postgres ProjectRepository
│ │ ├── task.go # New: Postgres TaskRepository
│ │ ├── label.go # New: Postgres LabelRepository
│ │ └── assignment.go # New: Postgres AssignmentRepository
│ ├── service/
│ │ ├── project.go # New: ProjectService
│ │ ├── project_test.go # New: ProjectService tests
│ │ ├── task.go # New: TaskService
│ │ ├── task_test.go # New: TaskService tests
│ │ ├── label.go # New: LabelService
│ │ ├── label_test.go # New: LabelService tests
│ │ ├── assignment.go # New: AssignmentService
│ │ └── assignment_test.go # New: AssignmentService tests
│ └── api/
│ ├── routes.go # Updated: register new handlers
│ ├── spec.go # Updated: new schemas and paths
│ └── handlers/
│ ├── project.go # New: Project handler
│ ├── project_test.go # New: Project handler tests
│ ├── task.go # New: Task handler
│ ├── task_test.go # New: Task handler tests
│ ├── label.go # New: Label handler
│ ├── label_test.go # New: Label handler tests
│ ├── assignment.go # New: Assignment handler
│ └── assignment_test.go # New: Assignment handler tests
Error Handling Strategy
Domain Layer
Sentinel errors defined in domain/errors.go:
ErrProjectNotFound,ErrTaskNotFound,ErrLabelNotFound,ErrAssignmentNotFoundErrDuplicateProjectName,ErrDuplicateLabelName,ErrDuplicateAssignmentErrInvalidStatus,ErrInvalidPriorityErrProjectHasTasks(reserved for future restrict-delete if needed)
Adapter Layer (Postgres)
Each Postgres adapter maps database errors to domain errors:
sql.ErrNoRows→domain.ErrXxxNotFound- Unique constraint violation (Postgres error code
23505) →domain.ErrDuplicateXxx- Detected via
*pq.Errortype assertion withCode == "23505" - The specific constraint name determines which duplicate error to return
- Detected via
- FK violation (code
23503) →domain.ErrXxxNotFound(referenced entity missing) - All other errors propagate unwrapped (become 500s)
Service Layer
Business logic validation before persistence:
- Create project: Check
ExistsByNamebefore insert (avoids race to friendly error) - Create task: Verify project exists via
ProjectRepository.Get - Create label: Verify project exists, check
ExistsByNamescoped to project - Create assignment: Verify task exists, check
ExistsByTaskAndAssignee - Update operations: same duplicate checks when names change
Handler Layer
Each handler file contains a mapDomainError() function:
func mapDomainError(err error) error {
switch {
case errors.Is(err, domain.ErrProjectNotFound):
return httperror.NotFound("project not found")
case errors.Is(err, domain.ErrDuplicateProjectName):
return httperror.Conflict("project with this name already exists")
// ...
default:
return err // becomes 500 via app.Wrap()
}
}
Infrastructure Failures
- Database connection failures:
database.MustConnectpanics on startup (fail-fast) - Migration failures:
database.MustRunMigrationspanics on startup (fail-fast) - Runtime query failures: propagate through service → handler →
app.Wrap()→ 500
Security Considerations
Input Validation
- Struct tags: All request DTOs use
validate:"required,min=X,max=Y"enforced byapp.BindAndValidate() - UUID format: All ID parameters validated with
uuid.Parse()before use - Enum validation: Status and priority values validated in domain constructors and via CHECK constraints
- Color format: Label color validated as hex color format (regex
^#[0-9A-Fa-f]{6}$) in domain constructor - SQL injection: All queries use parameterized placeholders (
$1,$2) via sqlx; no string concatenation
Authentication
- Auth is opt-in per the existing pattern in
routes.go - Write endpoints (POST, PUT, DELETE) are placed inside the auth-protected group
- Read endpoints (GET) remain public (matching existing Example pattern)
- Auth middleware integration is not part of this feature per spec's out-of-scope
Data Boundaries
- Each entity is scoped: tasks/labels belong to a project, assignments belong to a task
- Service layer verifies parent entity exists before creating children
- No cross-service data access; all queries stay within
studio-api's database assigneeis an opaque string — no user lookup or validation against external systems
Cascading Deletes
ON DELETE CASCADEon FKs means deleting a project removes all its tasks, labels, and transitively all assignments- This is the spec's chosen behavior; the handler should document this in OpenAPI descriptions
Performance Considerations
Query Complexity
- All list operations are full table scans scoped by a single FK (e.g.,
WHERE project_id = $1) - Indexes on
project_idandtask_idFKs ensure efficient lookups - Index on
tasks.statussupports future filtering
Connection Pool
- Uses
pkg/databasepool defaults: 25 max open, 5 max idle, 5min lifetime - Configurable via
DATABASE_MAX_OPEN_CONNS,DATABASE_MAX_IDLE_CONNSenv vars - Pool is shared across all repository adapters
Expected Load
- This is a developer-facing studio tool, not a high-traffic public API
- No pagination in v1 (per spec out-of-scope) — acceptable for expected data volumes
- No caching layer needed; direct DB queries are sufficient
Future Optimization Points
- Add pagination when list endpoints exceed ~100 records
- Add filtering (by status, priority) as query parameters
- Consider read replicas if read-heavy patterns emerge
Migration / Rollout Plan
Step 1: Database Setup
- Ensure
DATABASE_URLis configured in the service environment - The PostgreSQL database must exist and be accessible
Step 2: Migration Execution
- Migrations run automatically on service startup via
database.MustRunMigrations() - They are idempotent (tracked in
schema_migrationstable) - Each migration runs in a transaction — partial failure rolls back
Step 3: Code Deployment
- The service binary includes embedded migrations and Postgres adapters
- On startup: connect → migrate → serve
- If migrations fail, the service panics and does not start (fail-fast)
Backward Compatibility
- The existing
Exampleentity and endpoints remain unchanged - New endpoints are additive — no existing API contracts are modified
- The in-memory
Exampleadapter remains for now (can be migrated to Postgres separately)
Rollback Strategy
- If issues are found, revert the deployment to the previous binary
- Migrations are forward-only (no down migrations in this system)
- To revert schema changes, deploy a new migration that drops/alters the tables
Health Checks
- Add database health check to the existing health endpoint:
healthHandler := handlers.NewHealth(logger, app.HealthConfig{ Service: "studio-api", Checks: map[string]app.HealthChecker{ "database": app.PingChecker(pool.Ping), }, }) - Service reports unhealthy (503) if database is unreachable
Design Decisions
-
Cascade deletes (not restrict): The spec calls for
ON DELETE CASCADE. Deleting a project removes everything underneath. This is simpler and matches the user mental model of "delete project = delete everything in it." -
Separate Assignment entity (not a field on Task): Supports multiple assignees per task. The
(task_id, assignee)unique constraint prevents duplicates while allowing flexibility. -
Position field included: Included per spec. Default 0, managed as a simple integer. No automatic reordering — the client is responsible for setting position values.
-
No status transition rules: Free status changes as specified. Any status can transition to any other valid status. Business rules can be layered on later.
-
DATABASE_URLconfiguration: Uses the existingconfig.ReadDatabaseConfig()+ env var pattern. No new database name concept — the database is whateverDATABASE_URLpoints to. -
Postgres adapters take
*sqlx.DB: Following the established pattern frompkg/queue/postgres.go. The adapter receives the raw*sqlx.DB(frompool.DB), not the*database.Poolwrapper. -
Internal row structs in adapters: Each Postgres adapter defines a private
xxxRowstruct withdb:"column_name"tags, and atoDomain()converter. This decouples the DB schema from the domain model. -
One handler struct per entity: Each entity gets its own handler file with request/response DTOs and
mapDomainError(). This keeps files focused and avoids a monolithic handler. -
TaskService depends on ProjectRepository: To verify the parent project exists when creating a task. Same pattern for LabelService. AssignmentService depends on TaskRepository.
-
No soft deletes: Hard deletes only, per spec.
deleted_atcolumns are not included.