Platform · 90 min

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.

Baixar o projeto inicial

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

Pré-requisitos

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:

  1. 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.
  2. 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

  1. 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.

  2. 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, summary e body traduzem; 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
    end
    

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

    Verifique na mão: defina o locale como pt-BR, leia article.title e confirme que
    recebe o título em português do backfill; mude para en e confirme que recebe
    nil/vazio por ora (você ainda não tem inglês — o próximo passo corrige a exibição
    dessa lacuna).

  3. Configure fallback para um campo nunca ficar vazio

    Um leitor em en olhando um artigo que só tem texto em pt-BR ainda 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
    end
    

    Duas 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 para en continua 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-BR preenchido, ler article.title sob o locale en agora
    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.

  4. Mantenha o slug estável e físico (a pegadinha crítica)

    Este é o passo que as pessoas erram. É tentador tornar slug traduzí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-started em inglês e
      /articles/primeiros-passos em 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 slug uma 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))
    end
    

    Nã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ção en
    com título diferente. O slug deve permanecer inalterado, e o artigo deve continuar
    resolvendo por esse único slug sob os dois locales.

  5. 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-BR na 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])
    end
    

    A pegadinha: se o formulário pré-preencher os campos usando o leitor com fallback,
    um editor abrindo o formulário en vazio veria o texto pt-BR (via fallback), e
    salvar copiaria esse português para dentro de en — 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ário
    

    Ao 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 intactos
    

    Verifique: preencha o formulário pt-BR, salve; abra o formulário en (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.

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

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

    4. 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
    end
    

    Critério de submissão: um modelo de conteúdo traduzível onde as quatro
    propriedades valem — en e pt-BR sã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.