25 KiB
Design: Core Data Models & Persistence
Architecture Approach
This feature adds four new domain entities (Project, Task, Label, Assignment) and a task-label join to the existing studio-api service. The implementation follows the established hexagonal architecture pattern demonstrated by the Example entity, replicating the same layering:
domain → service → port (interface) → adapter/postgres (implementation)
What's new:
- 5 domain entities with strong-typed IDs and constructor validation
- 5 port interfaces defining repository contracts
- 5 PostgreSQL adapter implementations (new
adapter/postgres/package) - 4 service-layer orchestrators (TaskLabel and Assignment share a service)
- 4 handler files with CRUD + association endpoints
- SQL migrations creating 5 tables with FK constraints and indexes
main.gowired to connect to PostgreSQL, run migrations, and inject postgres adapters
What's modified:
main.go— adds database connection, migration runner, new service/handler wiringinternal/api/routes.go— adds new route registrationsinternal/api/spec.go— adds OpenAPI schemas and endpoint documentationinternal/domain/errors.go— adds domain errors for new entitiesinternal/config/config.go— already hasDatabaseconfig (no changes needed)
What's preserved:
- The existing
Exampleentity and its in-memory adapter remain untouched (per spec)
Data Model Changes
Entity Relationship Diagram
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ projects │ │ tasks │ │ labels │
├──────────────┤ ├──────────────────┤ ├──────────────┤
│ id (PK,UUID) │──1:N──│ id (PK,UUID) │ │ id (PK,UUID) │
│ name (UNIQ) │ │ project_id (FK) │ │ name (UNIQ) │
│ description │ │ title │ │ color │
│ created_at │ │ description │ │ created_at │
│ updated_at │ │ status │ └──────┬───────┘
└──────────────┘ │ priority │ │
│ created_at │ │
│ updated_at │ │
└───────┬──────────┘ │
│ │
┌──────────┴──────────┐ ┌────────┴────────┐
│ assignments │ │ task_labels │
├─────────────────────┤ ├─────────────────┤
│ id (PK,UUID) │ │ task_id (FK,PK) │
│ task_id (FK) │ │ label_id (FK,PK)│
│ user_id (STRING) │ │ created_at │
│ assigned_at │ └─────────────────┘
└─────────────────────┘
Domain Types
Each entity follows the Example pattern with strong-typed IDs:
// internal/domain/project.go
type ProjectID string
type Project struct {
ID ProjectID
Name string
Description string
CreatedAt time.Time
UpdatedAt time.Time
}
// internal/domain/task.go
type TaskID string
type TaskStatus string
type TaskPriority string
const (
TaskStatusOpen TaskStatus = "open"
TaskStatusInProgress TaskStatus = "in_progress"
TaskStatusDone TaskStatus = "done"
TaskStatusCancelled TaskStatus = "cancelled"
)
const (
TaskPriorityLow TaskPriority = "low"
TaskPriorityMedium TaskPriority = "medium"
TaskPriorityHigh TaskPriority = "high"
TaskPriorityCritical TaskPriority = "critical"
)
type Task struct {
ID TaskID
ProjectID ProjectID
Title string
Description string
Status TaskStatus
Priority TaskPriority
CreatedAt time.Time
UpdatedAt time.Time
}
// internal/domain/label.go
type LabelID string
type Label struct {
ID LabelID
Name string
Color string
CreatedAt time.Time
}
// internal/domain/assignment.go
type AssignmentID string
type Assignment struct {
ID AssignmentID
TaskID TaskID
UserID string
AssignedAt time.Time
}
// internal/domain/task_label.go
type TaskLabel struct {
TaskID TaskID
LabelID LabelID
CreatedAt time.Time
}
SQL Migrations
Three migration files in services/studio-api/migrations/:
001_create_projects.sql
CREATE TABLE projects (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
002_create_tasks.sql
CREATE TABLE tasks (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
description VARCHAR(2000),
status VARCHAR(20) NOT NULL DEFAULT 'open',
priority VARCHAR(20) NOT NULL DEFAULT 'medium',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_project_id ON tasks(project_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_priority ON tasks(priority);
003_create_labels_and_associations.sql
CREATE TABLE labels (
id UUID PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE task_labels (
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (task_id, label_id)
);
CREATE INDEX idx_task_labels_label_id ON task_labels(label_id);
CREATE TABLE assignments (
id UUID PRIMARY KEY,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
user_id VARCHAR(255) NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(task_id, user_id)
);
CREATE INDEX idx_assignments_task_id ON assignments(task_id);
CREATE INDEX idx_assignments_user_id ON assignments(user_id);
API Changes
All endpoints are under /api/studio-api/ to match existing ingress routing.
Projects
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/projects |
No | List all projects |
GET |
/projects/{id} |
No | Get project by ID |
POST |
/projects |
Yes | Create project |
PUT |
/projects/{id} |
Yes | Update project |
DELETE |
/projects/{id} |
Yes | Delete project (cascades) |
Request/Response shapes:
// POST /projects
Request: { "name": "My Project", "description": "optional" }
Response: { "data": { "id": "uuid", "name": "...", "description": "...", "created_at": "...", "updated_at": "..." } }
// GET /projects
Response: { "data": [{ "id": "...", ... }] }
Tasks
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/projects/{projectId}/tasks |
No | List tasks in project (filterable) |
POST |
/projects/{projectId}/tasks |
Yes | Create task in project |
GET |
/tasks/{id} |
No | Get task by ID |
PUT |
/tasks/{id} |
Yes | Update task |
DELETE |
/tasks/{id} |
Yes | Delete task |
Query parameters for list: ?status=open&priority=high
Request/Response shapes:
// POST /projects/{projectId}/tasks
Request: { "title": "Implement login", "description": "...", "status": "open", "priority": "high" }
Response: { "data": { "id": "uuid", "project_id": "uuid", "title": "...", "description": "...", "status": "open", "priority": "high", "created_at": "...", "updated_at": "..." } }
Labels
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/labels |
No | List all labels |
GET |
/labels/{id} |
No | Get label by ID |
POST |
/labels |
Yes | Create label |
PUT |
/labels/{id} |
Yes | Update label |
DELETE |
/labels/{id} |
Yes | Delete label (cascades) |
Request/Response shapes:
// POST /labels
Request: { "name": "Bug", "color": "#FF5733" }
Response: { "data": { "id": "uuid", "name": "Bug", "color": "#FF5733", "created_at": "..." } }
Task-Label Associations
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/tasks/{taskId}/labels |
No | List labels for a task |
POST |
/tasks/{taskId}/labels/{labelId} |
Yes | Attach label to task |
DELETE |
/tasks/{taskId}/labels/{labelId} |
Yes | Remove label from task |
Task-User Assignments
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/tasks/{taskId}/assignments |
No | List assignments for a task |
POST |
/tasks/{taskId}/assignments |
Yes | Assign user to task |
DELETE |
/tasks/{taskId}/assignments/{assignmentId} |
Yes | Remove assignment |
Request/Response shapes:
// POST /tasks/{taskId}/assignments
Request: { "user_id": "user-123" }
Response: { "data": { "id": "uuid", "task_id": "uuid", "user_id": "user-123", "assigned_at": "..." } }
Component Diagram
┌─────────────────────────────────────────────────────────────┐
│ HTTP Layer │
│ routes.go → handlers/{project,task,label,association}.go │
│ Uses: app.Wrap(), app.BindAndValidate(), chi.URLParam() │
│ Returns: httpresponse.OK/Created/NoContent, httperror.* │
└──────────────────────────┬──────────────────────────────────┘
│ calls service methods
┌──────────────────────────▼──────────────────────────────────┐
│ Service Layer │
│ service/{project,task,label,association}.go │
│ Business logic: duplicate checks, FK validation, │
│ UUID generation, domain entity construction │
└──────────────────────────┬──────────────────────────────────┘
│ calls port interfaces
┌──────────────────────────▼──────────────────────────────────┐
│ Port Layer │
│ port/{project,task,label,task_label,assignment}.go │
│ Repository interfaces for each entity │
└──────────────────────────┬──────────────────────────────────┘
│ implemented by
┌──────────────────────────▼──────────────────────────────────┐
│ Adapter Layer │
│ adapter/postgres/{project,task,label,task_label, │
│ assignment}.go │
│ Raw SQL via sqlx (pool.DB.SelectContext, GetContext, etc.) │
└──────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────┐
│ PostgreSQL │
│ projects, tasks, labels, task_labels, assignments tables │
│ FK constraints with ON DELETE CASCADE │
│ Unique constraints for name deduplication │
└─────────────────────────────────────────────────────────────┘
Wiring (main.go)
┌─────────────────────────────────────────────────────────────┐
│ 1. database.MustConnect(ctx, cfg.Database.URL, opts) │
│ 2. database.MustRunMigrations(ctx, pool, migrationsFS, dir) │
│ 3. postgres.NewXxxRepository(pool.DB) for each entity │
│ 4. service.NewXxxService(repo, logger) for each entity │
│ 5. api.RegisterRoutes(app, projectSvc, taskSvc, ...) │
│ 6. pool.Close() via app.OnShutdown() │
└─────────────────────────────────────────────────────────────┘
File Inventory
New Files
| File | Purpose |
|---|---|
internal/domain/project.go |
Project entity, ProjectID, constructor, validation |
internal/domain/task.go |
Task entity, TaskID, status/priority enums, constructor, validation |
internal/domain/label.go |
Label entity, LabelID, constructor, validation |
internal/domain/assignment.go |
Assignment entity, AssignmentID, constructor |
internal/domain/task_label.go |
TaskLabel join entity |
internal/port/project.go |
ProjectRepository interface |
internal/port/task.go |
TaskRepository interface |
internal/port/label.go |
LabelRepository interface |
internal/port/task_label.go |
TaskLabelRepository interface |
internal/port/assignment.go |
AssignmentRepository interface |
internal/adapter/postgres/project.go |
PostgreSQL ProjectRepository |
internal/adapter/postgres/task.go |
PostgreSQL TaskRepository |
internal/adapter/postgres/label.go |
PostgreSQL LabelRepository |
internal/adapter/postgres/task_label.go |
PostgreSQL TaskLabelRepository |
internal/adapter/postgres/assignment.go |
PostgreSQL AssignmentRepository |
internal/service/project.go |
ProjectService with business logic |
internal/service/task.go |
TaskService with business logic |
internal/service/label.go |
LabelService with business logic |
internal/service/association.go |
AssociationService (task-labels + assignments) |
internal/api/handlers/project.go |
Project CRUD handlers |
internal/api/handlers/task.go |
Task CRUD handlers |
internal/api/handlers/label.go |
Label CRUD handlers |
internal/api/handlers/association.go |
Task-label and assignment handlers |
migrations/001_create_projects.sql |
Projects table |
migrations/002_create_tasks.sql |
Tasks table + indexes |
migrations/003_create_labels_and_associations.sql |
Labels, task_labels, assignments tables |
New Test Files
| File | Purpose |
|---|---|
internal/domain/project_test.go |
Project constructor/validation tests |
internal/domain/task_test.go |
Task constructor/validation/enum tests |
internal/domain/label_test.go |
Label constructor/validation tests |
internal/service/project_test.go |
ProjectService unit tests (mock repo) |
internal/service/task_test.go |
TaskService unit tests (mock repo) |
internal/service/label_test.go |
LabelService unit tests (mock repo) |
internal/service/association_test.go |
AssociationService unit tests |
internal/api/handlers/project_test.go |
Project handler tests (httptest) |
internal/api/handlers/task_test.go |
Task handler tests (httptest) |
internal/api/handlers/label_test.go |
Label handler tests (httptest) |
internal/api/handlers/association_test.go |
Association handler tests (httptest) |
Modified Files
| File | Changes |
|---|---|
internal/domain/errors.go |
Add Project/Task/Label/Assignment error sentinels |
internal/api/routes.go |
Add route registrations for all new endpoints |
internal/api/spec.go |
Add OpenAPI schemas and paths for all new endpoints |
cmd/server/main.go |
Add DB connection, migrations, postgres adapters, new services |
Error Handling Strategy
Domain Errors (in errors.go)
// Project errors
var (
ErrProjectNotFound = errors.New("project not found")
ErrDuplicateProject = errors.New("project with this name already exists")
ErrInvalidProjectName = errors.New("invalid project name")
)
// Task errors
var (
ErrTaskNotFound = errors.New("task not found")
ErrInvalidTaskTitle = errors.New("invalid task title")
ErrInvalidTaskStatus = errors.New("invalid task status")
ErrInvalidTaskPriority = errors.New("invalid task priority")
)
// Label errors
var (
ErrLabelNotFound = errors.New("label not found")
ErrDuplicateLabel = errors.New("label with this name already exists")
ErrInvalidLabelName = errors.New("invalid label name")
ErrInvalidLabelColor = errors.New("invalid label color")
)
// Association errors
var (
ErrDuplicateTaskLabel = errors.New("label already attached to task")
ErrTaskLabelNotFound = errors.New("task-label association not found")
ErrDuplicateAssignment = errors.New("user already assigned to task")
ErrAssignmentNotFound = errors.New("assignment not found")
)
Handler Error Mapping
Each handler file includes a mapDomainError function (following the Example pattern) that converts domain errors to HTTP errors:
| Domain Error | HTTP Status | Response |
|---|---|---|
ErrXxxNotFound |
404 | httperror.NotFound(msg) |
ErrDuplicateXxx |
409 | httperror.Conflict(msg) |
ErrInvalidXxx |
400 | httperror.BadRequest(msg) |
| Validation errors | 400 | Returned by app.BindAndValidate() |
| Unknown errors | 500 | Passed through to app.Wrap() |
PostgreSQL Error Handling in Adapters
Postgres adapters detect constraint violations to return appropriate domain errors:
func isDuplicateKeyError(err error) bool {
var pgErr *pq.Error
if errors.As(err, &pgErr) {
return pgErr.Code == "23505" // unique_violation
}
return false
}
func isForeignKeyError(err error) bool {
var pgErr *pq.Error
if errors.As(err, &pgErr) {
return pgErr.Code == "23503" // foreign_key_violation
}
return false
}
Security Considerations
Authentication
- Read endpoints (GET) are public (no auth required) — follows existing
Examplepattern - Write endpoints (POST, PUT, DELETE) require authentication via
auth.Middleware()— follows existing pattern - Auth middleware is opt-in, configured via
AUTH_ENABLEDenv var
Input Validation
All inputs are validated at two levels:
-
Handler layer:
app.BindAndValidate()with struct tags for format validation- String lengths (min/max)
- Required fields
- Enum values via
oneoftag - UUID format for path parameters
- Hex color format via
hex_colorcustom validator
-
Domain layer: Constructor validation for business rules
- Name length constraints
- Status/priority enum validation
- Color format validation (regex
^#[0-9A-Fa-f]{6}$)
Data Boundaries
user_idin assignments is an opaque string — no user entity lookup (per spec)- All IDs are server-generated UUIDs — clients cannot choose IDs
- SQL queries use parameterized statements (
$1,$2) — no SQL injection risk - JSON serialization uses struct tags — no field leakage
Cascade Deletes
- Cascades are handled by PostgreSQL FK constraints (
ON DELETE CASCADE) - Deleting a project cascades to its tasks, which cascades to task_labels and assignments
- Deleting a label cascades to task_labels
- No application-level cascade logic needed
Performance Considerations
Query Complexity
- All list queries are simple
SELECTwith optionalWHEREfilters — O(n) scans with index support - Task listing filters on indexed columns (status, priority, project_id)
- No joins required for primary CRUD operations
- Task-label and assignment lookups use indexed FK columns
Indexes
idx_tasks_project_id— fast task listing by projectidx_tasks_status/idx_tasks_priority— fast filtered queriesidx_task_labels_label_id— fast "which tasks have this label" lookupsidx_assignments_task_id/idx_assignments_user_id— fast assignment lookups- Unique constraints on
projects.nameandlabels.namedouble as unique indexes
Connection Pool
- Uses existing
pkg/databasepool with sensible defaults (25 max open, 5 idle) - No additional caching layer needed for v1 (spec explicitly excludes pagination, so data volumes are expected to be small)
- Connection pool is shared across all repositories
No Pagination (v1)
- Per spec, list endpoints return all matching records
- Acceptable for initial release; pagination can be added later as a separate feature
- OpenAPI spec should document that results are unbounded
Migration / Rollout Plan
Phase 1: Database Schema (migrations)
- Add migration files to
services/studio-api/migrations/ - Migrations run at startup via
database.MustRunMigrations() - Migrations are idempotent (tracked in
schema_migrationstable) - Tables are created in dependency order: projects → tasks → labels → task_labels → assignments
Phase 2: Backward-Compatible Wiring
main.goadds database connection alongside existing in-memory adapter- Existing
Exampleentity continues using in-memory adapter (unchanged) - New entities use PostgreSQL adapters
RegisterRoutessignature expands to accept new services- All new routes are additive — no existing routes change
Phase 3: Testing Strategy
- Domain tests: Pure unit tests with no dependencies (table-driven)
- Service tests: Mock repositories via interface (follows
example_test.gopattern) - Handler tests:
httptest+ chi router with mock services (followsexample_test.gopattern) - No integration tests against real PostgreSQL in v1 (adapters are thin SQL wrappers)
- All tests run with
cd services/studio-api && go test -v ./...
Rollout Checklist
- Migrations run successfully on fresh database
- All existing tests continue to pass
- New unit tests pass for all domain, service, and handler layers
- OpenAPI spec renders correctly at
/docs DATABASE_URLenvironment variable documented in.env.example
Key Design Decisions
-
Separate migration files per entity group — Keeps migrations readable and allows partial rollback reasoning. Projects first, tasks second, labels+associations third.
-
One service per entity + one AssociationService — Task-label and assignment operations are thin enough to share a service. Project, Task, and Label each get their own service for clarity.
-
Domain validation in constructors — Follows
Examplepattern. Constructors (NewProject,NewTask, etc.) validate inputs and return errors. Handlers also validate via struct tags for early feedback. -
PostgreSQL adapter per repository interface — Each port gets its own adapter file. Adapters use
sqlxdirectly (no ORM). Compile-time interface checks viavar _ port.XxxRepository = (*XxxRepository)(nil). -
Cascade deletes via FK constraints — Simpler and more reliable than application-level cascade logic. PostgreSQL handles all cascading automatically.
-
Global labels (not project-scoped) — Per spec. Labels are shared across all projects. Task-label join table connects them.
-
user_idas opaque string — No FK to a users table. Supports any identity format (UUID, email, external ID). Per spec decision. -
Status defaults to
open, priority tomedium— Sensible defaults in both SQL schema and domain constructors. Clients can omit these fields on creation.