foundary-test-1770773605/.sdlc/features/data-models/design.md
rdev-worker 7c195fe034
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /design-feature data-models
2026-02-11 01:59:08 +00:00

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, ErrAssignmentNotFound
  • ErrDuplicateProjectName, ErrDuplicateLabelName, ErrDuplicateAssignment
  • ErrInvalidStatus, ErrInvalidPriority
  • ErrProjectHasTasks (reserved for future restrict-delete if needed)

Adapter Layer (Postgres)

Each Postgres adapter maps database errors to domain errors:

  • sql.ErrNoRowsdomain.ErrXxxNotFound
  • Unique constraint violation (Postgres error code 23505) → domain.ErrDuplicateXxx
    • Detected via *pq.Error type assertion with Code == "23505"
    • The specific constraint name determines which duplicate error to return
  • 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 ExistsByName before insert (avoids race to friendly error)
  • Create task: Verify project exists via ProjectRepository.Get
  • Create label: Verify project exists, check ExistsByName scoped 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.MustConnect panics on startup (fail-fast)
  • Migration failures: database.MustRunMigrations panics 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 by app.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
  • assignee is an opaque string — no user lookup or validation against external systems

Cascading Deletes

  • ON DELETE CASCADE on 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_id and task_id FKs ensure efficient lookups
  • Index on tasks.status supports future filtering

Connection Pool

  • Uses pkg/database pool defaults: 25 max open, 5 max idle, 5min lifetime
  • Configurable via DATABASE_MAX_OPEN_CONNS, DATABASE_MAX_IDLE_CONNS env 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_URL is 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_migrations table)
  • 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 Example entity and endpoints remain unchanged
  • New endpoints are additive — no existing API contracts are modified
  • The in-memory Example adapter 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

  1. 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."

  2. Separate Assignment entity (not a field on Task): Supports multiple assignees per task. The (task_id, assignee) unique constraint prevents duplicates while allowing flexibility.

  3. Position field included: Included per spec. Default 0, managed as a simple integer. No automatic reordering — the client is responsible for setting position values.

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

  5. DATABASE_URL configuration: Uses the existing config.ReadDatabaseConfig() + env var pattern. No new database name concept — the database is whatever DATABASE_URL points to.

  6. Postgres adapters take *sqlx.DB: Following the established pattern from pkg/queue/postgres.go. The adapter receives the raw *sqlx.DB (from pool.DB), not the *database.Pool wrapper.

  7. Internal row structs in adapters: Each Postgres adapter defines a private xxxRow struct with db:"column_name" tags, and a toDomain() converter. This decouples the DB schema from the domain model.

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

  9. TaskService depends on ProjectRepository: To verify the parent project exists when creating a task. Same pattern for LabelService. AssignmentService depends on TaskRepository.

  10. No soft deletes: Hard deletes only, per spec. deleted_at columns are not included.