Torne o Conteúdo Bilíngue (i18n)
Adicione traduções por locale a um modelo de conteúdo existente usando um backend em container JSONB, com fallback, backfill do conteúdo legado e um slug estável não traduzível. Prove zero N+1 ao ler traduções em coleção.
Faça fork ou clone (Ruby/Rails, Python/FastAPI ou TypeScript) e faça os testes que falham passarem.
Problema
Sua plataforma tem um modelo de conteúdo — chame de `Article` — com `title`, `summary` e `body` guardados como colunas de texto puro. Ele nasceu monolíngue. Agora o produto precisa servir o mesmo artigo em mais de um idioma (digamos `en` e `pt-BR`), mostrando a cada leitor o texto do locale **dele**. A solução ingênua — duplicar o registro inteiro por idioma — quebra rápido: ela bifurca suas chaves primárias, seus slugs, suas associações e sua analítica. Um artigo traduzido continua sendo **um** artigo; apenas alguns campos mudam por idioma. Neste lab você vai adaptar um modelo existente para ter traduções sem reescrevê-lo. Você vai: - Guardar traduções numa única coluna JSONB `translations` no formato `{ "<locale>": { "<campo>": valor } }`, indexada para busca. - Declarar exatamente quais atributos traduzem (`title`, `summary`, `body`) e resolvê-los pelo locale corrente através do acessor comum (`article.title`). - Fazer backfill do texto monolíngue existente para o locale de origem, sem perder nada. - Configurar uma cadeia de fallback para que o leitor **nunca** veja campo vazio quando falta uma tradução. - Manter o `slug` **fora** das traduções — ele continua uma coluna física, única, gerada uma única vez. Essa é a pegadinha que afunda a maioria das primeiras tentativas. - Deixar editores autorarem um locale por vez sem apagar o outro. A pegadinha que você deve respeitar o tempo todo: **a URL não pode mudar quando o idioma muda.** Um slug que varia por locale quebra silenciosamente toda rota, favorito, entrada de sitemap e link de entrada que você tem.
Objetivos
- Adicionar uma coluna JSONB
translations(com índice GIN) a um modelo de conteúdo
existente e fazer backfill do texto atual para o locale de origem. - Declarar os atributos traduzíveis e lê-los/gravá-los pelo locale corrente através dos
acessores comuns. - Configurar uma cadeia de fallback (
en<->pt-BR) para que uma tradução ausente
nunca renderize como campo vazio. - Manter o
sluguma coluna física, única, não traduzível, gerada uma vez a partir do
título no locale de criação — e explicar por que traduzi-lo quebra as URLs. - Autorar cada locale de forma independente: salvar um idioma não pode apagar o outro.
- Provar com testes que gravações
en/pt-BRseparadas coexistem, que o fallback
preenche lacunas e que ler traduções em coleção dispara zero queries extras (sem N+1).
Pré-requisitos
- Um app existente com pelo menos um modelo de conteúdo (ex.:
Article) que tenha
campos de texto puro, na linguagem/framework de sua escolha. - Um banco relacional com suporte a JSONB (PostgreSQL, ou um tipo de coluna JSON
equivalente) e a capacidade de adicionar um índice GIN/funcional. - Um mecanismo de locale/i18n que exponha um "locale corrente" configurável por
requisição (ex.: a partir de?locale=, de um headerAccept-Languageou da sessão). - Um runner de testes capaz de contar queries SQL (para asseverar a ausência de N+1).
- Capacidade de escrever e rodar uma migração de schema e um backfill de dados contra seu
banco de desenvolvimento.
Tradução é um problema de armazenamento e resolução, não de redação. Você precisa de
um lugar para guardar N versões de um campo, uma regra que escolhe a versão certa para o
leitor atual e um fallback para quando a versão certa ainda não existe.
Este lab usa o backend em container: uma coluna JSONB por registro guarda todas as
traduções daquele registro, no formato { "<locale>": { "<campo>": valor } }. Como o
JSON viaja dentro da própria linha, carregar o registro carrega junto todas as traduções
— sem join, sem query de acompanhamento por registro, sem N+1.
articles.translations =
{
"en": { "title": "Getting Started", "summary": "...", "body": "..." },
"pt-BR": { "title": "Primeiros Passos", "summary": "...", "body": "..." }
}
A principal alternativa é uma tabela de traduções separada (uma linha por
registro × locale × campo, no estilo chave/valor). Ela é mais normalizada e permite
consultar ou indexar traduções individuais no nível do banco, mas reintroduz o join e o
risco de N+1 que você acabou de remover, e complica as gravações. Para um conjunto
limitado de locales lidos junto com o registro — o caso comum de CMS — o container JSONB
vence em simplicidade e performance de leitura. Recorra à tabela separada quando os
locales são muitos, esparsos ou precisam ser consultados de forma independente.
Dois invariantes seguram tudo, e você vai implementar os dois explicitamente:
-
O slug não é uma tradução. Ele é identidade, não conteúdo. Gere uma vez, guarde
fisicamente, mantenha único. Se mudasse por locale, o mesmo artigo responderia a URLs
diferentes e todo link existente apodreceria. -
Gravar um locale não pode tocar em outro. Os formulários de edição devem ler o
valor cru do locale ativo (fallback DESLIGADO), para que salvar nunca copie um
fallback para dentro de um locale que não tinha nenhum.
Faça os seis passos em ordem. Cada um se apoia no anterior, e o último define exatamente o
que significa "pronto".
Passos
-
Migração: coluna JSONB translations + índice GIN + backfill
Adicione a coluna container, indexe-a e mova o texto monolíngue existente para o
locale de origem, sem perder conteúdo.Escolha o locale de origem que corresponde aos seus dados atuais (este lab assume
pt-BR— o idioma em que as linhas legadas foram escritas).-- 1. Adiciona o container JSONB, com objeto vazio como padrão. ALTER TABLE articles ADD COLUMN translations JSONB NOT NULL DEFAULT '{}'::jsonb; -- 2. Indexa para que buscas dentro do JSON sejam rápidas. CREATE INDEX index_articles_on_translations ON articles USING GIN (translations); -- 3. Backfill: dobra cada coluna de texto legada dentro do locale de origem. UPDATE articles SET translations = jsonb_build_object( 'pt-BR', jsonb_strip_nulls(jsonb_build_object( 'title', title, 'summary', summary, 'body', body )) ) WHERE translations = '{}'::jsonb;Assim que os dados passam a viver em
translations, as colunas de texto legadas
deixam de ser a fonte da verdade. Torne-as nullable para que novos registros não sejam
forçados a gravar em dobro, mas não as remova ainda — mantenha por um release como
rede de segurança para comparar.ALTER TABLE articles ALTER COLUMN title DROP NOT NULL, ALTER COLUMN summary DROP NOT NULL, ALTER COLUMN body DROP NOT NULL;Torne a migração reversível (dropar índice e coluna no rollback) e verifique o
backfill: toda linha que tinha um título deve agora ter
translations->'pt-BR'->>'title'preenchido. -
Declare os campos traduzíveis e resolva por locale
Diga ao modelo quais atributos são traduzidos e roteie seus acessores através do
locale corrente, lendo e gravando no container JSONB.Declare o conjunto explicitamente — só
title,summaryebodytraduzem;slug,
timestamps e chaves estrangeiras não.# pseudocódigo class Article translates :title, :summary, :body # backend: :jsonb, coluna: :translations # O leitor resolve contra o locale corrente: # article.title -> translations[Current.locale]["title"] # O escritor mira apenas o locale corrente: # article.title = x -> translations[Current.locale]["title"] = x endSe seu framework tem uma biblioteca de i18n/traduções madura (ex.: um plugin de
backend container/JSONB), apoie-se nela em vez de fazer à mão. Se não, o acessor é um
wrapper fino:def read_translated(field) locale = Current.locale (translations[locale] || {})[field] end def write_translated(field, value) self.translations = translations.merge( locale => (translations[Current.locale] || {}).merge(field => value) ) endVerifique na mão: defina o locale como
pt-BR, leiaarticle.titlee confirme que
recebe o título em português do backfill; mude paraene confirme que recebe
nil/vazio por ora (você ainda não tem inglês — o próximo passo corrige a exibição
dessa lacuna). -
Configure fallback para um campo nunca ficar vazio
Um leitor em
enolhando um artigo que só tem texto empt-BRainda precisa ver
alguma coisa — o português, não um vazio. Configure uma cadeia de fallback para que
o leitor resolva para o próximo melhor locale quando o dele falta.# pseudocódigo — os fallbacks são bidirecionais aqui i18n.fallbacks = { "en" => ["en", "pt-BR"], # en ausente -> tenta pt-BR "pt-BR" => ["pt-BR", "en"] # pt-BR ausente -> tenta en }Agora o acessor percorre a cadeia em vez de retornar no primeiro erro:
def read_translated(field) fallback_chain(Current.locale).each do |locale| value = (translations[locale] || {})[field] return value if value.present? end nil endDuas regras impedem que o fallback cause surpresas:
- Fallback é uma preocupação de tempo de leitura, só de exibição. Nunca deve ser
persistido — o JSON guardado paraencontinua vazio até alguém de fato escrever
inglês. - Trate vazio (
"") como ausente, para que uma string vazia não obscureça um bom
valor de fallback.
Verifique: com apenas
pt-BRpreenchido, lerarticle.titlesob o localeenagora
retorna o título em português. Adicione um título em inglês e a mesma leitura retorna
o inglês — o fallback cede assim que a tradução real existe. - Fallback é uma preocupação de tempo de leitura, só de exibição. Nunca deve ser
-
Mantenha o slug estável e físico (a pegadinha crítica)
Este é o passo que as pessoas erram. É tentador tornar
slugtraduzível para a URL
ficar bonita em cada idioma. Não faça. O slug é a identidade pública do registro;
precisa ser um único valor, estável em todos os locales.Por que quebra se você traduzi-lo:
- O mesmo artigo resolveria para
/articles/getting-startedem inglês e
/articles/primeiros-passosem português — duas URLs para um recurso. - Todo favorito, entrada de sitemap, tag canônica e link de entrada aponta para
exatamente uma delas; trocar de locale daria 404 ou mostraria a coisa errada em
silêncio. - A busca de rota por slug fica dependente de locale, então um link compartilhado
aberto sob outro locale não é encontrado de forma alguma.
Mantenha
sluguma coluna simples, única, indexada — não dentro de
translations. Gere uma vez na criação, a partir do título no locale em que o
registro foi escrito primeiro, e depois congele.# pseudocódigo before_create :assign_slug def assign_slug # lê o título no locale de criação, SEM fallback, # para o slug refletir o texto de origem real source = read_raw(:title, Current.locale) self.slug = ensure_unique(parameterize(source)) endNão regenere o slug quando uma tradução é adicionada ou o título é editado depois —
isso moveria a URL, que é exatamente o que estamos evitando. Se um humano realmente
precisar mudar um slug, trate como ação deliberada com um redirect a partir do antigo.Verifique: crie um artigo em
pt-BR, anote o slug, depois adicione uma traduçãoen
com título diferente. O slug deve permanecer inalterado, e o artigo deve continuar
resolvendo por esse único slug sob os dois locales. - O mesmo artigo resolveria para
-
Autoria por locale sem apagar o outro idioma
Editores trabalham um idioma por vez. O formulário deve salvar no locale ativo e
deixar todos os outros locales exatamente como estavam. O bug sutil aqui é o fallback
vazando para dentro das gravações.Selecione o locale de edição explicitamente — comumente
?locale=pt-BRna URL do
formulário — e defina-o para a requisição:# pseudocódigo — controller def edit Current.locale = params[:locale] || default_locale @article = Article.find_by!(slug: params[:slug]) endA pegadinha: se o formulário pré-preencher os campos usando o leitor com fallback,
um editor abrindo o formulárioenvazio veria o textopt-BR(via fallback), e
salvar copiaria esse português para dentro deen— corrompendo em silêncio o sinal
de "tradução ausente".Então o formulário deve pré-preencher a partir do valor cru do locale ativo, com
fallback DESLIGADO:# pseudocódigo — valor do formulário value = article.read_raw(:title, Current.locale) # sem fallback no formulárioAo salvar, grave apenas os campos do locale ativo; faça merge, nunca substitua, o
JSON:# pseudocódigo — update Current.locale = params[:locale] article.title = form[:title] # grava em translations[locale] article.summary = form[:summary] article.body = form[:body] article.save # os outros locales em translations ficam intactosVerifique: preencha o formulário
pt-BR, salve; abra o formulárioen(deve estar em
branco, não pré-preenchido com português), preencha e salve; recarregue e confirme que
os dois locales agora têm seu próprio texto distinto e que nenhum sobrescreveu o outro. -
Testes: gravações separadas, fallback, slug estável e zero N+1
Trave o comportamento com testes. Quatro propriedades importam, e a prova de zero N+1
é a que justifica a escolha do JSONB.1. Gravações separadas por locale coexistem.
with_locale("pt-BR") { article.update!(title: "Primeiros Passos") } with_locale("en") { article.update!(title: "Getting Started") } assert_equal "Primeiros Passos", with_locale("pt-BR") { article.reload.title } assert_equal "Getting Started", with_locale("en") { article.reload.title }2. O fallback preenche uma lacuna e não é persistido.
article = create_article_with(only: { "pt-BR" => { title: "Só PT" } }) assert_equal "Só PT", with_locale("en") { article.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") { create_article(title: "Primeiros Passos") } slug_original = article.slug with_locale("en") { article.update!(title: "Getting Started") } assert_equal slug_original, article.reload.slug4. Zero N+1 ao ler traduções em coleção.
Carregue um lote de artigos, leia um campo traduzido em cada um e asseve que a
contagem de queries fica plana — com o container JSONB as traduções vieram junto com
as linhas.create_list_of_articles(10) assert_queries(1) do Article.all.each { |a| a.title } # sem query extra por registro endCritério de submissão: um modelo de conteúdo traduzível onde as quatro
propriedades valem —enept-BRsão escritos e lidos de forma independente, o
fallback impede qualquer campo vazio sem ser persistido, o slug é gerado uma vez e
nunca muda quando uma tradução é adicionada, e ler um campo traduzido numa coleção de N
registros não dispara query por registro (contagem de queries plana, independente de
N). Quando esses testes passam, o lab está pronto.