Blog Houssan Ali Hijazi
IA · Python · Android
← Voltar ao blog
Python, FastAPI, Docker, IA

SDD na prática: construí uma API de tarefas escrevendo SPEC.md e TASKS.md

SDD na prática: construí uma API de tarefas escrevendo SPEC.md e TASKS.md

Spec-Driven Development com um agente de IA: escrevi SPEC.md e TASKS.md, e fui pedindo "execute a etapa N" até uma API REST completa, com Docker, Postgres, testes e documentação.

🎯 O experimento

Queria testar até onde dá pra levar SDD (Spec-Driven Development) trabalhando com um agente de codificação. A ideia é simples: em vez de ficar pedindo "faz isso, agora faz aquilo" numa conversa solta, você escreve dois documentos primeiro:

  • SPEC.md: o que o sistema precisa fazer, contrato da API, casos de borda, restrições técnicas.
  • TASKS.md: o plano de implementação quebrado em etapas e tarefas, cada uma com checkbox.

E depois só vai pedindo "execute a Etapa N do TASKS.md", conferindo o resultado, ajustando a spec quando um requisito novo aparece.

O projeto escolhido: uma API REST de To-Do (MyTodo SDD) em Python, FastAPI, Postgres, tudo em Docker.

Existem frameworks prontos pra SDD, como o GitHub Spec Kit, com comandos próprios (/specify, /plan, /tasks, /implement) e arquivos de convenção como constitution.md. Deliberadamente não usei nenhum aqui: o objetivo não era testar uma ferramenta, era entender o processo na mão, escrevendo os dois arquivos do jeito mais simples possível e vendo onde ele quebra ou funciona sem a estrutura de um framework em volta.

🔄 As quatro fases do SDD (SPTI)

O fluxo formalizado pelo GitHub Spec Kit (a referência mais usada de Spec-Driven Development hoje) segue quatro fases sequenciais: Specify → Plan → Tasks → Implement. No projeto elas apareceram assim:

Specify. Escrever o SPEC.md: o que o sistema faz, não como. Objetivo, público-alvo, requisitos funcionais e não-funcionais, contrato dos endpoints, casos de borda. Nessa fase ainda não existe uma linha de código, só decisão de escopo. A spec não nasceu pronta: fui voltando nela pelo editor pra adicionar requisito novo (documentação, depois testes) conforme o projeto amadurecia.

Plan. Definir a abordagem técnica antes de quebrar em tarefas: Python + FastAPI + Postgres em Docker, SQLModel pra modelagem, pytest pra testes. No projeto o plano ficou implícito nas próprias restrições técnicas do SPEC.md, sem um plan.md separado.

Tasks. Quebrar o plano em tarefas executáveis e sequenciadas: o TASKS.md. Cada requisito da spec virou uma ou mais tarefas, agrupadas em etapas (Infraestrutura, Banco de Dados, Endpoints, Erros, Testes, Validação, Documentação), cada uma com checkbox.

Implement. A execução em si, tarefa por tarefa. Pedia "execute a Etapa X do TASKS.md" e o agente implementava só o escopo daquela etapa, validando rodando de verdade (build do Docker, curl nos endpoints, pytest passando) antes de marcar a tarefa como concluída. Validação incremental faz parte do Implement, não é uma fase à parte: é o que separa SDD de simplesmente "gerar código".

Na prática o ciclo não foi totalmente linear: cada nova entrada na spec (Specify) exigia revisitar o plano e gerava tarefas novas (Tasks), que rodavam Implement antes de eu voltar pra spec de novo. Specify, Plan e Tasks se repetiram várias vezes ao longo do projeto, não só no início.

SpecifySPEC.mdo que o sistema fazPlanabordagem técnicastack, ferramentasTasksTASKS.mdetapas com checkboxImplementexecuta etapa a etapavalida antes de marcar ✓"execute a Etapa X"requisito novo aparecevalida e libera próxima etapafluxo dentro do ciclo atualvolta pro Specify
Specify, Plan e Tasks não rodaram uma vez só neste projeto: Implement valida cada etapa antes de liberar a próxima (seta cinza), e quando surgia um requisito novo, o ciclo reabria a partir do Specify em vez de seguir direto pra implementação (seta laranja). É o próprio princípio do SDD ("manutenção de software é evoluir a especificação") aplicado na mão: o Spec Kit automatiza esse retorno com uma fase própria de Converge, mas sem framework o caminho é regenerar spec, plano e tarefas manualmente.

📋 A spec (Specify + Plan)

O SPEC.md definia o essencial: POST/GET/PUT/DELETE /todos, title obrigatório e description opcional na criação, toggle de completed, e os dois casos de erro que mais importam numa API de CRUD: 400 quando falta título, 404 quando o id não existe.

Fui adicionando requisitos ao longo do processo, direto no SPEC.md pelo editor: primeiro documentação, depois testes. Cada vez que a spec mudava, o TASKS.md ganhava uma etapa nova refletindo o requisito.

Duas fases do SDD couberam nesse arquivo. A seção de requisitos funcionais é Specify puro: o que o sistema faz. Já a seção 3, de restrições técnicas (Python com FastAPI em container, Postgres em container, JSON), é Plan disfarçado de requisito. Num projeto maior isso viraria um plan.md separado, com decisão de arquitetura, modelagem de dados e escolha de bibliotecas; aqui a stack era pequena o bastante pra caber em quatro linhas da própria spec.

O SPEC.md final ficou assim:

# MyTodo SDD - Scope Specification

## 1. Overview
* **Main goal:** Build a simple REST API for task (To-Do) management.
* **Target audience:** Users who want to organize their tasks through a programmatic interface.
* **Context:** Minimalist project to solve the user's pain of managing tasks quickly.

## 2. Functional Requirements
* **Task creation:**
  - [ ] Must allow creating a task by sending `title` (required) and `description` (optional).
  - [ ] Every created task must start with `completed: false` and get a unique `id` and a creation date (`createdAt`).
* **Task listing:**
  - [ ] Must return all registered tasks in a list (array).
* **Task deletion:**
  - [ ] Must allow deleting a task by its `id`.
* **Task update:**
  - [ ] Must allow toggling the task's `completed` status (true/false).
  - [ ] Must allow updating `title` or `description` by passing the `id`.

## 3. Non-Functional Requirements (Technical Constraints)
* **Technology:** Python with FastAPI inside a Docker container.
* **Persistence:** Data stored in a Postgres database in a Docker container.
* **Format:** The API must accept and respond strictly in JSON format.
* **Documentation:** Create documentation on how to run the project, tests and the APIs docs.
* **Tests:** Create tests to api endpoints.

## 4. Expected Endpoints (API Contract)
* `POST /todos` -> Creates a task.
* `GET /todos` -> Lists all tasks.
* `PUT /todos/:id` -> Updates the data or status of a task.
* `DELETE /todos/:id` -> Deletes a task.

## 5. Edge Cases and Errors
* **Invalid payload:** If `title` is not sent on `POST`, return `400 Bad Request` with the message: `"Title is required"`.
* **Item not found:** If trying to update or delete an `id` that doesn't exist, return `404 Not Found`.

🗂️ O plano em etapas (Tasks)

O TASKS.md acabou com 7 etapas:

  1. Infraestrutura e Docker: Dockerfile, docker-compose com app + Postgres, requirements.txt
  2. Banco de dados e modelagem: conexão via env vars, model Todo, schema Pydantic de entrada
  3. Endpoints: as quatro rotas do CRUD
  4. Tratamento de erros: 400 pra título ausente, 404 pra id inexistente
  5. Testes: pytest cobrindo sucesso e casos de borda de cada rota
  6. Validação final: rodar o ciclo completo via /docs (Swagger UI nativo do FastAPI)
  7. Documentação: README com setup, endpoints e como rodar os testes

Antes de começar a execução, instalei skills específicas pra FastAPI e testes em Python com npx autoskills (fastapi-python, fastapi-templates, python-executor, python-testing-patterns), pra o agente ter acesso a boas práticas e padrões prontos em vez de inventar tudo do zero a cada etapa.

O TASKS.md final, com tudo marcado:

# MyTodo SDD - Implementation Plan and Tasks

## 🚀 Overall Status: [100%] Complete (22/22 tasks)

## 🐳 Stage 1: Infrastructure and Containerization (Docker)
- [x] **Task 1.1:** Create the `Dockerfile` for the Python/FastAPI application.
- [x] **Task 1.2:** Create the `docker-compose.yml` configuring the app and Postgres database services.
- [x] **Task 1.3:** Create the `requirements.txt` file with dependencies (`fastapi`, `uvicorn`, `psycopg2-binary` or `asyncpg`, and an ORM if desired, such as `sqlmodel` or `sqlalchemy`).
- [x] **Task 1.4:** Bring up the containers and validate that the database and the Python container communicate.
- [x] **Task 1.5:** Create `README.md` with instructions on how to run the project (Docker/docker-compose).

## 🗄️ Stage 2: Database and Modeling
- [x] **Task 2.1:** Configure the connection script to the Postgres database using Docker environment variables.
- [x] **Task 2.2:** Create the `Todo` data model/table with fields: `id` (UUID or Serial), `title`, `description`, `completed` (default false) and `createdAt`.
- [x] **Task 2.3:** Create the Pydantic Schema for input validation (requiring `title` and optional `description`).

## 🚀 Stage 3: Endpoint Development (Routes)
- [x] **Task 3.1:** Implement the `POST /todos` route to persist new tasks in Postgres.
- [x] **Task 3.2:** Implement the `GET /todos` route to fetch and return the list of tasks from the database in JSON.
- [x] **Task 3.3:** Implement the `PUT /todos/{id}` route to update data or toggle the `completed` status.
- [x] **Task 3.4:** Implement the `DELETE /todos/{id}` route to remove tasks from the database.

## 🚨 Stage 4: Error Handling and Edge Cases
- [x] **Task 4.1:** Test Pydantic validation to ensure the correct response when `title` is missing.
- [x] **Task 4.2:** Add validation on the `PUT` and `DELETE` routes to return HTTP `404 Not Found` when the `id` doesn't exist in the database.

## ✅ Stage 5: Tests
- [x] **Task 5.1:** Set up test dependencies (`pytest`, `httpx`) and a test configuration.
- [x] **Task 5.2:** Write tests for the `POST /todos` endpoint (success and missing-title cases).
- [x] **Task 5.3:** Write tests for the `GET /todos` endpoint.
- [x] **Task 5.4:** Write tests for the `PUT /todos/{id}` endpoint (success and 404 cases).
- [x] **Task 5.5:** Write tests for the `DELETE /todos/{id}` endpoint (success and 404 cases).

## 🧪 Stage 6: Final Validation
- [x] **Task 6.1:** Test the full API usage cycle using FastAPI's native interactive documentation (`/docs`).

## 📚 Stage 7: Documentation
- [x] **Task 7.1:** Document the API endpoints (routes, payloads, responses and error codes).
- [x] **Task 7.2:** Document how to run the tests.

⚙️ A execução (Implement)

Cada etapa eu simplesmente pedia: "Execute a Etapa X do TASKS.md e atualize o Overall status". O agente lia a spec, implementava só o escopo daquela etapa, validava rodando de verdade e só então marcava as tarefas como concluídas.

Quando surgia algo novo no meio da execução, o fluxo era sempre o mesmo: voltar pro SPEC.md, escrever o requisito, e só depois gerar as tarefas correspondentes no TASKS.md. Nada de pedir a implementação direto pro agente sem passar pela spec primeiro, mesmo pra um ajuste pequeno, pra manter os dois arquivos como fonte da verdade do que o projeto faz e por quê.

📁 Depois: uma pasta por feature

Dois arquivos na raiz funcionam enquanto o projeto tem uma feature só. Não escala: eles crescem sem parar, fica difícil achar o que pertence a qual parte, e "execute a Etapa 3" vira ambíguo assim que existe mais de uma frente aberta, cada uma com suas próprias etapas.

Com o CRUD já pronto, reorganizei pra uma pasta specs/ com um subdiretório por feature, cada um com seu próprio par spec/tasks. É o mesmo padrão que o GitHub Spec Kit adota (specs/<feature>/spec.md, plan.md, tasks.md, gerados por branch):

specs/
  todos/
    SPEC.md      # requisitos da feature
    TASKS.md     # etapas e tarefas da feature
SPEC.md          # índice: visão geral do projeto
TASKS.md         # índice: progresso por feature

Os arquivos da raiz viram índice, não donos do conteúdo. SPEC.md:

# MyTodo SDD - Project Overview

Simple REST API for task (To-Do) management. Python + FastAPI + Postgres, all in Docker.

## Features

* **[todos-crud](specs/todos/SPEC.md)**: core task CRUD: create, list, update, delete, with title validation and 404 handling.

E TASKS.md, que além de indexar já deixa explícito pro agente como pedir a execução:

# MyTodo SDD - Progress Index

Each feature has its own SPEC.md/TASKS.md pair under `specs/<feature>/`, broken into numbered stages. When asked to "execute Stage N", specify which feature's TASKS.md (e.g. "execute Stage 3 of specs/todos/TASKS.md").

## Features

* **[todos-crud](specs/todos/TASKS.md)**: [100%] Complete (22/22 tasks)

Uma feature nova vira só um specs/<nome>/ com seu par de arquivos e uma linha em cada índice.

✅ Resultado

Ao final das 7 etapas: API funcional com as quatro rotas do CRUD, validação de entrada, tratamento de erro com os códigos certos, 7 testes automatizados passando, Swagger UI funcionando em /docs, e um README documentando desde o docker compose up até o payload de cada endpoint.

O ganho maior do SDD aqui não foi velocidade, foi rastreabilidade. A cada momento dava pra saber exatamente o que já tinha sido feito e o que faltava, porque tudo ficou documentado em markdown versionável junto com o código.

🚀 Próximos passos

Subagents por etapa. Neste projeto a fase de Implement rodou sequencial: um pedido por etapa, na mesma conversa. Dá pra ir além delegando cada etapa do TASKS.md pra um subagent, um agente isolado que recebe só aquele trecho (mais o contexto relevante da spec) e reporta de volta quando termina, validado. Aqui as etapas são dependentes entre si (a 3 usa o model criado na 2, os testes da 5 usam as rotas da 3), então não dá pra rodar em paralelo real, mas o ganho ainda existe: cada subagent carrega só o contexto da sua etapa, sem a história inteira das anteriores. Isso mantém a "memória de trabalho" do agente enxuta em projetos maiores, onde o histórico de uma conversa longa começa a pesar. Etapas genuinamente independentes (dois módulos sem dependência entre si, por exemplo) aí sim se beneficiariam de rodar em paralelo de verdade.

Sincronizando com Jira. Num time, o TASKS.md sozinho não basta: quem acompanha o projeto de fora quer ver progresso no board, não abrir um markdown no repositório. Dá pra manter o TASKS.md como fonte de trabalho do agente (é ele que orienta o "execute a Etapa X") e espelhar o estado pro Jira, cada etapa virando uma issue e cada tarefa interna virando uma subtask com status próprio. Isso rendeu um script à parte, e algumas armadilhas reais no caminho (idempotência quebrando por falta de state incremental, sync bidirecional trazendo status de volta, decisão de manter Jira como espelho de leitura em vez de inverter o fluxo, e um arquivo de estado por feature dentro de cada specs/<feature>/). Vou detalhar tudo isso, com o código, num post separado.