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:
- Store articles with per-locale translations for
title,summary, andbody, plus a
physical, non-translatedslugthat is stable across languages. - Resolve every read to the reader's locale, with a fallback chain so no field ever renders
empty — without that fallback ever being written to the database. - Expose authenticated CRUD endpoints (
GET/POST /articles,GET/PATCH/DELETE /articles/:slug) that negotiate locale from?locale=or theAccept-Languageheader. - Let an editor author one locale at a time without wiping the other.
- Prove all of the above with tests, including a zero N+1 assertion when reading a
translated field across a collection.
How DARE guides the build, phase by phase:
-
Design names the users, the use cases, the scope, and the non-functional requirements
(i18n, auth, no N+1). It answers what and why before any how. -
Blueprint turns that into an architecture: the data model, the endpoint contracts with
concrete request/response shapes, the auth strategy, and where the fallback lives. -
Tasks decomposes the blueprint into atomic, ordered units with minimal dependencies —
a DAG you can execute one node at a time. -
Execute implements those tasks against real tests, in two passes (data + i18n, then
API + auth), and finishes with a hardening pass that locks the behavior down.
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
-
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.
Milestones
-
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
Articleresource withtitle,summary,body
(translatable) plus a stableslug; 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
401and 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. -
Reader — fetches articles and always sees content in their locale (or a sensible
-
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
articlestable: atranslationsJSONB column shaped as
{ "<locale>": { "<field>": value } }, a physical uniqueslugcolumn,author_id,
timestamps, and a GIN index ontranslations. 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-Languageover 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 (401with a JSON error
body, no side effect).Fallback strategy. State the chain (
en -> pt-BRandpt-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 its401contract, and the fallback chain with its
read-only rule. Every non-functional requirement from milestone 1 maps to something in this
blueprint. -
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 thetranslationsJSONB column and
its GIN index, and (if you carry legacy rows) backfill existing text into the source
locale. -
T2 — Translatable model: declare
title,summary,bodyas translated, resolving
per current locale with fallback; slug stays physical. -
T3 — Auth: the credential mechanism and the middleware/before-action that returns
401for 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. -
T1 — Migration + backfill: create
-
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
articleswith the JSONB container defaulting
to'{}', add a GIN index ontranslations, 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,bodytranslate;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) endStable 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)) endDone when: the migration runs and rolls back cleanly; setting the locale to
pt-BRand
readingarticle.titlereturns the Portuguese text; reading underenyields the fallback
(not empty) butread_raw(:title, "en")is still nil; and creating an article then adding a
second-locale translation leaves the slug unchanged. -
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_localeAuth (T3). Put the credential check in one place so every write goes through it. An
unauthenticated write returns401with 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) # authWriting 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 wholetranslationsobject 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 untouchedDone when: you can create an article as an authenticated client (
POST /articles),
read it back under both?locale=enand?locale=pt-BR(each resolving correctly, with
fallback when a locale is empty),PATCHone locale and confirm the other is intact, and an
unauthenticatedPOST/PATCH/DELETEreturns401with no change to the data. -
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 empty3. 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).slug4. Zero N+1 across a collection.
create_articles(10) assert_queries(1) do get("/articles").each { |a| a.title } # no per-record query end5. Auth blocks the unauthenticated.
response = post("/articles", body: valid_payload, credential: nil) assert_equal 401, response.status assert_equal 0, Article.count # no side effectSubmission criterion. The project is done — and submittable — when all five hold in a
single test run:enandpt-BRare 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 with401and 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.