Backend

Build a Bilingual Content API With DARE

Design and build a small content API — articles with authentication, per-locale i18n (per-field translations, fallback, stable slug), and tests — by applying the DARE method end to end in your own repo: Design, Blueprint, Tasks, Execute.

This is a guided project, not a starter. There is no repository to clone and no code to
fill in the blanks. You build a small bilingual content API from an empty directory,
driving every decision through the four phases of the DARE method — Design → Blueprint →
Tasks → Execute
— in a repository you own. The artifacts you produce (a design doc, a
blueprint, a task DAG, and passing tests) are as much the deliverable as the running code.

A content API that has to serve the same article in more than one language is an unusually
good training ground for DARE. It is small enough to finish, but it hides every trap that
makes real backends hard: a data model that must not fork when a field is translated,
request-time negotiation of the reader's locale, an auth boundary that has to actually block
strangers, a fallback rule that must never leak into storage, and a URL that must stay put
when the language changes. You cannot brute-force it — you have to design it first, which
is exactly the muscle DARE trains.

By the end you will have shipped an API that can:

How DARE guides the build, phase by phase:

Language and framework are your choice — Rails, Laravel, FastAPI, NestJS, Gin, whatever you
reach for. The method is the same everywhere; the milestones below describe what each phase
must produce, and leave the idioms of how to your stack. Work the milestones in order: each
one has a concrete done-criterion, and the last defines exactly what "submitted" means.

This project pulls directly on the i18n lab (Make Content Bilingual With i18n). If you have
not done it, do it first — this project reuses its container-JSONB translations, fallback, and
stable-slug ideas, and wraps them in a real authenticated API built the DARE way.

Architecture

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

Milestones

  1. Design: users, use cases, scope, non-functional requirements

    Open the DARE Design phase. Before any schema or endpoint, write down who this API
    is for, what they do with it, and the constraints it must honor. Produce a short design
    document — this is a real deliverable, not a warm-up.

    Name the actors and their use cases:

    • Reader — fetches articles and always sees content in their locale (or a sensible
      fallback), never an empty field.
    • Editor — creates and updates articles, authoring one language at a time, without
      wiping the other language.
    • System/agent — a programmatic client that negotiates locale via headers and must be
      authenticated to write.

    Fix the scope explicitly. In: one Article resource with title, summary, body
    (translatable) plus a stable slug; two locales (en, pt-BR); authenticated CRUD;
    locale negotiation. Out (for now): comments, media uploads, roles/permissions beyond
    "authenticated", more than two locales, search. Writing down what is out is as important
    as what is in.

    State the non-functional requirements as testable sentences, because they drive every
    later phase:

    • i18n: a read never returns an empty translatable field when any locale has content
      (fallback), and the fallback is never persisted.
    • Identity: the slug is stable across locales and across title edits.
    • Auth: an unauthenticated write is rejected with 401 and has no side effect.
    • Performance: reading a translated field across a collection of N articles issues a
      query count independent of N (no N+1).

    Done when: a design doc exists that lists the actors, their use cases, an explicit
    in/out scope, and the four non-functional requirements above phrased as checkable
    statements. Anyone reading it can tell what you are building and how you will know it works
    — without seeing a line of code.

  2. Blueprint: data model, endpoint contracts, auth and fallback strategy

    Move to the DARE Blueprint phase: turn the approved Design into a concrete
    architecture. This milestone is about deciding how, on paper, so Execute has nothing left
    to improvise.

    Data model. Specify the articles table: a translations JSONB column shaped as
    { "<locale>": { "<field>": value } }, a physical unique slug column, author_id,
    timestamps, and a GIN index on translations. Write down explicitly which fields
    translate (title, summary, body) and which do not (slug, ids, timestamps).

    Endpoint contracts. Pin down request and response shapes. For example:

    POST /articles          (auth required)
    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)", ... } ]
    

    Define the locale negotiation rule precisely: precedence of ?locale= over
    Accept-Language over a default, and what happens for an unknown locale (fall back to
    default, do not error).

    Auth strategy. Decide the mechanism (bearer token or session), which endpoints are
    protected (all writes at minimum), and the exact failure shape (401 with a JSON error
    body, no side effect).

    Fallback strategy. State the chain (en -> pt-BR and pt-BR -> en), that blank is
    treated as missing, and — critically — that fallback applies to reads only and is never
    written back. Note where in the layers it lives (the model's read accessor).

    Done when: a blueprint document captures the table (with index), the full endpoint
    table with at least one concrete request/response pair per verb, the negotiation
    precedence, the auth mechanism with its 401 contract, and the fallback chain with its
    read-only rule. Every non-functional requirement from milestone 1 maps to something in this
    blueprint.

  3. Tasks: decompose into a DAG with minimal dependencies

    Enter the DARE Tasks phase. Break the blueprint into atomic units of work, each small
    enough to implement and test on its own, and wire them into a dependency graph (a DAG) so
    you always know what is unblocked next.

    A reasonable decomposition:

    • T1 — Migration + backfill: create articles, add the translations JSONB column and
      its GIN index, and (if you carry legacy rows) backfill existing text into the source
      locale.
    • T2 — Translatable model: declare title, summary, body as translated, resolving
      per current locale with fallback; slug stays physical.
    • T3 — Auth: the credential mechanism and the middleware/before-action that returns
      401 for unauthenticated writes.
    • T4 — Endpoints: the CRUD handlers (GET/POST /articles,
      GET/PATCH/DELETE /articles/:slug) wired to a service and repository.
    • T5 — Locale negotiation: resolve current locale from ?locale= / Accept-Language /
      default, applied per request before the model reads.
    • T6 — Tests: the hardening suite (separate writes coexist, fallback, stable slug,
      zero N+1, auth blocks).

    Now draw the edges — only the minimal ones:

    flowchart LR
      T1[Migration + backfill] --> T2[Translatable model]
      T2 --> T4[Endpoints]
      T3[Auth] --> T4
      T5[Locale negotiation] --> T4
      T2 --> T5
      T4 --> T6[Tests / harden]
      T3 --> T6
    

    Note what is parallelizable: T3 (auth) has no dependency on T1/T2 and can be built
    alongside the data work; T5 depends only on the model exposing locale-aware reads. Keeping
    dependencies minimal is the point — a fat, over-connected graph serializes work that could
    run in parallel and hides the real critical path (T1 -> T2 -> T4 -> T6).

    Done when: every blueprint element maps to exactly one task, each task has a clear
    done-criterion, and the dependency graph is acyclic with no edge that isn't truly required.
    You can point at the next unblocked task at any moment.

  4. Execute — data + i18n: migration, translatable model, stable slug

    Begin the DARE Execute phase with the foundation everything else stands on: the data
    layer and its i18n behavior. This is tasks T1, T2, and the slug rule from the DAG.

    Migration + index + backfill (T1). Create articles with the JSONB container defaulting
    to '{}', add a GIN index on translations, and — if you seeded legacy monolingual rows —
    fold each legacy text field into the source locale.

    ALTER TABLE articles
      ADD COLUMN translations JSONB NOT NULL DEFAULT '{}'::jsonb;
    
    CREATE INDEX index_articles_on_translations
      ON articles USING GIN (translations);
    
    -- backfill only if you have legacy rows
    UPDATE articles
    SET translations = jsonb_build_object(
      'pt-BR', jsonb_strip_nulls(jsonb_build_object('title', title, 'summary', summary, 'body', body))
    )
    WHERE translations = '{}'::jsonb;
    

    Make the migration reversible.

    Translatable model (T2). Declare exactly which attributes translate and route their
    accessors through the current locale — reading from and writing into the container. Only
    title, summary, body translate; slug, ids, and timestamps do not.

    # pseudocode
    class Article
      translates :title, :summary, :body   # backend: container/JSONB, column: translations
      # article.title      -> translations[Current.locale]["title"] (with fallback on read)
      # article.title = x  -> translations[Current.locale]["title"] = x (raw, this locale only)
    end
    

    Stable slug. Generate the slug once, at creation, from the creation-locale title read
    without fallback, and freeze it. Never regenerate it when a translation is added or a
    title is edited.

    before_create :assign_slug
    def assign_slug
      source = read_raw(:title, Current.locale)   # no fallback
      self.slug = ensure_unique(parameterize(source))
    end
    

    Done when: the migration runs and rolls back cleanly; setting the locale to pt-BR and
    reading article.title returns the Portuguese text; reading under en yields the fallback
    (not empty) but read_raw(:title, "en") is still nil; and creating an article then adding a
    second-locale translation leaves the slug unchanged.

  5. Execute — API + auth: CRUD, authentication, per-request locale negotiation

    Continue Execute with the request surface: the endpoints, the auth boundary, and
    locale negotiation. This is tasks T3, T4, and T5 from the DAG, built on the data layer from
    milestone 4.

    Locale negotiation per request (T5). Before any model read, resolve the current locale
    once, in the blueprint's precedence, and set it for the request:

    # pseudocode — middleware / before-action
    locale = params[:locale] ||
             best_supported(request.headers["Accept-Language"]) ||
             default_locale
    Current.locale = supported?(locale) ? locale : default_locale
    

    Auth (T3). Put the credential check in one place so every write goes through it. An
    unauthenticated write returns 401 with a JSON error body and touches nothing.

    # pseudocode — before the controller acts on writes
    return unauthorized_401 unless valid_credential?(request)
    

    CRUD endpoints (T4). Wire the handlers to a thin service and repository. Reads resolve
    through fallback; the collection read must eager-load so it stays N+1-free.

    GET  /articles          -> repo.list  (locale-negotiated, single query)
    POST /articles          -> service.create(params, locale: Current.locale)   # auth
    GET  /articles/:slug    -> repo.find_by_slug!(slug)   (resolved to locale)
    PATCH /articles/:slug   -> service.update_locale(article, params, locale: Current.locale)  # auth
    DELETE /articles/:slug  -> service.destroy(article)   # auth
    

    Writing one locale without wiping the other. The update path must read the raw
    value of the active locale (fallback OFF) and merge the active locale's fields into the
    container — never replace the whole translations object and never copy a fallback into an
    empty locale.

    # pseudocode — update
    Current.locale = negotiated_locale
    article.title = params[:title]     # writes translations[locale]["title"] only
    article.save                       # other locales untouched
    

    Done when: you can create an article as an authenticated client (POST /articles),
    read it back under both ?locale=en and ?locale=pt-BR (each resolving correctly, with
    fallback when a locale is empty), PATCH one locale and confirm the other is intact, and an
    unauthenticated POST/PATCH/DELETE returns 401 with no change to the data.

  6. Harden & verify: tests, zero N+1, and the submission criterion

    Close the DARE Execute phase with a hardening pass (task T6). Turn every non-functional
    requirement from the Design into an automated test, so the behavior is locked and a
    regression fails loudly.

    1. Separate per-locale writes coexist.

    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. Fallback fills a gap and is never persisted.

    create_article(only: { "pt-BR" => { title: "Só PT" } })
    
    assert_equal "Só PT", with_locale("en") { get_article(slug).title }   # fallback shows PT
    assert_nil article.read_raw(:title, "en")                             # but en stays empty
    

    3. The slug is stable across translation.

    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 across a collection.

    create_articles(10)
    assert_queries(1) do
      get("/articles").each { |a| a.title }   # no per-record query
    end
    

    5. Auth blocks the unauthenticated.

    response = post("/articles", body: valid_payload, credential: nil)
    assert_equal 401, response.status
    assert_equal 0, Article.count            # no side effect
    

    Submission criterion. The project is done — and submittable — when all five hold in a
    single test run: en and pt-BR are written and read independently; fallback prevents any
    empty translatable field without ever being persisted; the slug is generated once and never
    moves when a translation is added or a title is edited; reading a translated field across N
    articles issues a flat, N-independent query count; and every write endpoint rejects an
    unauthenticated request with 401 and no side effect. When those tests are green, you have
    applied DARE end to end — Design, Blueprint, Tasks, Execute — and built a real bilingual
    content API to prove it.