Spec-driven development é a prática de escrever uma especificação explícita do comportamento esperado antes de escrever qualquer código. Não é uma ideia nova — engenheiros que escrevem TDD rigoroso já fazem isso, já que o teste é a spec executável. O que muda com LLMs não é o conceito, mas a consequência de não fazê-lo: quando o par com quem você trabalha produz código a 100x sua velocidade, a ausência de uma spec clara resulta em 100x mais código que satisfaz a descrição vaga do que você disse, não o que você quis dizer.
O padrão mais comum de fracasso com AI-assisted engineering não é o LLM gerando código errado — é o LLM gerando código que satisfaz exatamente o que foi pedido, enquanto o que foi pedido era vago ou incompleto. "Adicione um endpoint para listar pedidos" produz um endpoint que lista pedidos. Mas lista os pedidos de todos os usuários ou só do usuário autenticado? Inclui pedidos cancelados? Tem paginação? Retorna qual formato de data? Tem algum limite de quantidade? Cada uma dessas decisões não respondidas pelo prompt vai resultar em uma escolha do LLM — que pode estar ou não alinhada com o que o sistema precisa.
Spec-driven development resolve esse problema na origem: a spec força o engenheiro a tomar essas decisões explicitamente antes de interagir com qualquer ferramenta. Isso tem valor independente de IA — forçar clareza antes de implementar é uma prática que melhora qualidade independentemente do executor. Mas o valor se multiplica com LLMs porque a spec se torna o contrato que governa toda a interação: o que você fornece como contexto estruturado, o que o LLM implementa, e o critério pelo qual você avalia se o output está correto.
A spec é também o artefato persistente que a sessão de chat não é. Uma conversa com um LLM não pode ser revisada por um par, versionada no repositório, ou reaproveitada para uma reimplementação futura. A spec pode. Ela é documentação de design que precede o código e sobrevive a ele.
A anatomia de uma spec eficaz
Uma spec eficaz para desenvolvimento — com ou sem LLM — tem estrutura que força completude. Cada seção serve um propósito específico, e seções faltantes indicam decisões que precisam ser tomadas antes de implementar.
Contexto. Onde esse código se encaixa no sistema. Quais serviços ou componentes interagem com ele. Quais são as dependências relevantes. O que motivou a necessidade dessa feature. Contexto não é história ou justificativa para a gestão — é o que o implementador (humano ou LLM) precisa saber para não tomar decisões incompatíveis com o resto do sistema.
Comportamento esperado. O que o código deve fazer, descrito em termos de comportamento observável, não de implementação. "O endpoint deve retornar os pedidos do usuário autenticado" é comportamento. "O endpoint deve usar uma query SQL com WHERE user_id = ?" é implementação — e pertence à discussão de design, não à spec. A separação é importante porque o LLM vai tentar satisfazer exatamente o que está escrito: se você especifica implementação, ele implementa aquilo, mesmo que haja uma solução melhor.
Exemplos concretos. Casos específicos que ilustram o comportamento esperado. "Dado um usuário com 3 pedidos (2 confirmados, 1 cancelado), o endpoint deve retornar os 2 confirmados em ordem decrescente de data de criação." Exemplos concretos eliminam ambiguidade que a descrição abstrata sempre deixa. Eles são também a base natural para testes — cada exemplo pode se tornar um caso de teste.
Restrições. O que o código não pode fazer, os limites que deve respeitar, as garantias que deve oferecer. Restrições de segurança (usuário não pode ver pedidos de outro usuário), restrições de performance (resposta em menos de 200ms para listagens de até 1000 itens), restrições de compatibilidade (a resposta JSON deve ser compatível com o schema atual do contrato de API, sem campos novos obrigatórios). LLMs não inferem restrições — elas precisam ser ditas.
Não-objetivos. O que está explicitamente fora do escopo dessa implementação. "Esta spec não cobre exportação de pedidos para CSV — isso é feature separada." Não-objetivos evitam que o LLM (ou o implementador humano) adicione funcionalidade não-solicitada que aumenta complexidade sem benefício imediato. São também úteis em code review: "por que isso está aqui? não estava na spec" é uma observação legítima.
Critério de aceitação. Como validar que a implementação está correta. Idealmente em termos de testes: "deve passar nos seguintes casos de teste". Pode ser também uma lista de comportamentos observáveis que o revisor vai verificar manualmente. Critério de aceitação sem ambiguidade é o que permite code review produtivo — sem ele, "está correto?" depende da interpretação de cada revisor.
Uma spec é bem escrita quando dois implementadores independentes — um humano e um LLM — produzem implementações que diferem apenas em detalhes estilísticos, não em comportamento. Se as implementações divergem em comportamento, a spec tinha ambiguidade. Encontrar ambiguidades na spec antes de implementar custa minutos. Encontrá-las em produção custa muito mais.
Spec como ferramenta de alinhamento
Além do valor técnico, a spec tem função de alinhamento: ela é o documento que o engenheiro e o LLM — ou o engenheiro e seu par humano — precisam concordar antes de implementar. Esse alinhamento é onde a maioria dos problemas de AI-assisted engineering aparecem mais cedo do que em qualquer outro ponto do ciclo.
O fluxo com LLM é: engenheiro escreve a spec, compartilha com o LLM, pede que o LLM identifique ambiguidades ou inconsistências antes de implementar. Esse passo extra — "olhe a spec, não o código" — tem valor desproporcional. LLMs são bons em identificar casos que a spec não cobre: "a spec diz que retorna pedidos do usuário autenticado, mas não diz o que acontece se o usuário não tem pedidos — deve retornar lista vazia ou 404?". Esse tipo de identificação de lacuna é exatamente o que um revisor humano faz em design review.
O LLM não está "entendendo" a spec no sentido humano. Está aplicando pattern matching sobre o texto e identificando padrões ausentes que aparecem frequentemente em specs similares no corpus de treinamento. O resultado prático é útil independentemente do mecanismo: a spec fica mais completa antes de a implementação começar.
A spec como contrato verificável
Uma spec que não pode ser verificada não é spec — é documentação de intenção. A distinção importa porque a verificabilidade é o que torna possível code review produtivo e iterar com segurança.
O melhor formato de verificação é o teste. Cada exemplo concreto na spec pode se tornar um caso de teste. Cada restrição pode se tornar uma assertion. Quando a spec é escrita com testes em mente — ou quando os testes são escritos junto com a spec — o critério de aceitação fica claro e automatizável. O LLM pode ser instruído a implementar código que passa nesses testes, e a correção da implementação é verificável sem depender de leitura subjetiva do código.
Essa é a ponte entre spec-driven development e TDD: em TDD rigoroso, o teste é a spec. Em spec-driven development com LLM, a spec é escrita em linguagem natural estruturada, os testes são derivados dela, e o LLM implementa código que satisfaz os testes. O ciclo é: spec → testes (humano escreve) → implementação (LLM escreve) → revisão (humano verifica). Cada passo tem um artefato claro e um responsável claro.
// SPEC: GET /api/orders
//
// Contexto:
// Endpoint de listagem de pedidos do usuário autenticado.
// Usa ClaimsPrincipal para extrair user_id do JWT.
// Repositório: IOrderRepository (ver IOrderRepository.cs).
//
// Comportamento esperado:
// Retorna lista paginada de pedidos do usuário autenticado.
// Ordenação padrão: createdAt DESC.
// Filtros opcionais via query string: status (enum OrderStatus).
// Paginação: cursor-based (campo: cursor, limit, hasMore).
//
// Exemplos:
// GET /api/orders → 200 { items: [...], cursor: "...", hasMore: true }
// GET /api/orders?status=Cancelled → só pedidos cancelados
// GET /api/orders?cursor=abc123&limit=20 → próxima página
// Usuário sem pedidos → 200 { items: [], cursor: null, hasMore: false }
//
// Restrições:
// Usuário só vê seus próprios pedidos (filtrar por user_id do token).
// Limit máximo: 100. Se omitido, usar 20.
// Não expor campos internos: cost_price, supplier_id.
//
// Não-objetivos:
// Exportação para CSV — feature separada.
// Pedidos de outros usuários (admin view) — endpoint diferente.
//
// Critério de aceitação:
// Testes em OrdersEndpointTests.cs devem passar.
// Response time < 200ms para usuários com até 10k pedidos.
A spec em comentário estruturado fica no mesmo repositório que o código, pode ser versionada, e serve como contexto quando o arquivo é lido pelo LLM em sessões futuras.
"""
SPEC: list_user_orders(user_id, status=None, cursor=None, limit=20)
Contexto:
Função de domínio chamada pela route GET /api/orders.
Usa OrderRepository (ver repository/orders.py).
cursor é opaque string (base64 de created_at + id).
Comportamento:
Retorna OrderPage com items, next_cursor e has_more.
Items ordenados por created_at DESC, id DESC (desempate).
Se status fornecido, filtra por OrderStatus enum.
Se cursor fornecido, retorna itens após esse ponto.
Exemplos:
list_user_orders(user_id=1)
→ OrderPage(items=[...20 items...], next_cursor="abc", has_more=True)
list_user_orders(user_id=1, status=OrderStatus.CANCELLED)
→ Só pedidos cancelados desse usuário
list_user_orders(user_id=1, cursor="abc", limit=5)
→ Próxima página com 5 itens
Usuário sem pedidos:
→ OrderPage(items=[], next_cursor=None, has_more=False)
Restrições:
NUNCA retornar pedidos de outro user_id.
limit máximo = 100; valores maiores → ValueError.
cursor inválido → CursorInvalidError (não silenciar).
Não-objetivos:
Busca full-text de pedidos — feature separada.
"""
# Testes derivados da spec (escritos antes da implementação):
def test_returns_own_orders_only():
...
def test_empty_user_returns_empty_page():
...
def test_limit_above_100_raises():
...
def test_invalid_cursor_raises():
...
Os testes são derivados diretamente dos exemplos e restrições da spec. Quando passados ao LLM junto com a spec, ele implementa a função sabendo exatamente o que vai ser verificado.
// SPEC.md (no pacote orders/)
//
// # ListUserOrders
//
// ## Contexto
// Serviço de domínio chamado pelo handler HTTP.
// Dependência: OrderStore interface (ver store.go).
// Retorna: OrderPage struct (ver types.go).
//
// ## Assinatura
// func (s *Service) ListUserOrders(
// ctx context.Context,
// userID string,
// opts ListOptions,
// ) (OrderPage, error)
//
// ## Comportamento
// - Retorna pedidos do userID especificado, ordenados por
// CreatedAt DESC.
// - opts.Status: se não-zero, filtra por status.
// - opts.Cursor: se não-vazio, retorna itens após esse cursor.
// - opts.Limit: default 20, max 100.
//
// ## Casos de borda
// - userID inexistente → OrderPage vazia, nil error
// - opts.Limit > 100 → ErrLimitExceeded
// - opts.Cursor inválido → ErrInvalidCursor
// - ctx cancelado → ctx.Err() propagado
//
// ## Garantias
// - Nunca retorna pedidos de outro userID (invariante crítica).
// - Idempotente: mesma chamada mesma resposta (dado dados inalterados).
//
// ## Não-objetivos
// - Busca por conteúdo de pedido — SearchOrders separado.
// - Paginação offset-based — somente cursor-based.
// Testes derivados (escritos pelo humano antes da impl):
func TestListUserOrders_ReturnsOnlyOwnOrders(t *testing.T) { ... }
func TestListUserOrders_EmptyUser(t *testing.T) { ... }
func TestListUserOrders_LimitExceeded(t *testing.T) { ... }
func TestListUserOrders_InvalidCursor(t *testing.T) { ... }
func TestListUserOrders_ContextCancelled(t *testing.T) { ... }
Em Go, a spec como arquivo separado no pacote serve de documentação viva. O LLM pode ler store.go, types.go e SPEC.md antes de implementar — todo o contexto necessário está no repositório.
Quando investir em spec formal e quando não investir
Spec-driven development tem custo: escrever uma spec estruturada leva tempo. O retorno — menos iterações, menos bugs, melhor output do LLM, código mais fácil de revisar — só supera o custo para funcionalidades acima de um certo limiar de complexidade.
A heurística prática que emerge da experiência com times que adotaram a prática: qualquer funcionalidade que levaria mais de uma hora para implementar manualmente justifica uma spec. Para funcionalidades que tomam menos que isso, um prompt bem escrito com contexto suficiente geralmente produz resultado aceitável sem o overhead de spec formal. O boundary não é rígido — depende do domínio, do histórico de bugs da área, e do quanto o comportamento correto é crítico para o negócio.
Funcionalidades que sempre justificam spec independente de tamanho: autenticação e autorização (invariantes de segurança são fáceis de violar com código gerado), integração com sistemas externos (comportamento sob falha precisa ser especificado explicitamente), lógica de negócio com regras complexas ou condicionais não-óbvios (o LLM vai inventar regras onde faltarem), e qualquer coisa que afete dados financeiros ou de saúde.
Funcionalidades que geralmente não precisam de spec formal: endpoints CRUD simples sem lógica de negócio, scripts de migração com comportamento claro, configuração de infraestrutura, código de setup e boilerplate. Para essas, um prompt detalhado com referência a código existente é suficiente.
O erro oposto também existe: spec-driven como processo burocrático que atrasa entrega sem agregar valor. Spec excessivamente detalhada para funcionalidade trivial, spec que especifica implementação em vez de comportamento, spec que exige aprovação de múltiplas partes antes de começar qualquer código — esses padrões subvertem o objetivo. A spec existe para forçar clareza antes de implementar, não para criar processo. Se escrever a spec parece mais trabalho do que implementar, a spec está no nível de detalhe errado.
A spec como documentação que sobrevive ao código
Uma das propriedades mais valiosas da spec é sua longevidade. O código muda — é refatorado, reescrito, migrado para nova biblioteca. A spec, quando bem escrita, continua relevante: ela documenta o comportamento que o código deve oferecer, independentemente de como é implementado.
Quando um engenheiro precisa entender o que um trecho de código deve fazer — não o que faz agora, que pode ter bug, mas o que deveria fazer — a spec é a fonte de verdade. Quando surge um bug, a spec determina se o comportamento atual é o bug (diverge da spec) ou se a spec estava errada (precisava ser atualizada). Essa distinção é crucial para diagnóstico correto.
Essa propriedade tem implicação para como manter specs: elas precisam ser atualizadas quando o comportamento especificado muda, assim como testes precisam ser atualizados. Uma spec desatualizada é pior do que nenhuma spec — ela induz engenheiros a assumir comportamento que não existe mais. O padrão recomendado é tratar spec e testes como co-localizados com o código que implementam: a spec vive no mesmo diretório, é revisada no mesmo PR, e desatualizar a spec é tão inaceitável quanto desatualizar os testes.
Spec-driven com LLMs: o ciclo completo
O ciclo completo de spec-driven development com LLM tem quatro passos, cada um com responsabilidade clara:
1. Escrever a spec (humano). O engenheiro escreve a spec antes de abrir qualquer ferramenta de IA. Isso é deliberado: a spec deve refletir o julgamento técnico e de domínio do engenheiro, não o que o LLM acha que deve ser feito. A ordem importa — escrever a spec depois de ver o output do LLM é deixar o LLM guiar o design, que inverte o controle.
2. Revisar a spec com o LLM (humano + LLM). Compartilhar a spec com o LLM e pedir identificação de ambiguidades, casos não cobertos, e inconsistências. "Leia essa spec e me diga: quais casos de entrada não estão cobertos? quais restrições poderiam conflitar entre si? o que está ambíguo?" Esse passo é opcional para specs simples, mas valioso para specs de comportamento complexo.
3. Implementar (LLM). Com a spec completa e os testes escritos pelo humano, instruir o LLM a implementar. O prompt inclui: a spec completa, os arquivos de dependência relevantes (interfaces, tipos existentes, exemplos de código da base), e os testes que a implementação deve passar. O LLM implementa; o humano observa sem intervir ainda.
4. Revisar e iterar (humano). O humano lê cada linha do código gerado, verifica contra a spec e os testes, identifica desvios (erros de implementação, casos de borda não tratados, código que funciona mas não segue as convenções da base), e instrui o LLM a corrigir pontos específicos. A iteração continua até o código satisfazer a spec completamente — ou até o engenheiro decidir que algum ponto da spec precisa ser revisado.
Se você está iterando com o LLM mais de três vezes no mesmo comportamento sem convergir, o problema geralmente não é o LLM — é que a spec está incompleta ou inconsistente. Pare a iteração de implementação, volte à spec, identifique o que está faltando, complete, e recomece. Iterar sobre código ambíguo produz código que eventualmente "parece certo" mas pode estar errado de formas que só aparecem em produção.
Spec-driven e o contrato de API
Para APIs expostas externamente ou para outros times, a spec tem uma dimensão adicional: é o contrato que define o que consumidores podem esperar. Esse contrato tem implicações de versionamento e compatibilidade que vão além do code review.
OpenAPI (Swagger) é uma forma de spec executável para APIs HTTP: define endpoints, parâmetros, schemas de request/response, e códigos de status de forma que ferramentas podem validar automaticamente. Quando a spec OpenAPI é escrita antes da implementação — schema-first development — o LLM pode gerar código que satisfaz o schema, e a consistência entre documentação e implementação é verificável automaticamente. Isso é uma das integrações mais produtivas de spec-driven com LLM: o schema é a spec, o código é gerado a partir dela, e a conformidade é automaticamente verificável.
gRPC via Protocol Buffers segue o mesmo princípio: o arquivo .proto é a spec. Escrever o .proto antes de implementar os handlers é spec-driven development nativo ao protocolo. O LLM pode gerar stubs, implementações de servidor e cliente, e testes a partir do .proto — todo o contexto que precisa está em um arquivo estruturado.
Como praticar
- Retroativamente escrever spec de código existente. Escolha uma função ou endpoint que você escreveu nos últimos três meses. Escreva a spec que deveria ter existido antes de implementá-la: contexto, comportamento, exemplos, restrições, não-objetivos. Compare a spec com o código atual — onde divergem? Onde a spec teria prevenido uma decisão que depois precisou ser corrigida? Esse exercício calibra o olhar para o que faz uma spec boa.
- Spec + testes antes de qualquer código. Na próxima funcionalidade que você for implementar (com ou sem LLM), escreva a spec completa primeiro. Derive os casos de teste dos exemplos e restrições. Só então escreva ou gere o código. Meça o tempo total: spec + testes + implementação vs sua estimativa de "só implementar". Na maioria dos casos, o tempo total é menor — a spec elimina retrabalho.
- Exercício de ambiguidade com LLM. Escreva uma spec deliberadamente incompleta para uma funcionalidade simples (uma página, sem casos de borda). Passe para um LLM e peça que identifique ambiguidades antes de implementar. Compare o que o LLM identificou com o que você sabe que está faltando. Itere a spec até o LLM não encontrar mais lacunas óbvias. Esse exercício trena o olhar para o que specs precisam cobrir explicitamente.
Referências para aprofundar
- livro Specification by Example — Gojko Adzic (2011).
- livro Test-Driven Development: By Example — Kent Beck (2002).
- artigo Writing Great Specifications — Kamil Nicieja (2017).
- artigo Schema-First API Development — Phil Sturgeon (2022).
- docs OpenAPI Specification 3.1 — OpenAPI Initiative.
- docs Protocol Buffers Language Guide — Google.
- artigo The Art of the Specification — Joel Spolsky (2000).
- artigo Behavior-Driven Development — Dan North (2006).
- livro Designing Data-Intensive Applications — Martin Kleppmann (2017).
- artigo Prompt Engineering for Developers — Anthropic (2024).
- vídeo TDD, Where Did It All Go Wrong — Ian Cooper, NDC London (2013).
- livro A Philosophy of Software Design — John Ousterhout (2018).