Backend

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:

Como o DARE guia a construção, fase a fase:

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

Marcos

  1. 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 Article com title, summary,
    body (traduzíveis) mais um slug está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 401 e 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.

  2. 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 JSONB translations no
    formato { "<locale>": { "<campo>": valor } }, uma coluna slug física e única,
    author_id, timestamps e um índice GIN em translations. 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-Language sobre 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 (401 com corpo JSON
    de erro, sem efeito colateral).

    Estratégia de fallback. Declare a cadeia (en -> pt-BR e pt-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 de 401 e a cadeia de
    fallback com sua regra de somente-leitura. Cada requisito não-funcional do milestone 1
    mapeia para algo neste blueprint.

  3. 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 JSONB translations e
      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, body como 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.

  4. 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 articles com o container JSONB tendo '{}'
    como padrão, adicione um índice GIN em translations e — 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,
    body traduzem; 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)
    end
    

    Slug 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))
    end
    

    Pronto quando: a migração roda e reverte limpo; definir o locale como pt-BR e ler
    article.title retorna o texto em português; ler sob en produz o fallback (não vazio)
    mas read_raw(:title, "en") ainda é nil; e criar um artigo e depois adicionar uma tradução
    no segundo locale deixa o slug inalterado.

  5. 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_default
    

    Auth (T3). Ponha a checagem de credencial num só lugar para toda escrita passar por
    ela. Uma escrita não autenticada retorna 401 com 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)   # auth
    

    Gravar 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 objeto translations inteiro 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 intactos
    

    Pronto quando: você consegue criar um artigo como cliente autenticado (POST /articles), lê-lo de volta sob ?locale=en e ?locale=pt-BR (cada um resolvendo
    certo, com fallback quando um locale está vazio), dar PATCH num locale e confirmar que o
    outro está intacto, e um POST/PATCH/DELETE não autenticado retorna 401 sem mudar os
    dados.

  6. 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 vazio
    

    3. 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).slug
    

    4. Zero N+1 em coleção.

    create_articles(10)
    assert_queries(1) do
      get("/articles").each { |a| a.title }   # sem query por registro
    end
    

    5. 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 colateral
    

    Critério de submissão. O projeto está pronto — e submissível — quando as cinco
    propriedades valem numa única rodada de testes: en e pt-BR sã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
    401 e 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.