In Finanz c'è un file che si chiama it.json. Oggi pesa zero byte.
Fino a poco tempo fa conteneva tutte le stringhe dell'app. Era il sistema di traduzione: un file, compilato nella build, letto da easy_localization.
Funzionava. Con un difetto solo.
Cambiare una parola significava una build. Una review dello store. Un rollout. Per una parola.
Aggiungere una lingua era lo stesso lavoro, moltiplicato per il numero di stringhe.
Quel file vuoto è la ricevuta della migrazione. Le stringhe non sono più nel repository, e la cartella che le conteneva non è nemmeno più dichiarata negli asset della build.
Non l'ho fatto per supportare più lingue. L'ho fatto per staccare il rilascio del testo dal rilascio dell'app.
La lingua è stata l'occasione. La metrica vera è il tempo tra "questa label è sbagliata" e "questa label è corretta in mano agli utenti". Prima era la review dello store. Ora è una scrittura sul backend.
Qui sotto ci sono le cinque decisioni che hanno determinato tutto il resto, e il conto che ho pagato.
Prima decisione: lingua e mercato sono due assi diversi
È la scelta che regge tutto, ed è quella che si sbaglia più spesso.
Le traduzioni della UI sono indicizzate solo per codice lingua. Non per la coppia mercato più lingua.
Un utente in Spagna e uno in Argentina scaricano lo stesso identico file di traduzione. Il mercato non c'entra con le label.
Il mercato viaggia su un canale completamente diverso: un header Accept-Language iniettato da un interceptor su tutte le altre chiamate, quelle dei contenuti.
Due assi ortogonali, due trasporti separati. La lingua sta nel path dell'endpoint traduzioni. Il mercato sta in un header su tutto il resto.
Il mercato differenzia i contenuti. La lingua differenzia le label.
Se li unisci, il numero di file di traduzione diventa lingue per mercati, e da lì non torni indietro: ogni label corretta va corretta N volte, e il problema che volevi risolvere ricompare moltiplicato.
Un dettaglio che costa un pomeriggio se non ci pensi prima: gli endpoint che servono a scoprire mercati, lingue e traduzioni vanno esclusi da quell'interceptor. Girano prima dell'onboarding, quando una preferenza da mandare non esiste ancora. Se non li escludi, il sistema si morde la coda al primo avvio.
Seconda decisione: il check degli aggiornamenti è la richiesta stessa
Il riflesso è costruire due endpoint. Uno che ti dice se ci sono aggiornamenti, uno che te li dà. Due chiamate, due contratti da tenere allineati, due modi di andare fuori sincrono.
Ne ho fatto uno solo.
GET /app/translation/{languageCode}/
body: { "from_dt": "<iso8601>" }
200 → { "updated_at": "<iso8601>", "translation": { ... } }
204 → nessun body. La cache locale è fresca.
errore rete → fallback su cache
from_dt è l'updated_at che il server ha restituito la volta precedente, salvato dal client accanto alla cache. Non un hash. Non un numero di versione. Un timestamp deciso dal server, che il client conserva e rispedisce senza interpretarlo.
Il 204 fa il lavoro pesante. Zero byte di risposta quando non c'è niente di nuovo, che in produzione è il caso normale.
Il 200 restituisce il file intero, non un diff. È una scelta esplicita: un diff richiede un merge lato client e uno stato di versione da mantenere corretto nel tempo. Il file intero si sostituisce in blocco, e la sostituzione in blocco non ha casi limite.
Nota che è una GET con un body. Funziona, ma è terreno minato: nella stessa app un endpoint il body su GET lo ignora e un altro lo pretende, rispondendo 422 se gli mandi query params. Stesso backend, comportamento opposto. Va verificato caso per caso, mai assunto per convenzione.
Terza decisione: la cache non scade a tempo
Le traduzioni scaricate finiscono su disco, un file per lingua più un file di metadati con il timestamp dell'ultimo aggiornamento. Sopra al disco c'è un secondo livello in memoria.
Non nello storage chiave-valore delle preferenze, perché il payload è troppo grosso. Non in un database locale, perché non c'è niente da interrogare: è un blob che si legge intero.
Nessun TTL. La cache resta valida finché il server non risponde 200. Si invalida in quattro casi soltanto:
- il server restituisce un payload nuovo
- l'utente cambia lingua, e le lingue non attive vengono droppate
- logout
- ambiente di sviluppo, che fa wipe a ogni avvio
Poi esiste un quinto caso che non è un'invalidazione ma uno stato incoerente: 204 con la cache vuota. Il server dice "ce l'hai già" e il client non ce l'ha.
Lì non ho messo un fallback, ho messo un errore esplicito. Non esiste una risposta ragionevole a quel punto, e mascherarla significa scoprire il problema più tardi e lontano dalla causa.
In una riga
Una cache senza TTL non è una cache pigra: è una cache di cui decide il server. Il client non prova a indovinare se il dato è vecchio — glielo dicono, con un 204.
Il loader l'ho scritto a mano. In pubspec.yaml c'era la libreria che avrebbe dovuto evitarmelo, e non è mai stata importata: è rimasta lì come dipendenza morta.
Il motivo non è ideologico. I loader pronti sanno fare "scarica un JSON e tienilo in cache". Nessuno di loro conosce il protocollo di freschezza sopra, perché è specifico del nostro backend.
E c'è una conseguenza che non avevo previsto. Il metodo di traduzione standard restituisce solo stringhe. Ma quando le traduzioni diventano un payload remoto, dentro quel file finiscono anche liste e mappe: i passi dell'onboarding, gli elenchi di feature. Contenuto che prima sarebbe stato codice.
Sposti le traduzioni sul server e smettono di essere stringhe. Diventano contenuto. Il layer di localizzazione che hai in mano non è progettato per il contenuto, e te ne accorgi quando ti serve leggere una lista.
Quarta decisione: se le traduzioni non arrivano, l'app parte lo stesso
Finanz senza rete non fa niente. È un'app che vive di dati remoti, e non ho mai finto il contrario.
Quindi il caso da gestire non è l'utente offline. È l'utente con la connessione che funziona e la chiamata alle traduzioni che no: endpoint lento oltre il timeout, 5xx, un deploy in corso, una prima installazione su una linea che va e viene.
In quello scenario l'utente legge la chiave della traduzione a schermo, in font di sistema, in mezzo al design.
È una scelta. Il caricamento fallisce in silenzio e la libreria renderizza le chiavi raw, perché una chiave raw è meglio di un crash a bootstrap. Il boot prosegue, la prossima apertura riprova.
Brutale, ma diagnostico. Una chiave raw a schermo significa una cosa sola e la leggi senza aprire i log.
C'è un dettaglio che rende questo comportamento affidabile invece che casuale: al bootstrap precarico due lingue, quella risolta e l'inglese.
Il fallback locale non è magia. Cade sull'inglese solo se le traduzioni inglesi sono già caricate. Senza quel preload, una chiave mancante nella lingua attiva non ha dove cadere e finisce raw.
Il fallback per chiave esiste solo se hai pagato in anticipo il download della lingua di fallback.
Vale la pena notare che le catene sono due e sono distinte. La lingua di avvio si risolve in modo sincrono leggendo solo le preferenze locali, perché deve essere pronta prima di qualsiasi chiamata. Le traduzioni si risolvono in rete, con la cache come rete di sicurezza. Confonderle significa bloccare il primo frame in attesa di un endpoint.
Quinta decisione: la logica di risoluzione sta sul backend
La risoluzione iniziale di mercato e lingua è una chiamata sola, e gli input non sono quelli che ci si aspetta.
Non IP. Non country dello store. Non un campo sul profilo.
Il client manda l'offset UTC del device e la lingua grezza del device, e riceve un verdetto. Timeout 5 secondi, oltre il quale si cade su inglese.
Nel codice ci sono due commenti che dicono la stessa cosa in punti diversi: la business logic di matching e fallback risiede interamente sul backend, e nessuna euristica sulla lingua del device vive nel client.
Il motivo è banale. Ogni euristica client è una regola che poi cambi con una build. Cioè il problema da cui ero partito.
La selezione manuale è a cascata: prima il mercato, poi il picker delle lingue si popola in base al mercato scelto. Le combinazioni legali le decide il backend. Se la tua lingua non c'è, non c'è, e un link apre un dialog "stiamo arrivando". La mancanza è un fatto di prodotto, non un caso da gestire in codice.
Poi c'è il campo più importante di tutto il sistema, e ha due soli valori: automatico o manuale.
Se l'utente ha scelto a mano, non si ri-risolve mai più. Lo scenario ha un nome in codice: "it in Spagna". Un italiano in vacanza a Barcellona che si è messo l'italiano a mano non deve ritrovarsi lo spagnolo al riavvio, per quanto sensata sembri la risoluzione automatica.
Un'ultima cosa, che ho invertito a metà strada. Al login, se la lingua locale e quella dell'account divergono, vince quella locale e il client allinea il backend.
All'inizio era il contrario: vinceva l'account, e l'app faceva un restart nella lingua del profilo. L'ho rimosso insieme a tutta la macchina di restart che serviva a reggerlo. La direzione della verità è una decisione di prodotto, non di architettura, e quando cambia si porta via parecchio codice.
Le immagini vanno al contrario, di proposito
Non solo le stringhe sono localizzate. Ci sono asset con il testo cotto dentro: sette immagini tra onboarding e feature, più un'animazione.
Quelli sono compilati nella build. L'opposto esatto delle stringhe.
Una classe base risolve tutto su una cartella comune, e tre derivate per lingua fanno override solo degli asset che hanno parole dentro. Il fallback qui è per singolo asset, non per lingua: non esiste una cartella italiana completa, esiste una cartella italiana che copre le sette immagini che ne hanno bisogno.
Due strategie opposte per due tipi di contenuto localizzato nella stessa app. Il riflesso sarebbe uniformare, ed è sbagliato.
Le immagini pesano e non cambiano mai. Il testo è leggero e cambia in continuazione. Il criterio non è la coerenza dell'architettura, è il rapporto tra peso e frequenza di modifica.
Tre cose che si sono rotte
La lingua che non cambiava. La libreria di localizzazione tiene la locale salvata in una variabile statica che sopravvive alla ricostruzione dell'albero dei widget. Cambio lingua, restart soft, e il costruttore rilegge la statica in memoria ignorando la lingua appena calcolata.
Sintomo: cambi lingua, l'app riparte, è ancora nella lingua di prima. Chiudi e riapri da cold start, adesso è giusta. Il tipo di bug che ti fa dubitare della tua persistenza quando il problema è a monte.
Il fix è cancellare esplicitamente la locale salvata prima del restart. È documentato con un commento lungo in due punti diversi, perché è il genere di riga che qualcuno toglie pensando sia ridondante.
La lezione è più larga del bug: avevo spostato le traduzioni sul server, ma il ciclo di vita del locale era ancora dentro una libreria che assume asset compilati. Il mismatch non era nella rete. Era nelle assunzioni della dipendenza.
Il 422 fantasma. Endpoint chiamato senza slash finale, il backend Flask risponde 308, il client HTTP segue il redirect e nel farlo perde body e Content-Type, e la richiesta ridiretta torna 422.
Sintomo: 422 su un endpoint che in Postman funziona perfettamente.
/// Trailing slash obbligatorio: il backend Flask risponde altrimenti con
/// 308 redirect, e nel redirect Dio perde body / Content-Type → 422.
Il secondo fix sulla stessa linea è il Content-Type: application/json esplicito, perché il backend valida lo schema del body anche su una GET.
Il flash post-logout. Al logout la lingua torna a quella del mercato di default. Tra il logout e l'applicazione della nuova lingua il router si ricostruiva, e per uno o due frame mostrava la schermata iniziale nella lingua precedente.
La soluzione non è stata sistemare i tempi. È stata un overlay sfocato, alzato in modo sincrono prima che il router ricostruisca e abbassato in un finally così da non restare mai bloccato.
Lo dico senza abbellirlo: un sistema in cui la lingua è remota ha finestre in cui la lingua è in transizione. A volte la risposta corretta è coprire la transizione, non eliminarla.
Cosa ho pagato
Il primo frame dipende da una catena di chiamate. L'app richiede comunque la rete, quindi la dipendenza in sé non è il costo. Il costo è quante chiamate stanno tra l'avvio e il primo testo renderizzato: mercati, poi le lingue di ogni mercato in parallelo, poi la risoluzione con timeout 5s, poi la lingua attiva, poi l'inglese. Con timeout di 10s sui loader, il caso peggiore somma 5 + 10 + 10.
Le stringhe non sono più nel repository. Non fai grep di una label per trovare dove vive. Non bisechi una regressione di copy. Il diff di una traduzione non passa da una pull request, non ha review e non ha blame. Ho comprato velocità di rilascio pagandola in tracciabilità.
Il bootstrap è diventato logica. L'inizializzazione è una sequenza in cui l'ordine conta e ogni riga ha un motivo scritto sopra. Le preferenze prima della localizzazione, perché l'interceptor le legge. La persistenza prima dello splash, per non risolvere due volte. L'inglese dopo la lingua attiva, perché il fallback per chiave lo richiede. È un file che non si riordina.
In sviluppo non testo mai la cache. L'ambiente di dev pulisce tutto a ogni avvio. Il percorso 204 e il cache-hit si vedono solo in staging e in produzione. Il path più frequente per gli utenti è quello meno esercitato da chi lo scrive.
La stessa invariante vive in tre punti del boot. La regola "se l'utente ha scelto a mano non si ri-risolve" è replicata in tre file, tenuti insieme da commenti incrociati. Non è elegante. È un promemoria che lì sotto va estratto qualcosa.
I test sono accoppiati al trasporto. Il modo in cui passi il body è accoppiato al modo in cui i test lo simulano. Cambiarlo non produce un fallimento leggibile, produce uno stub mancante che maschera la causa vera.
Quello che non c'è ancora
Il layer dei formati non è incompleto. Non esiste.
Zero gestione delle valute nel client. Formattatori numerici istanziati senza locale, quindi usano quella di default della libreria e non quella dell'utente. Un formato data con la locale italiana scritta a mano, che significa che un utente spagnolo legge il nome del mese in italiano. Un pattern data europeo cablato in un'altra schermata.
Il sistema che ho descritto risolve il testo. Numeri, date e valute sono al punto di partenza, e si vede in produzione.
Lo scrivo perché è la parte che di solito sparisce dagli articoli di architettura: un sistema reale è sempre anche la lista di quello che non è stato ancora fatto.
Quando è over-engineering
App mono-mercato, poche decine di label, un solo team che scrive il testo e spedisce l'app.
Lì i file compilati vincono su tutta la linea. Zero rete al boot. Stringhe cercabili con un grep. Chiavi verificate dal compilatore. Una regressione di copy che trovi con un bisect.
Il discrimine non è quante lingue servi. È chi tocca il testo.
Se il copy lo cambia una persona che non passa da te e non può aspettare la review dello store, il sistema si ripaga la prima settimana.
Se il copy lo scrivi tu nello stesso commit del widget, stai pagando un boot dipendente dalla rete e un bootstrap fragile in cambio di niente.
Il principio
Spostare le traduzioni sul server non è una feature di internazionalizzazione. È una decisione su chi può cambiare cosa senza chiedere il permesso a una build.
Nel momento in cui il testo smette di essere codice, perde la review, il blame e il grep che il codice ti dà. Quel prezzo si paga sempre.
Ha senso pagarlo solo se in cambio compri l'autonomia di qualcuno che oggi aspetta te.
Takeaway
"Le traduzioni sul server non le misuri in lingue supportate. Le misuri in quante persone possono correggere una parola senza passare da te."
— Savino Fiore, Tech Lead @ Finanz