Construa um API de Conteúdo Bilíngue com DARE
Projete e construa um pequeno API de conteúdo — artigos com autenticação, i18n por locale (traduções por campo, fallback, slug estável) e testes — aplicando o método DARE de ponta a ponta no seu próprio repo: Design, Blueprint, Tasks, Execute.
Este é um projeto guiado, não um starter. Não há repositório para clonar nem código com
lacunas a preencher. Você constrói um pequeno API de conteúdo bilíngue a partir de um
diretório vazio, conduzindo cada decisão pelas quatro fases do método DARE — Design →
Blueprint → Tasks → Execute — num repositório seu. Os artefatos que você produz (um
documento de design, um blueprint, um DAG de tarefas e testes passando) são tanto o entregável
quanto o código rodando.
Um API de conteúdo que precisa servir o mesmo artigo em mais de um idioma é um campo de
treino excepcionalmente bom para o DARE. É pequeno o suficiente para terminar, mas esconde
toda pegadinha que torna backends reais difíceis: um modelo de dados que não pode bifurcar
quando um campo é traduzido, negociação do locale do leitor em tempo de requisição, uma
fronteira de auth que precisa de fato barrar estranhos, uma regra de fallback que nunca pode
vazar para o armazenamento e uma URL que precisa ficar parada quando o idioma muda. Você não
consegue resolver na força bruta — precisa projetar primeiro, que é exatamente o músculo que
o DARE treina.
Ao final você terá entregue um API capaz de:
- Guardar artigos com traduções por locale para
title,summaryebody, além de umslug
físico, não traduzível, estável entre idiomas. - Resolver toda leitura para o locale do leitor, com uma cadeia de fallback para que nenhum
campo renderize vazio — sem que esse fallback jamais seja gravado no banco. - Expor endpoints CRUD autenticados (
GET/POST /articles,GET/PATCH/DELETE /articles/:slug) que negociam locale a partir de?locale=ou do headerAccept-Language. - Deixar um editor autorar um locale por vez sem apagar o outro.
- Provar tudo isso com testes, incluindo uma asserção de zero N+1 ao ler um campo
traduzido em coleção.
Como o DARE guia a construção, fase a fase:
-
Design nomeia os usuários, os casos de uso, o escopo e os requisitos não-funcionais
(i18n, auth, sem N+1). Responde o quê e por quê antes de qualquer como. -
Blueprint transforma isso em arquitetura: o modelo de dados, os contratos dos endpoints
com formatos concretos de request/response, a estratégia de auth e onde vive o fallback. -
Tasks decompõe o blueprint em unidades atômicas, ordenadas, com dependências mínimas —
um DAG que você executa um nó por vez. -
Execute implementa essas tarefas contra testes reais, em duas passadas (dados + i18n,
depois API + auth), e termina com uma passada de hardening que trava o comportamento.
Linguagem e framework são sua escolha — Rails, Laravel, FastAPI, NestJS, Gin, o que você
dominar. O método é o mesmo em todos; os milestones abaixo descrevem o que cada fase precisa
produzir e deixam os idiomas do como para a sua stack. Faça os milestones em ordem: cada um
tem um critério de pronto concreto, e o último define exatamente o que significa "submetido".
Este projeto se apoia diretamente no lab de i18n (Torne o Conteúdo Bilíngue (i18n)). Se você
ainda não o fez, faça primeiro — este projeto reusa as ideias de traduções em container JSONB,
fallback e slug estável, e as embrulha num API autenticado de verdade construído do jeito DARE.
Arquitetura
Esta é uma arquitetura de referência — o alvo que o milestone de Blueprint vai formalizar
para a sua stack. Leia como o formato a mirar, não como código para copiar.
Modelo de dados
Uma tabela física, articles. O texto traduzível vive num único container JSONB; identidade e
metadados são colunas comuns.
articles
├─ id bigint / uuid (chave primária)
├─ slug citext (UNIQUE, NOT NULL — física, nunca traduzida)
├─ author_id bigint (FK -> users.id)
├─ published_at timestamptz (null = rascunho)
├─ translations jsonb NOT NULL DEFAULT '{}' (índice GIN)
├─ created_at timestamptz
└─ updated_at timestamptz
translations = {
"en": { "title": "...", "summary": "...", "body": "..." },
"pt-BR": { "title": "...", "summary": "...", "body": "..." }
}
O slug é a identidade pública do registro: gerado uma vez a partir do título no locale de
criação, único, e nunca regenerado quando uma tradução é adicionada ou um título é editado.
Traduzir o slug é o único erro que bifurca um recurso em duas URLs — não faça.
Camadas
Mantenha o caminho da requisição em camadas finas, de responsabilidade única, para que i18n e
auth tenham cada um exatamente uma casa:
Requisição HTTP
│
▼
Controller / Handler — parseia params, negocia locale, aplica auth, formata a resposta
│
▼
Service — lógica do caso de uso: criar/atualizar artigo, mesclar os campos de um locale
│
▼
Repository — acesso a dados: find_by_slug, list (eager, sem N+1), persistir
│
▼
Model (Article) — acessores traduzíveis resolvem title/summary/body pelo locale corrente
Negociação de locale
Toda requisição resolve exatamente um "locale corrente" antes de o model ler qualquer coisa,
numa precedência fixa: query param explícito ?locale=, depois o header Accept-Language,
depois um default configurado. Resolva uma vez (um middleware ou um before-action), defina como
o locale corrente da requisição e deixe os acessores do model lerem contra ele. Rejeite locales
desconhecidos caindo no default, em vez de dar erro.
flowchart TD
A[Requisição recebida] --> B{"?locale= presente?"}
B -- sim --> L[Usa locale da query]
B -- não --> C{"Accept-Language presente?"}
C -- sim --> M[Parseia header, escolhe melhor suportado]
C -- não --> D[Usa locale default]
L --> E[Define Current.locale]
M --> E
D --> E
E --> F{Autenticado?}
F -- não --> G[401 Unauthorized]
F -- sim --> H[Controller -> Service -> Repository]
H --> I[Model resolve campos por Current.locale]
I --> J[Serializa resposta nesse locale]
Auth
Uma fronteira de token ou sessão na frente das escritas (e, se o produto exigir, das leituras).
Uma requisição sem credencial válida recebe 401 e nunca chega ao service. Mantenha a checagem
de auth num só lugar (middleware / before-action), não espalhada por endpoint. Quais endpoints
são públicos ou protegidos é uma decisão de Design — no mínimo, toda escrita é autenticada.
Superfície de endpoints
| Método | Caminho | Propósito | Auth |
|---|---|---|---|
| GET | /articles |
Lista (locale negociado, sem N+1) | por Design |
| POST | /articles |
Cria (grava locale de criação) | sim |
| GET | /articles/:slug |
Mostra um, resolvido ao locale | por Design |
| PATCH | /articles/:slug |
Atualiza campos de um locale (merge) | sim |
| DELETE | /articles/:slug |
Remove | sim |
Leituras sempre resolvem pela cadeia de fallback (nunca um campo vazio). Escritas miram
apenas o locale ativo e fazem merge no container JSONB — nunca podem substituir o container
inteiro nem copiar um valor de fallback para dentro de um locale que não tinha nenhum.
Trade-offs
-
Container JSONB vs. tabela de traduções. O container guarda toda tradução dentro da
linha, então carregar uma coleção carrega todos os locales junto — sem join, sem query de
acompanhamento por registro, sem N+1. É por isso que este projeto o usa. Uma tabela separada
article_translations(linha por artigo × locale × campo) é mais normalizada e permite ao
banco consultar ou indexar traduções individuais, mas reintroduz o join e o N+1 que você
acabou de remover e complica as escritas. Escolha a tabela só quando os locales são muitos,
esparsos ou precisam ser consultados de forma independente; para um conjunto limitado lido
junto com o registro, o container vence. -
Onde vive o fallback. Fallback é uma preocupação de tempo de leitura, só de exibição
e pertence ao acessor de leitura do model, nunca ao armazenamento e nunca ao caminho de
escrita. Formulários de edição e o endpoint de escrita devem ler o valor cru do locale
ativo (fallback DESLIGADO) para que salvar nunca persista um fallback dentro de um locale que
estava genuinamente vazio. -
Dono do slug. O slug é identidade, gerado uma vez e congelado. Se um humano precisar
mudar um, trate como ação deliberada com redirect a partir do valor antigo — não como efeito
colateral de tradução.
Marcos
-
Design: usuários, casos de uso, escopo, requisitos não-funcionais
Abra a fase de Design do DARE. Antes de qualquer schema ou endpoint, escreva para
quem este API existe, o que essas pessoas fazem com ele e as restrições que ele precisa
honrar. Produza um documento de design curto — este é um entregável de verdade, não um
aquecimento.Nomeie os atores e seus casos de uso:
-
Leitor — busca artigos e sempre vê o conteúdo no seu locale (ou um fallback
sensato), nunca um campo vazio. -
Editor — cria e atualiza artigos, autorando um idioma por vez, sem apagar o outro
idioma. -
Sistema/agente — um cliente programático que negocia locale por headers e precisa
estar autenticado para escrever.
Fixe o escopo explicitamente. Dentro: um recurso
Articlecomtitle,summary,
body(traduzíveis) mais umslugestável; dois locales (en,pt-BR); CRUD
autenticado; negociação de locale. Fora (por ora): comentários, upload de mídia,
papéis/permissões além de "autenticado", mais de dois locales, busca. Escrever o que está
fora é tão importante quanto o que está dentro.Declare os requisitos não-funcionais como frases testáveis, porque eles guiam todas as
fases seguintes:-
i18n: uma leitura nunca retorna campo traduzível vazio quando algum locale tem
conteúdo (fallback), e o fallback nunca é persistido. - Identidade: o slug é estável entre locales e entre edições de título.
-
Auth: uma escrita não autenticada é rejeitada com
401e não tem efeito colateral. -
Performance: ler um campo traduzido numa coleção de N artigos dispara uma contagem de
queries independente de N (sem N+1).
Pronto quando: existe um documento de design que lista os atores, seus casos de uso, um
escopo explícito de dentro/fora e os quatro requisitos não-funcionais acima escritos como
afirmações verificáveis. Quem o ler consegue dizer o que você está construindo e como vai
saber que funciona — sem ver uma linha de código. -
Leitor — busca artigos e sempre vê o conteúdo no seu locale (ou um fallback
-
Blueprint: modelo de dados, contratos dos endpoints, estratégia de auth e fallback
Avance para a fase de Blueprint do DARE: transforme o Design aprovado numa arquitetura
concreta. Este milestone é sobre decidir como, no papel, para que o Execute não tenha
nada a improvisar.Modelo de dados. Especifique a tabela
articles: uma coluna JSONBtranslationsno
formato{ "<locale>": { "<campo>": valor } }, uma colunaslugfísica e única,
author_id, timestamps e um índice GIN emtranslations. Escreva explicitamente quais
campos traduzem (title,summary,body) e quais não (slug, ids, timestamps).Contratos dos endpoints. Fixe os formatos de request e response. Por exemplo:
POST /articles (auth obrigatória) Accept-Language: pt-BR { "title": "Primeiros Passos", "summary": "Um guia curto", "body": "..." } -> 201 Created { "slug": "primeiros-passos", "locale": "pt-BR", "title": "Primeiros Passos", "summary": "Um guia curto" } GET /articles?locale=en -> 200 OK [ { "slug": "primeiros-passos", "title": "Primeiros Passos (fallback)", ... } ]Defina a regra de negociação de locale com precisão: precedência de
?locale=sobre
Accept-Languagesobre um default, e o que acontece com um locale desconhecido (cai no
default, não dá erro).Estratégia de auth. Decida o mecanismo (bearer token ou sessão), quais endpoints são
protegidos (no mínimo todas as escritas) e o formato exato da falha (401com corpo JSON
de erro, sem efeito colateral).Estratégia de fallback. Declare a cadeia (
en -> pt-BRept-BR -> en), que vazio é
tratado como ausente e — crucialmente — que o fallback vale só para leituras e nunca é
gravado de volta. Anote em qual camada ele vive (o acessor de leitura do model).Pronto quando: um documento de blueprint captura a tabela (com índice), a tabela
completa de endpoints com pelo menos um par concreto de request/response por verbo, a
precedência de negociação, o mecanismo de auth com seu contrato de401e a cadeia de
fallback com sua regra de somente-leitura. Cada requisito não-funcional do milestone 1
mapeia para algo neste blueprint. -
Tasks: decomponha num DAG com dependências mínimas
Entre na fase de Tasks do DARE. Quebre o blueprint em unidades atômicas de trabalho,
cada uma pequena o suficiente para implementar e testar sozinha, e conecte-as num grafo de
dependências (um DAG) para você sempre saber o que está desbloqueado em seguida.Uma decomposição razoável:
-
T1 — Migração + backfill: cria
articles, adiciona a coluna JSONBtranslationse
seu índice GIN e (se você carrega linhas legadas) faz backfill do texto existente para o
locale de origem. -
T2 — Model traduzível: declara
title,summary,bodycomo traduzidos,
resolvendo pelo locale corrente com fallback; o slug continua físico. -
T3 — Auth: o mecanismo de credencial e o middleware/before-action que retorna
401
para escritas não autenticadas. -
T4 — Endpoints: os handlers CRUD (
GET/POST /articles,
GET/PATCH/DELETE /articles/:slug) ligados a um service e a um repository. -
T5 — Negociação de locale: resolve o locale corrente a partir de
?locale=/
Accept-Language/ default, aplicado por requisição antes de o model ler. -
T6 — Testes: a suíte de hardening (gravações separadas coexistem, fallback, slug
estável, zero N+1, auth barra).
Agora desenhe as arestas — só as mínimas:
flowchart LR T1[Migração + backfill] --> T2[Model traduzível] T2 --> T4[Endpoints] T3[Auth] --> T4 T5[Negociação de locale] --> T4 T2 --> T5 T4 --> T6[Testes / harden] T3 --> T6
Note o que é paralelizável: T3 (auth) não depende de T1/T2 e pode ser construída junto
com o trabalho de dados; T5 depende apenas de o model expor leituras cientes de locale.
Manter dependências mínimas é o ponto — um grafo gordo e superconectado serializa trabalho
que poderia rodar em paralelo e esconde o caminho crítico real (T1 -> T2 -> T4 -> T6).Pronto quando: cada elemento do blueprint mapeia para exatamente uma task, cada task
tem um critério de pronto claro e o grafo de dependências é acíclico sem nenhuma aresta que
não seja de fato necessária. Você consegue apontar a próxima task desbloqueada a qualquer
momento. -
T1 — Migração + backfill: cria
-
Execute — dados + i18n: migração, model traduzível, slug estável
Comece a fase de Execute do DARE pela fundação sobre a qual tudo se apoia: a camada de
dados e seu comportamento de i18n. São as tasks T1, T2 e a regra do slug do DAG.Migração + índice + backfill (T1). Crie
articlescom o container JSONB tendo'{}'
como padrão, adicione um índice GIN emtranslationse — se você semeou linhas monolíngues
legadas — dobre cada campo de texto legado dentro do locale de origem.ALTER TABLE articles ADD COLUMN translations JSONB NOT NULL DEFAULT '{}'::jsonb; CREATE INDEX index_articles_on_translations ON articles USING GIN (translations); -- backfill só se você tiver linhas legadas UPDATE articles SET translations = jsonb_build_object( 'pt-BR', jsonb_strip_nulls(jsonb_build_object('title', title, 'summary', summary, 'body', body)) ) WHERE translations = '{}'::jsonb;Torne a migração reversível.
Model traduzível (T2). Declare exatamente quais atributos traduzem e roteie seus
acessores pelo locale corrente — lendo e gravando no container. Sótitle,summary,
bodytraduzem;slug, ids e timestamps não.# pseudocódigo class Article translates :title, :summary, :body # backend: container/JSONB, coluna: translations # article.title -> translations[Current.locale]["title"] (com fallback na leitura) # article.title = x -> translations[Current.locale]["title"] = x (cru, só este locale) endSlug estável. Gere o slug uma vez, na criação, a partir do título no locale de criação
lido sem fallback, e congele. Nunca regenere quando uma tradução é adicionada ou um
título é editado.before_create :assign_slug def assign_slug source = read_raw(:title, Current.locale) # sem fallback self.slug = ensure_unique(parameterize(source)) endPronto quando: a migração roda e reverte limpo; definir o locale como
pt-BRe ler
article.titleretorna o texto em português; ler sobenproduz o fallback (não vazio)
masread_raw(:title, "en")ainda é nil; e criar um artigo e depois adicionar uma tradução
no segundo locale deixa o slug inalterado. -
Execute — API + auth: CRUD, autenticação, negociação de locale por requisição
Continue o Execute com a superfície de requisição: os endpoints, a fronteira de auth e
a negociação de locale. São as tasks T3, T4 e T5 do DAG, construídas sobre a camada de
dados do milestone 4.Negociação de locale por requisição (T5). Antes de qualquer leitura do model, resolva o
locale corrente uma vez, na precedência do blueprint, e defina para a requisição:# pseudocódigo — middleware / before-action locale = params[:locale] || melhor_suportado(request.headers["Accept-Language"]) || locale_default Current.locale = suportado?(locale) ? locale : locale_defaultAuth (T3). Ponha a checagem de credencial num só lugar para toda escrita passar por
ela. Uma escrita não autenticada retorna401com corpo JSON de erro e não toca em nada.# pseudocódigo — antes de o controller agir nas escritas return unauthorized_401 unless credencial_valida?(request)Endpoints CRUD (T4). Ligue os handlers a um service e repository finos. Leituras
resolvem por fallback; a leitura de coleção precisa fazer eager-load para ficar livre de
N+1.GET /articles -> repo.list (locale negociado, query única) POST /articles -> service.create(params, locale: Current.locale) # auth GET /articles/:slug -> repo.find_by_slug!(slug) (resolvido ao locale) PATCH /articles/:slug -> service.update_locale(article, params, locale: Current.locale) # auth DELETE /articles/:slug -> service.destroy(article) # authGravar um locale sem apagar o outro. O caminho de update precisa ler o valor cru do
locale ativo (fallback DESLIGADO) e fazer merge dos campos do locale ativo no container
— nunca substituir o objetotranslationsinteiro e nunca copiar um fallback para dentro
de um locale vazio.# pseudocódigo — update Current.locale = locale_negociado article.title = params[:title] # grava só translations[locale]["title"] article.save # outros locales intactosPronto quando: você consegue criar um artigo como cliente autenticado (
POST /articles), lê-lo de volta sob?locale=ene?locale=pt-BR(cada um resolvendo
certo, com fallback quando um locale está vazio), darPATCHnum locale e confirmar que o
outro está intacto, e umPOST/PATCH/DELETEnão autenticado retorna401sem mudar os
dados. -
Harden & verify: testes, zero N+1 e o critério de submissão
Encerre a fase de Execute do DARE com uma passada de hardening (task T6). Transforme
cada requisito não-funcional do Design num teste automatizado, para o comportamento ficar
travado e uma regressão falhar em alto e bom som.1. Gravações separadas por locale coexistem.
with_locale("pt-BR") { post_article(title: "Primeiros Passos") } with_locale("en") { patch_article(slug, title: "Getting Started") } assert_equal "Primeiros Passos", with_locale("pt-BR") { get_article(slug).title } assert_equal "Getting Started", with_locale("en") { get_article(slug).title }2. O fallback preenche uma lacuna e nunca é persistido.
create_article(only: { "pt-BR" => { title: "Só PT" } }) assert_equal "Só PT", with_locale("en") { get_article(slug).title } # fallback mostra PT assert_nil article.read_raw(:title, "en") # mas en fica vazio3. O slug é estável através da tradução.
article = with_locale("pt-BR") { post_article(title: "Primeiros Passos") } original = article.slug with_locale("en") { patch_article(article.slug, title: "Getting Started") } assert_equal original, get_article(original).slug4. Zero N+1 em coleção.
create_articles(10) assert_queries(1) do get("/articles").each { |a| a.title } # sem query por registro end5. Auth barra o não autenticado.
response = post("/articles", body: payload_valido, credential: nil) assert_equal 401, response.status assert_equal 0, Article.count # sem efeito colateralCritério de submissão. O projeto está pronto — e submissível — quando as cinco
propriedades valem numa única rodada de testes:enept-BRsão escritos e lidos de
forma independente; o fallback impede qualquer campo traduzível vazio sem nunca ser
persistido; o slug é gerado uma vez e nunca se move quando uma tradução é adicionada ou um
título é editado; ler um campo traduzido em N artigos dispara uma contagem de queries plana,
independente de N; e todo endpoint de escrita rejeita uma requisição não autenticada com
401e sem efeito colateral. Quando esses testes estão verdes, você aplicou o DARE de ponta
a ponta — Design, Blueprint, Tasks, Execute — e construiu um API de conteúdo bilíngue de
verdade para provar.