Validazione indirizzi in e-commerce: costruire un servizio che funziona davvero
Gli indirizzi inseriti dai clienti sono caotici: abbreviazioni, CAP ambigui, nomi città con apostrofi, formati diversi per ogni paese. Ho costruito un servizio di validazione da zero. Ecco le sfide reali.
Gli indirizzi che arrivano dagli ordini e-commerce sono un disastro. “VIA ROMA, 1”, “Via roma1”, “v.roma 1”, “Viaroma 1” sono tutti lo stesso indirizzo — ma nessun sistema di fulfillment lo sa. Se l’indirizzo non corrisponde esattamente a quello atteso dal corriere, il pacco torna indietro.
Ho costruito un microservizio di validazione indirizzi per un sistema di fulfillment che gestisce ordini da più marketplace su più paesi europei. Questo articolo racconta le scelte architetturali e i problemi non ovvi che ho incontrato lungo la strada.
Cosa fa il servizio
Il servizio espone tre endpoint distinti:
POST /validate-address— normalizza e valida un indirizzo. Input: addressLine1, city, zipCode, provinceCode, countryCode. Output: indirizzo formattato + coordinate geografiche.POST /validate-customer— normalizza nome, telefono e email del destinatario, con regole specifiche per paese.POST /suggest-address— dato un indirizzo parziale o impreciso, restituisce suggerimenti da AWS Location Service e Google Maps.
Sono tre Lambda indipendenti, ognuna con le proprie dipendenze e il proprio throttling. Deployarle separate permette di scalare e aggiornare ogni endpoint senza toccare gli altri.
Il flusso di validazione
Il percorso di un indirizzo attraverso /validate-address passa per quattro fasi:
1. Sanitizzazione — rimuove ridondanze, normalizza il formato, compatta le abbreviazioni stradali.
2. Validazione — controlla che i campi rispettino le regole: addressLine1 tra 6 e 100 caratteri, deve contenere un numero civico, zipCode nel formato corretto per il paese.
3. Geocodifica — chiama un servizio esterno con CAP e codice paese, ottiene la lista di località associate.
4. Formattazione — sceglie la città corretta dalla lista, normalizza province code e coordinate geografiche.
Output:
{
"addressLine1": "VIA ROMA 1",
"city": "ROMA",
"zipCode": "00186",
"provinceCode": "RM",
"countryCode": "IT",
"geo": { "lat": 41.8919, "lng": 12.5113 }
}
Il problema delle abbreviazioni stradali
Il primo problema che ho trovato: lo stesso indirizzo arriva scritto in decine di modi diversi. “Via”, “V.”, “VIA”, “via.” sono tutti la stessa cosa. “Piazza”, “P.zza”, “P.za”, “PIAZZA” anche.
Ho costruito un metodo compactAddressLine() con oltre 100 pattern per normalizzare:
// PIAZZA → P.ZA
// VIA → V. (ma "VIALE" → "V.LE")
// STRADA PROVINCIALE 123 → SP123
// PIANO PRIMO → P.1
// INTERNO CINQUE → INT.5
// FRAZIONE → FRAZ.
L’obiettivo non è rendere l’indirizzo leggibile — è renderlo consistente. Se il corriere ha in database “V. ROMA 1” e tu mandi “Via Roma, 1”, la bolla di spedizione viene rifiutata.
Una sottigliezza: i numeri decimali nei civici. “Via Roma 1.5” in Italia non esiste, ma arriva dagli ordini Amazon. Viene normalizzato a “VIA ROMA 1 5” (spazio al posto del punto) per non far saltare le validazioni del corriere.
CAP ambigui: quando uno zipCode mappa a più città
Questo è il problema più sottile. Un CAP non identifica univocamente una città — specialmente nelle aree metropolitane. Il CAP 20100 di Milano copre più comuni. Il servizio di geocodifica restituisce una lista di possibili corrispondenze.
Devo scegliere quella giusta:
private formatAddress(address: Address, places: Place[]): Address {
if (places.length === 1) {
return { ...address, city: places[0].city, provinceCode: places[0].province_code };
}
// Normalizzo il nome città in input e cerco un match
const normalizedInput = this.normalizeCityName(address.city);
const match = places.find(
p => this.normalizeCityName(p.city) === normalizedInput
);
if (match) {
return { ...address, city: match.city, provinceCode: match.province_code };
}
// Nessun match esatto — verifico se tutti i comuni hanno lo stesso province code
const provinceCodes = places.map(p => p.province_code);
const safeProvinceCode = provinceCodes.every(p => p === provinceCodes[0])
? provinceCodes[0]
: address.provinceCode; // fallback: usa quello inserito dall'utente
return { ...address, city: places[0].city, provinceCode: safeProvinceCode };
}
La normalizzazione del nome città gestisce apostrofi e punteggiatura:
private normalizeCityName(city: string): string {
return removeSpecialChar(city)
.trim()
.toLowerCase()
.replace(/['\s.-]+/g, '-');
}
// "Sant'Agata di Militello" → "sant-agata-di-militello"
// "Reggio d'Emilia" → "reggio-d-emilia"
In questo modo “SANT AGATA DI MILITELLO”, “Sant’Agata di Militello” e “sant-agata-di-militello” matchano tutti contro lo stesso record.
Regole per paese
Ogni paese ha le sue peculiarità. Ne ho incontrate alcune che non mi aspettavo.
Italia — zipCode deve essere esattamente 5 cifre. Province code deve essere 2 caratteri. Se mancano, l’ordine si blocca al corriere.
Irlanda (Eircode) — il codice postale irlandese ha un formato unico: 7 caratteri alfanumerici (es. D02XY45). Non uso il servizio di geocodifica standard per l’Irlanda — l’Eircode già identifica univocamente una zona e richiede una gestione dedicata.
Paesi Bassi — zipCode nel formato olandese è 4 cifre + 2 lettere (es. 1234AB). Il sistema interno conserva solo le 4 cifre numeriche.
Portogallo — formato XXXX-XXX. Gli ordini arrivano spesso senza il trattino; lo aggiungo in fase di normalizzazione.
Francia — il numero di telefono del destinatario è obbligatorio. Gli altri paesi no. La validazione del cliente controlla questo:
const phoneRequiredCountries = ['FR'];
if (phoneRequiredCountries.includes(countryCode) && !customer.phone) {
throw new CustomerInvalidError('Phone is required for FR');
}
Suggerimenti da provider multipli
L’endpoint /suggest-address serve un caso diverso: l’operatore ha un indirizzo che non supera la validazione e vuole vedere alternative. Chiama AWS Location Service e Google Maps e restituisce entrambe le risposte.
Un provider che fallisce non deve bloccare la risposta. Se AWS Location è down, voglio comunque il risultato di Google Maps:
private async searchAddress(params: SearchParams): Promise<string> {
try {
return await params.addressClient.searchAddress(params.addressLine);
} catch (error) {
return error instanceof Error ? error.message : 'Address search failed';
}
}
async suggestAddress(address: Address): Promise<AddressSuggestion> {
const [aws, google] = await Promise.all([
this.searchAddress({ addressClient: this.awsAddressClient, addressLine }),
this.searchAddress({ addressClient: this.googleAddressClient, addressLine }),
]);
return { aws, google };
}
Il client riceve sempre un oggetto {aws, google} — anche se uno dei due contiene un messaggio di errore invece di un indirizzo. Questo permette all’interfaccia di mostrare il risultato disponibile senza gestire stati di errore complessi.
Il servizio come package condiviso
Una scelta architetturale che si è rivelata utile: il service layer dell’app è esportato come package npm interno, non solo come API HTTP.
Il sistema di fulfillment importa direttamente i servizi tramite il package interno del monorepo:
import { AddressService, CustomerService, SuggestionService }
from 'address-validation/services';
Questo elimina un hop di rete per le operazioni critiche sul path degli ordini. Quando un ordine arriva, la validazione dell’indirizzo avviene inline — nessuna chiamata HTTP a un’altra Lambda. Solo se la validazione fallisce e si vuole proporre un suggerimento all’operatore si usa l’endpoint REST.
La logica di validazione è in un posto solo e può essere usata sia via API che inline. Se cambia una regola (es. un nuovo paese), la modifica si propaga ovunque automaticamente.
Quello che rifarei
Suggerimento sequenziale invece di parallelo — nella prima versione chiamavo AWS Location e Google Maps in sequenza. La latenza era la somma delle due. Con Promise.all si riduce alla più lenta delle due. L’ho corretto, ma avrei dovuto farlo da subito.
Nessun circuit breaker — se il servizio di geocodifica è lento o irraggiungibile, ogni richiesta aspetta il timeout prima di fallire. Un circuit breaker che smette di provare dopo N fallimenti consecutivi ridurrebbe la latenza percepita durante un’outage.
Rate limiting assente — l’API Gateway ha un throttle a 50 req/s, ma non c’è protezione granulare per IP. Un cliente con indirizzi malformati può consumare tutta la quota di geocodifica.
La validazione degli indirizzi sembra un problema risolto — ci sono servizi pronti come Loqate o SmartyStreets. Ma integrare un servizio esterno a pagamento con le specificità del proprio dominio (abbreviazioni stradali locali, regole per corriere, formati CAP interni) richiede comunque un layer di adattamento. In quel caso, costruirlo da zero ti dà controllo completo e zero dipendenze esterne a pagamento.