foundary-test-1770784989/.sdlc/features/data-models/design.md
rdev-worker e0b6834743
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
build: /design-feature data-models
2026-02-11 05:04:28 +00:00

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.go wired to connect to PostgreSQL, run migrations, and inject postgres adapters

What's modified:

  • main.go — adds database connection, migration runner, new service/handler wiring
  • internal/api/routes.go — adds new route registrations
  • internal/api/spec.go — adds OpenAPI schemas and endpoint documentation
  • internal/domain/errors.go — adds domain errors for new entities
  • internal/config/config.go — already has Database config (no changes needed)

What's preserved:

  • The existing Example entity 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 Example pattern
  • Write endpoints (POST, PUT, DELETE) require authentication via auth.Middleware() — follows existing pattern
  • Auth middleware is opt-in, configured via AUTH_ENABLED env var

Input Validation

All inputs are validated at two levels:

  1. Handler layer: app.BindAndValidate() with struct tags for format validation

    • String lengths (min/max)
    • Required fields
    • Enum values via oneof tag
    • UUID format for path parameters
    • Hex color format via hex_color custom validator
  2. 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_id in 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 SELECT with optional WHERE filters — 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 project
  • idx_tasks_status / idx_tasks_priority — fast filtered queries
  • idx_task_labels_label_id — fast "which tasks have this label" lookups
  • idx_assignments_task_id / idx_assignments_user_id — fast assignment lookups
  • Unique constraints on projects.name and labels.name double as unique indexes

Connection Pool

  • Uses existing pkg/database pool 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)

  1. Add migration files to services/studio-api/migrations/
  2. Migrations run at startup via database.MustRunMigrations()
  3. Migrations are idempotent (tracked in schema_migrations table)
  4. Tables are created in dependency order: projects → tasks → labels → task_labels → assignments

Phase 2: Backward-Compatible Wiring

  1. main.go adds database connection alongside existing in-memory adapter
  2. Existing Example entity continues using in-memory adapter (unchanged)
  3. New entities use PostgreSQL adapters
  4. RegisterRoutes signature expands to accept new services
  5. All new routes are additive — no existing routes change

Phase 3: Testing Strategy

  1. Domain tests: Pure unit tests with no dependencies (table-driven)
  2. Service tests: Mock repositories via interface (follows example_test.go pattern)
  3. Handler tests: httptest + chi router with mock services (follows example_test.go pattern)
  4. No integration tests against real PostgreSQL in v1 (adapters are thin SQL wrappers)
  5. 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_URL environment variable documented in .env.example

Key Design Decisions

  1. Separate migration files per entity group — Keeps migrations readable and allows partial rollback reasoning. Projects first, tasks second, labels+associations third.

  2. 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.

  3. Domain validation in constructors — Follows Example pattern. Constructors (NewProject, NewTask, etc.) validate inputs and return errors. Handlers also validate via struct tags for early feedback.

  4. PostgreSQL adapter per repository interface — Each port gets its own adapter file. Adapters use sqlx directly (no ORM). Compile-time interface checks via var _ port.XxxRepository = (*XxxRepository)(nil).

  5. Cascade deletes via FK constraints — Simpler and more reliable than application-level cascade logic. PostgreSQL handles all cascading automatically.

  6. Global labels (not project-scoped) — Per spec. Labels are shared across all projects. Task-label join table connects them.

  7. user_id as opaque string — No FK to a users table. Supports any identity format (UUID, email, external ID). Per spec decision.

  8. Status defaults to open, priority to medium — Sensible defaults in both SQL schema and domain constructors. Clients can omit these fields on creation.