11 KiB
Feature: Core Data Models & Persistence
Problem Statement
The studio application currently uses an in-memory storage adapter with a single example entity. To build real product functionality, the system needs persistent domain entities — Projects, Tasks, Labels, and Assignments — backed by PostgreSQL. Without these core data models, no meaningful product workflows (task management, project organization, label categorization, team assignment) can be built.
User Stories
- As a developer, I want Project, Task, Label, and Assignment entities with full CRUD so that the studio application has a foundation for product features.
- As a developer, I want PostgreSQL-backed persistence via
studio-dbso that data survives service restarts. - As a developer, I want a repository layer with clean interfaces so that I can swap storage implementations and write testable code.
- As a developer, I want service-layer business logic so that domain rules (validation, uniqueness, referential integrity) are enforced consistently.
- As a developer, I want REST endpoints on
studio-apiso that frontends and external clients can manage these entities. - As a developer, I want database migrations so that schema changes are versioned, repeatable, and safe to apply.
- As a developer, I want handler tests so that API behavior is verified and regressions are caught.
Domain Model
Project
| Field | Type | Constraints |
|---|---|---|
id |
UUID | PK, generated |
name |
string | required, 1–200 chars, unique |
description |
string | optional, max 1000 chars |
status |
enum | active, archived; default active |
created_at |
timestamp | UTC, set on create |
updated_at |
timestamp | UTC, set on create/update |
Task
| Field | Type | Constraints |
|---|---|---|
id |
UUID | PK, generated |
project_id |
UUID | FK → projects, required |
title |
string | required, 1–300 chars |
description |
string | optional, max 5000 chars |
status |
enum | todo, in_progress, done; default todo |
priority |
enum | low, medium, high; default medium |
position |
integer | ordering within project, default 0 |
created_at |
timestamp | UTC |
updated_at |
timestamp | UTC |
Label
| Field | Type | Constraints |
|---|---|---|
id |
UUID | PK, generated |
project_id |
UUID | FK → projects, required |
name |
string | required, 1–50 chars, unique per project |
color |
string | required, hex color (e.g., #FF5733) |
created_at |
timestamp | UTC |
updated_at |
timestamp | UTC |
Assignment (join entity: Task ↔ User)
| Field | Type | Constraints |
|---|---|---|
id |
UUID | PK, generated |
task_id |
UUID | FK → tasks, required |
assignee |
string | required, 1–200 chars (user identifier) |
created_at |
timestamp | UTC |
Note: assignee is a string identifier (email or user ID) rather than a FK to a users table, since user management is out of scope for this feature. A unique constraint on (task_id, assignee) prevents duplicate assignments.
Acceptance Criteria
Migrations
- Migration
001_create_projects.sqlcreates theprojectstable with all fields, constraints, and indexes - Migration
002_create_tasks.sqlcreates thetaskstable with FK to projects, indexes onproject_idandstatus - Migration
003_create_labels.sqlcreates thelabelstable with FK to projects, unique constraint on(project_id, name) - Migration
004_create_assignments.sqlcreates theassignmentstable with FK to tasks, unique constraint on(task_id, assignee) - Migrations run via the existing
database.MustRunMigrations()system with embedded SQL files - All migrations are idempotent and run in transactions
Domain Layer
- Domain entities
Project,Task,Label,Assignmentdefined with strongly-typed IDs (e.g.,ProjectID,TaskID) - Domain constructors (
NewProject,NewTask,NewLabel,NewAssignment) validate all required fields - Domain errors defined:
ErrProjectNotFound,ErrTaskNotFound,ErrLabelNotFound,ErrAssignmentNotFound,ErrDuplicateProjectName,ErrDuplicateLabelName,ErrDuplicateAssignment,ErrInvalidStatus,ErrInvalidPriority - Update methods validate mutable fields and set
updated_at
Repository Layer (Ports)
ProjectRepositoryinterface withList,Get,Create,Update,Delete,ExistsByNameTaskRepositoryinterface withListByProject,Get,Create,Update,DeleteLabelRepositoryinterface withListByProject,Get,Create,Update,Delete,ExistsByNameAssignmentRepositoryinterface withListByTask,Get,Create,Delete,ExistsByTaskAndAssignee
Adapter Layer (Postgres)
adapter/postgres/package implementing all repository interfaces usingsqlx- Proper SQL parameterization (
$1,$2, etc.) — no string concatenation sql.ErrNoRowsmapped to domainErrNotFounderrors- Unique constraint violations mapped to domain duplicate errors
Service Layer
ProjectServicewith Create (duplicate name check), Get, List, Update, Delete (cascade consideration)TaskServicewith Create (validate project exists), Get, ListByProject, Update, DeleteLabelServicewith Create (validate project exists, duplicate name per project), Get, ListByProject, Update, DeleteAssignmentServicewith Create (validate task exists, duplicate check), ListByTask, Delete
Handler Layer (REST API)
- Projects:
POST /api/studio-api/projects,GET /api/studio-api/projects,GET /api/studio-api/projects/{id},PUT /api/studio-api/projects/{id},DELETE /api/studio-api/projects/{id} - Tasks:
POST /api/studio-api/projects/{projectId}/tasks,GET /api/studio-api/projects/{projectId}/tasks,GET /api/studio-api/tasks/{id},PUT /api/studio-api/tasks/{id},DELETE /api/studio-api/tasks/{id} - Labels:
POST /api/studio-api/projects/{projectId}/labels,GET /api/studio-api/projects/{projectId}/labels,GET /api/studio-api/labels/{id},PUT /api/studio-api/labels/{id},DELETE /api/studio-api/labels/{id} - Assignments:
POST /api/studio-api/tasks/{taskId}/assignments,GET /api/studio-api/tasks/{taskId}/assignments,DELETE /api/studio-api/assignments/{id} - All handlers follow
app.Wrap()+app.BindAndValidate()pattern - All responses use
httpresponse.OK,httpresponse.Created,httpresponse.NoContentenvelope - URL parameters use
{param}brace syntax, extracted withchi.URLParam() - Domain errors mapped to HTTP errors via
mapDomainError()functions
OpenAPI Specification
- All schemas defined in
spec.gousingopenapi.*helpers - All endpoints documented with request/response types, status codes, and tags
- Tags:
Projects,Tasks,Labels,Assignments
Tests
- Handler tests for all CRUD operations on each entity using mock repositories
- Table-driven tests covering: success, not found, validation failure, duplicate/conflict
- Test setup uses
newTestHandler()pattern with mock repositories - Tests use
chi.NewRouter()+httptestfor HTTP testing
Wiring
main.goupdated to: connect to database, run migrations, create postgres adapters, inject into services and handlers- Database connection uses
config.ReadDatabaseConfig()+database.MustConnect() - Migrations embedded with
//go:embed migrations/*.sql - Graceful shutdown closes database pool
Technical Constraints
- Database: PostgreSQL via
sqlx+lib/pqdriver (already inpkg/database) - Migration system: Embedded SQL files using
database.MustRunMigrations()— no external migration tool - Architecture: Hexagonal architecture — domain has no external dependencies; ports define interfaces; adapters implement them
- Routing: chi router with
{param}brace syntax; all handlers returnerrorwrapped withapp.Wrap() - Validation: Struct tags using
go-playground/validatorviaapp.BindAndValidate() - ID generation: UUIDs generated server-side (e.g.,
uuid.New().String()) - Timestamps: All UTC, set by domain constructors
- Cascading deletes: Deleting a project should delete its tasks, labels, and associated assignments (via
ON DELETE CASCADEin FK constraints)
Dependencies
pkg/database— PostgreSQL connection pool and migration runner (exists)pkg/config— Database URL and pool configuration (exists)pkg/app— Handler wrapping, binding, routing (exists)pkg/httperror,pkg/httpresponse,pkg/httpvalidation— HTTP layer helpers (exists)pkg/openapi— API documentation (exists)- Running PostgreSQL instance for local development and integration tests
github.com/google/uuid— UUID generation (add to go.mod if not present)
Out of Scope
- User management / authentication:
assigneeis a plain string, not a FK to a users table. Auth middleware integration is not part of this feature. - Pagination: List endpoints return all records. Pagination will be a follow-up feature.
- Filtering/sorting: List endpoints are unfiltered. Query parameters for filtering by status, priority, etc. will be a follow-up.
- Task-Label association: Many-to-many relationship between tasks and labels (a
task_labelsjoin table) is not included. This is a follow-up feature. - Soft deletes: All deletes are hard deletes. Soft delete (archive) behavior may be added later.
- Audit logging: No change tracking or audit trail in this feature.
- Frontend integration: No UI changes. This is backend-only.
Open Questions
-
Delete cascade vs. restrict: Should deleting a project cascade-delete all tasks, labels, and assignments? The spec assumes
ON DELETE CASCADE, but an alternative is to return an error if a project has tasks (forcing explicit cleanup). Which behavior is preferred? -
Assignment entity vs. simple field: Should task assignment be a separate entity with its own table (supporting multiple assignees per task), or a simple
assigneestring field on the Task table (single assignee)? The spec assumes a separate join entity for multiple assignees. -
Task position/ordering: The spec includes a
positioninteger for ordering tasks within a project. Should this be included in the initial implementation, or deferred until a drag-and-drop UI feature is built? -
Project status transitions: Should there be business rules governing status transitions (e.g., can only archive a project if all tasks are
done)? The spec currently allows free status changes. -
Database name: The requirements mention
studio-dbas the database. Should this be a new PostgreSQL database name, or should it refer to the database configured inDATABASE_URL? The spec assumes the existingDATABASE_URLconfiguration.