A small HTTP service that owns the canonical list of internal team tasks: create, list (with filter/sort/paginate), fetch, update, and delete. Phase 1 ships as a single uvicorn worker with in-memory SQLite, structured logging, and a swappable repository contract so Phase 2 can move to Postgres without touching the domain or application layers.
The locked design lives in docs/PRD.md (product), docs/FRD.md (functional), and docs/TIS.md (technical).
A small distributed team kept losing track of agreed action items in chat threads and personal to-do lists. There was no single source of truth for "what we said we'd do," which caused duplicates, naming confusion in stand-ups, and no way to filter or prioritize work across the team. The PO asked for "a simple task service. Something internal. Clean. We'll build more on top of it later." This repo is that first iteration.
All endpoints are mounted under /v1. Open the interactive docs at http://localhost:8000/docs for the live schema.
| Method | Path | Purpose | Success | Error envelope codes |
|---|---|---|---|---|
POST |
/v1/tasks |
Create a task | 201 Created |
409 duplicate_task, 422 validation_error / read_only_field |
GET |
/v1/tasks |
List (filter / sort / paginate) | 200 OK |
422 validation_error |
GET |
/v1/tasks/{id} |
Fetch one task | 200 OK |
404 task_not_found |
PUT |
/v1/tasks/{id} |
Replace all mutable fields | 200 OK |
404, 409, 422 |
PATCH |
/v1/tasks/{id} |
Update any subset of fields | 200 OK |
404, 409, 422 (incl. empty_update) |
DELETE |
/v1/tasks/{id} |
Delete a task | 204 No Content |
404 |
GET |
/healthz |
Liveness — synchronous, no I/O | 200 OK |
— |
GET |
/readyz |
Readiness — DB round-trip | 200 OK or 503 |
— |
Every non-2xx response uses the same envelope: {"error": {"code", "message", "details", "request_id"}}. The code is machine-readable so consumers can branch without parsing English.
# Create
curl -X POST http://localhost:8000/v1/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "ship task service", "priority": 4}'
# → 201 {"id":1,"title":"ship task service","status":"new","priority":4,...}
# Move to in-progress
curl -X PATCH http://localhost:8000/v1/tasks/1 \
-H 'Content-Type: application/json' \
-d '{"status": "in_progress"}'
# List open work, highest priority first
curl 'http://localhost:8000/v1/tasks?status=new&order_by=priority&order_dir=desc&limit=20'
# Duplicate title — 409 with structured error
curl -X POST http://localhost:8000/v1/tasks \
-H 'Content-Type: application/json' \
-d '{"title": "Ship Task Service", "priority": 1}'
# → 409 {"error":{"code":"duplicate_task","message":"...","details":{"title":"Ship Task Service"},"request_id":"..."}}The service is built around four design choices, each tied to an objective from PRD §3.
Feature-first hexagonal layout. Each feature under app/services/<feature>/ owns its full vertical slice — domain/, application/, infrastructure/, api/ — and exposes a single interfaces.py ABC for the storage port. This keeps related code together (you change one feature in one folder) while preserving the dependency rule: api → application → domain, and infrastructure implements interfaces.py. The textbook Cosmic-Python ceremony (separate ports/, Protocol typing, distinct domain entity apart from the ORM row) is deliberately not used in Phase 1 — see TIS §1 (architecture overview) and §4.1 (ORM-as-domain Decision callout).
Single domain+ORM entity. The Task SQLModel row is the domain entity (table=True). Phase 1 does not split domain and ORM into two classes; the duplication wasn't paying for itself at this scale. If/when a richer domain model arrives (state machines, invariants the ORM can't express), the split happens then.
Five-event domain bus. The application service publishes TaskCreated, TaskUpdated, TaskStatusChanged, TaskCompleted, TaskDeleted after each mutation via EventBus.publish(event, background_tasks). Listeners run through FastAPI BackgroundTasks so they execute post-commit, pre-response without blocking the HTTP call. Phase 1 ships one listener (structured-log subscriber); the bus is the seam where notifications / outbox / Kafka would land in Phase 2.
Swappable repository. TaskRepositoryInterface is an ABC, not a Protocol — any implementation that forgets a method fails at instantiation time with a clear TypeError. Contract tests (tests/contract/) are parametrised over every concrete repository, so adding a Postgres adapter in Phase 2 requires zero new test code.
Other rationale, briefly:
title_key = title.strip().casefold()is the canonical uniqueness column; originaltitleis preserved verbatim for display. Duplicate detection goes throughtitle_key, nevertitle. This is what makes "Fix bug" and " fix BUG" the same task.- Single global error handler converts every
AppErrorsubclass and every PydanticRequestValidationErrorinto the same envelope. Domain code never builds HTTP responses —raise DuplicateTaskError(details={"title": …})is enough. Indevmode only, raisers may passoriginal_error=and the envelope'sdetails.causewill surface the underlying exception (gated bysettings.expose_stack_traces); other envs strip it. - All timestamps UTC, always.
datetime.now(UTC)everywhere; the Docker image setsTZ=UTC. Naïve datetimes are a bug, surfaced by mypy and theensure_utcboundary helper. - Request-ID middleware generates a UUIDv4 when
X-Request-IDis absent, binds it to the structlog context, and echoes it on the response. Every log line in a request carries the same id.
The codebase is organised feature-first: each feature under app/services/<feature>/ is a self-contained vertical slice with its own domain/, application/, infrastructure/, and api/ layers, plus an interfaces.py ABC for the storage port and an errors.py for feature-typed exceptions. Cross-cutting concerns live in app/core/. Tests are split by what they need (unit lives next to the feature; everything else lives under the top-level tests/).
app/
├── __init__.py # __version__ (single source of truth; hatchling reads here)
├── main.py # FastAPI app factory + lifespan + middleware wiring
├── core/ # Cross-cutting infrastructure — no feature imports here
│ ├── config.py # pydantic-settings + APP_ENV behavior matrix
│ ├── constants.py # Environment / OrderDirection enums + INT64_MAX, list-limit bounds
│ ├── database.py # SQLAlchemy engine + StaticPool wiring + init_schema
│ ├── datetime_utils.py # ensure_utc helper (boundary normaliser)
│ ├── dependencies.py # Cross-cutting DI: get_session, get_event_bus
│ ├── errors.py # ErrorCode enum, AppError hierarchy, global handlers
│ ├── event_bus.py # In-process EventBus (publish via BackgroundTasks)
│ ├── health.py # /healthz and /readyz handlers
│ ├── logging.py # structlog configuration
│ ├── middleware.py # Request-ID middleware
│ └── openapi_responses.py # Shared 404 / 409 / 422 response specs for the router
└── services/
└── tasks/ # Feature-first vertical slice — full domain/app/infra/api
├── domain/ # Task SQLModel (table=True) + 5 domain events + MUTABLE_FIELDS
├── application/ # TaskService (use-case orchestration) + DTOs
├── infrastructure/ # SQLModelTaskRepository + event listeners
├── api/v1/ # FastAPI router (mounted under /v1/tasks)
├── interfaces.py # TaskRepositoryInterface ABC
├── constants.py # Status / TaskSortField StrEnums + field bounds
├── dependencies.py # Feature DI providers (repository, service, query params)
├── errors.py # DuplicateTaskError, TaskNotFoundError, EmptyUpdateError
├── MODULE.md # Feature-internal doc: invariants, error-table, conventions
└── tests/ # Feature-local unit tests (no FastAPI, no DB)
tests/
├── conftest.py # Test fixtures (in-process app, lifespan, fresh DB)
├── integration/ # httpx.AsyncClient against in-process FastAPI app
├── contract/ # Parametrised over every TaskRepositoryInterface impl
├── e2e/ # Schemathesis property tests (pytest marker: ``e2e``)
└── hurl/ # 12 black-box scenarios against the running container
docker/ # Multi-stage Dockerfile + docker-compose.yaml
docs/ # PRD (product), FRD (functional), TIS (technical)
.github/workflows/ # CI: pre-commit → mypy → pytest → Hurl
reports/hurl/ # Generated HTML + JSON reports (gitignored except .gitkeep)
Enforced by review (not tooling) — see docs/TIS.md §1 for the full contract (and §3.1 for the layer-responsibility table):
domain/**may import stdlib,pydantic,sqlmodel,app/core/*. Nofastapi.application/**may importdomain/andinterfaces.py. Noinfrastructure/, nofastapi.infrastructure/**may import everything in the feature plus DB helpers fromapp/core/.api/**is the only feature-internal place that touchesfastapi.app/core/**must not import from any individual service.
The dependency arrow always points inward: api → application → domain ← infrastructure (implements interfaces.py). Swapping the repository for Postgres in Phase 2 means editing infrastructure/ + interfaces.py only — domain/, application/, and api/ stay untouched.
Carried forward from PRD §10 — these define the operating envelope for Phase 1:
- Callers are trusted internal services or developers. No rate limiting, authentication, captcha, or abuse protection — that arrives in Phase 2 when the service moves beyond the LAN.
- Task data is not sensitive and data loss on restart is acceptable. Phase 1 uses in-memory SQLite (
sqlite+pysqlite:///:memory:); tasks disappear when the process or container restarts. Intentional. - Single instance, single uvicorn worker. The repository is synchronous; concurrency beyond one worker is out of scope. (Hurl E2E tests must run with
--jobs 1for the same reason —StaticPoolserialises on a single shared connection.) - The team is comfortable with Python 3.13, FastAPI, and
uv. No alternative package manager or runtime is supported.
- Python 3.13+
uv(the only supported package manager — do not runpipdirectly)- Docker + Docker Compose (only required for the container path and Hurl E2E)
- Hurl 4+ (only required for
make hurl-e2e; CI pins 8.0.1 — see.github/workflows/ci.yaml)
make install # uv sync --all-groups + pre-commit hooks
cp .env.example .env # one-time: seed the base config filemake install pins to uv.lock and wires the git pre-commit hooks (ruff, ruff-format, bandit, file hygiene, uv lock --check).
cp .env.example .env is the manual step that gives the app its working defaults. .env.example is the only .env* file checked into git; copying it to .env is what pydantic-settings reads on startup. (Per-environment override files like .env.qa are optional and explained in § Configuration.)
make run # uvicorn --reload on :8000 (override with APP_PORT=9000 make run)Open http://localhost:8000/docs for the OpenAPI UI.
make compose-up # build image, start container, wait for healthcheck
make compose-logs # tail logs
make compose-down # tear downA fresh checkout to a running service is one command — make compose-up or make run — with no further setup beyond uv and Docker being installed.
Tests live at four layers, each chosen to give a different kind of confidence:
| Layer | Location | What it proves | When it runs |
|---|---|---|---|
| Unit | app/services/<feature>/tests/ |
Single classes/functions in isolation; no FastAPI, no DB | Every make test |
| Integration | tests/integration/ |
The HTTP boundary against httpx.AsyncClient + ASGI |
Every make test |
| Contract | tests/contract/ |
Every TaskRepositoryInterface impl satisfies the ABC |
Every make test |
| E2E (Hurl) | tests/hurl/ |
Black-box HTTP flows against the running container | make hurl-e2e / CI |
| Property-based | tests/e2e/test_schemathesis.py |
Schemathesis fuzzes every documented operation | make schemathesis |
The split rule for unit vs. integration: can this test run with only my feature module imported? Yes → unit test, lives in app/services/<feature>/tests/. No (needs the full FastAPI app, real HTTP, or another feature) → cross-boundary, lives in tests/.
make all # lint + typecheck + full pytest suite (~80 tests, 95% coverage; gate at 80%)
make test # full pytest with coverage gate
make test-unit # feature-local unit tests only — fast, no FastAPI/DB
make test-integration # in-process FastAPI + SQLite :memory:
make test-contract # repository ABC conformance — parametrised over every impl
make hurl-e2e # 13-scenario black-box Hurl suite against the docker-compose container
make schemathesis # Schemathesis property tests via pytest (ASGI in-process, no container)Run a single pytest:
uv run pytest -k test_name
uv run pytest tests/integration/services/tasks/test_create_task.py::test_create_201What it is. Hurl is a small CLI that runs plain-text .hurl files of HTTP requests with first-class support for variable captures, JSONPath assertions, and stateful multi-step flows. We use it as the highest-level (black-box) test layer: scenarios talk to the running container, not the in-process FastAPI app, so the Docker image, lifespan, healthcheck, and middleware stack are all exercised end-to-end.
Why Hurl on top of pytest. Integration tests (pytest + httpx.AsyncClient) cover correctness inside the Python process; they bypass the Docker image, the uvicorn process model, and any container-side glue. Hurl runs the same image that ships to production. The two layers catch different things — pytest catches logic bugs, Hurl catches packaging / config / runtime bugs.
Run the full suite (recommended path). Brings the container up, runs every tests/hurl/*.hurl file sequentially, writes HTML + JSON reports, then tears the container down — even on failure (trap on EXIT):
make hurl-e2e
# → reports/hurl/index.html (per-request clickable view)
# → reports/hurl/report.json (machine-readable)--jobs 1 is hard-coded in the target because in-memory SQLite + StaticPool serialises on a single shared connection — parallel scenarios would race on the same in-memory DB. When Phase 2 swaps to Postgres, this constraint lifts.
Run a single scenario standalone. Useful while authoring a new scenario or debugging an assertion failure. Bring the container up yourself, then point Hurl at one file:
make compose-up # 1. start the container (healthcheck-gated)
hurl --test --verbose \
--variable base_url=http://localhost:8000 \
tests/hurl/task_full_flow.hurl # 2. run just this scenario
make compose-down # 3. tear down when doneUse --very-verbose for full request/response bodies — invaluable when an [Asserts] line fails and you need to see what the server actually sent back.
The 13 scenarios in tests/hurl/.
| Scenario | What it pins down |
|---|---|
aaa_sort_priority.hurl |
Runs first (empty DB); priority sort default + ASC/DESC + tie-break by created_at + invalid order_by / order_dir rejections |
healthz.hurl |
GET /healthz returns 200 (no I/O — process is alive) |
readyz.hurl |
GET /readyz does a real DB round-trip and returns 200 |
request_id_propagation.hurl |
X-Request-ID echoed when caller sends one; generated when absent |
task_create.hurl |
Happy-path POST |
task_create_validation_errors.hurl |
Each invalid-input shape → 422 with the right code |
task_create_duplicate_title.hurl |
Case-insensitive + trimmed duplicate detection on CREATE |
task_not_found.hurl |
404 envelope shape on GET / PATCH / PUT / DELETE of a missing id |
task_patch_partial.hurl |
PATCH partial-update semantics; empty_update on {} |
task_put_full_replace.hurl |
PUT full-replace semantics |
task_lifecycle.hurl |
One task: new → in_progress → completed → delete → 404 |
task_list_filter_sort.hurl |
Sorting, status filter, pagination, limit-validation |
task_full_flow.hurl |
Multi-task narrative: 4 creates, dup-rejection on create + rename, PATCH/PUT/DELETE, multi-value status filter, error envelopes mid-flow, cleanup |
Authoring conventions for new .hurl files (so they coexist under --jobs 1 against the shared in-memory SQLite):
- Use a unique title prefix (e.g.
"hurl flow alpha","hurl list L1") so other scenarios' rows can't collide with yours. - Assert presence with
jsonpath "$.items[?(@.title=='…')]" exists/not existsrather than$.total == N— other scenarios' rows pad the total count unpredictably. - Capture ids once with
[Captures] task_id: jsonpath "$.id", then reuse{{task_id}}across follow-up requests in the same file. - Use
{{base_url}}for the host so the same file works against a local container and against any future remote env. - If a scenario creates rows it doesn't need to leave behind,
DELETEthem at the end as hygiene.
pydantic-settings resolves settings from three sources, in increasing precedence:
.env— base values, copied from.env.exampleduring setup..env.<APP_ENV>— optional overrides for the active environment (e.g..env.qa). Layered on top of.env, not instead of it.- Process environment variables — always win over both files. This is what k8s
env:blocks and CI manifests use.
APP_ENV ∈ {dev, test, qa, prod} and defaults to dev.
APP_ENV also drives three runtime knobs via app/core/config.py (not just file selection):
APP_ENV |
Default LOG_LEVEL |
JSON logs | Stack traces in error responses |
|---|---|---|---|
dev |
DEBUG |
no (console renderer) | yes |
test |
WARNING |
no | no |
qa |
INFO |
yes | no |
prod |
INFO |
yes | no |
In dev, the error envelope's details.cause carries the underlying exception ("<ExceptionType>: <message>") whenever the raiser passes original_error=. Other envs strip it. This is the runtime form of "stack traces in error responses" for Phase 1; full Python tracebacks are not exposed.
# Switch the active per-env file. .env still loads as the base layer.
APP_ENV=test uv run pytest
APP_ENV=qa make run
# One-off override of a single setting (process env var beats both files).
APP_ENV=qa LOG_LEVEL=DEBUG uv run uvicorn app.main:app.env.example is the only .env* file tracked in git. .env and any .env.<APP_ENV> are gitignored and .dockerignored — secrets stay on the developer machine / in the orchestrator.
- In-memory data loss on restart — the most visible Phase 1 limitation. Acceptable for the internal MVP; Phase 2 swaps in Postgres.
- No auth / authz / rate limits — the service assumes trusted callers on a private network.
- Single worker — multi-worker deployment is a Phase 2 concern.
Phase 2 (planned): Postgres + async repository, authentication, multi-worker, optional event sinks (outbox / Kafka), and promoting make schemathesis from a standalone target into the default CI gate. The application and domain layers stay untouched; only infrastructure/ and interfaces.py widen.
uv manages everything. make help lists every target. Common direct invocations:
uv run uvicorn app.main:app --reload # dev server
uv run pytest -k some_test # single test
uv run ruff check . && uv run mypy # lint + typecheckPre-commit hooks wire automatically on make install (ruff, ruff-format, bandit, file hygiene, uv lock --check).