Lavorando in ambito bancario come Senior Software Engineer, la governance del codice rappresenta un requisito non negoziabile, dettato dai rigidi standard di qualità e compliance.
Quando ho iniziato a integrare strumenti di AI-assisted development nel workflow del team, i benefici erano evidenti: velocità, suggerimenti contestuali, generazione di boilerplate. Ma il nodo critico non era la qualità del codice generato — era la sua coerenza con gli standard del progetto.
Senza un contesto strutturato, l'AI tende a produrre soluzioni tecnicamente valide ma architetturalmente inconsistenti — pattern che divergono, responsabilità mal distribuite, convenzioni ignorate.
La sfida non era la generazione, ma la governance: come confinare l'AI entro i nostri standard invece di subirne l'anarchia operativa?
L'idea di partenza: le best practice non dovrebbero essere delegate all'AI, ma definite a livello architetturale. Da lì ho strutturato il progetto affinché i vincoli siano intrinseci, garantendo la conformità agli standard prima ancora che l'AI scriva la prima riga di codice.
Gli LLM operano su pattern statistici derivati da milioni di repository. La maggior parte del codice open-source non segue standard enterprise. Il risultato è che il codice generato tende a:
Il codice compila, i test passano, ma si accumula debito tecnico — debito che emerge mesi dopo, quando si scala o si avviano nuovi sviluppi.
Invece di correggere il codice generato a posteriori, abbiamo costruito un sistema dove l'AI opera dentro binari predefiniti: architettura imposta, validazione tipizzata, generator per il boilerplate, errori catalogati, agent specializzati per dominio.
In pratica, un parco giochi per gli agent — con regole ben definite.
L'approccio è stato iterativo: nell'arco di alcune settimane ogni vincolo è stato affinato in base ai pattern problematici che emergevano nelle review.
In un'applicazione React senza vincoli espliciti, è comune ricevere dall'AI componenti monolitici che fanno fetch dei dati, gestiscono stato locale, validano input e renderizzano UI. Non è un bug del modello — è l'output più probabile dati i repository su cui è stato addestrato.
L'app è stata separata in tre layer con dipendenze unidirezionali:
Service Layer (src/service/) — Business logic, chiamate API, query e mutation. Nessun import di componenti React.
Pages Layer (src/pages/) — Componenti UI e logica di orchestrazione. Può importare dal Service Layer, mai il contrario.
Store Layer (src/store/) — Stato UI puro: modali, drawer, selezioni temporanee. Il server state vive esclusivamente nella cache della libreria di data fetching, senza repliche nello store locale.
Con questa struttura, quando l'AI deve aggiungere una feature, sa esattamente dove posizionare ogni pezzo.
Ogni endpoint ha uno schema di validazione. I tipi sono sempre inferiti dallo schema, mai definiti manualmente.
// service/screens/products/constants/schemas.ts
export const ProductSchema = schema.object({
id: schema.string().uuid(),
name: schema.string().min(1),
price: schema.number().positive(),
})
export type Product = InferType<typeof ProductSchema>Ogni query valida la response prima di restituirla:
// service/screens/products/queries/queryProductList.ts
export const productListQueryConfig = defineQuery({
cacheKey: PRODUCTS_CACHE_KEYS.LIST(),
queryFn: async () => {
const response = await httpClient.get(PRODUCTS_URLS.list)
return schema.array(ProductSchema).parse(response.data)
},
})L'AI non può immaginare campi che non esistono nello schema. Se l'API response cambia e lo schema non viene aggiornato, il codice non compila.
L'approccio base — codice più messaggio — non scala. Abbiamo costruito un catalogo dove ogni errore ha uno schema che definisce il payload opzionale, una severity, e una mappatura a una chiave i18n.
const errorCatalog = {
VALIDATION_FAILED: {
shape: schema.object({
missingFields: schema.array(schema.string()).optional(),
}),
severity: "error",
},
RATE_LIMITED: {
shape: schema.object({ retryAfter: schema.number() }),
severity: "warning",
},
}Il tipo del catalogo forza che ogni codice errore mappi a una chiave di traduzione esistente — errori nuovi senza traduzione generano errore TypeScript a compile time.
Le cache key seguono lo stesso principio: factory tipizzate, non stringhe libere.
const PRODUCTS_LIST_QUERY = () => ["products", "list"] as const
export const PRODUCTS_CACHE_KEYS = { LIST: PRODUCTS_LIST_QUERY }Creare una nuova feature richiede decine di file. Senza automazione, ogni sviluppatore — o agent — potrebbe interpretare la struttura in modo diverso, o sprecare token generando boilerplate.
npm run gen:page # nuova page con struttura completa
npm run gen:feature # feature con service layer
npm run gen:service # solo service layerI file generati contengono import alias corretti e placeholder tipizzati che causano errori TypeScript finché non vengono sostituiti con l'implementazione reale — impossibile dimenticare qualcosa.
Il workflow cambia: invece di chiedere all'AI "crea una feature dashboard", si esegue il generator. L'AI parte da uno scaffold corretto, non da template da interpretare.
Agent e skill sono stati separati per dominio: gli agent definiscono comportamento e regole decisionali, le skill codificano procedure operative e pattern implementativi.
Ogni agent contiene anti-pattern espliciti con spiegazione, pattern di riferimento, e una checklist post-implementazione.
## Anti-pattern
❌ useState per errori → usa query.error
❌ Tipi manuali → usa InferType<>
❌ Query inline → usa defineQuery()
## Checklist
- [ ] Schema di validazione definito
- [ ] Cache key factory aggiornata
- [ ] Hook wrapper creato
- [ ] Errore derivato dallo stato della queryHook di pre-commit. Il linting non basta se non è automatico. Ho configurato hook di pre-commit che eseguono formattazione e linting automatico su ogni file staged. Il codice non conforme non entra nel repository — indipendentemente da chi lo ha scritto, umano o AI.
Invece di documentazione separata che diventa obsoleta, il progetto ha un file di contratto che contiene architettura, convenzioni, import alias, comandi, esempi. Quando l'AI inizia a lavorare, legge questo file e opera secondo le regole definite.
È un documento vivo: quando l'AI produce codice inconsistente, identifico il pattern mancante e lo aggiungo al contratto.
Le allucinazioni si raggruppano in tre categorie.
Pattern dai dati di addestramento. L'AI suggerisce strutture che ha visto altrove — cartelle utils/ dentro ogni feature, fetch() diretto nei componenti, tipi manuali invece di inferirli. La soluzione non è ripetere le regole nei prompt, ma renderle non aggirabili.
❌ NON creare cartelle utils specifiche per feature
✅ Le utility condivise vanno in service/shared/utilities/
Import e alias. L'AI usa path relativi invece degli alias configurati quando questi non sono visibili nel contesto. Documentarli tutti con esempi nel contratto risolve il problema in modo stabile.
Stato duplicato. Pattern ricorrente: useState per gestire errori da mutation o query.
// ❌ Pattern problematico — stato duplicato, possibile disallineamento
const [error, setError] = useState(null);
const { mutate } = useMutation({
onError: (err) => setError(err)
});
// ✅ Pattern corretto — derivare, non duplicare
const { mutate, error, isError } = useMutation({...});
{isError && <ErrorBanner message={error.message} />}L'AI lo produce perché è il pattern più comune nei dati di addestramento. Basta mostrare entrambi gli esempi nel contratto per eliminarlo in modo sistematico.
Le code review sono tornate a concentrarsi sulla business logic. I commenti legati a pattern inconsistenti o file fuori posto si sono ridotti drasticamente — da problema ricorrente a eccezione rara.
Nuovi sviluppatori hanno una guida operativa dal primo giorno. Il tempo di onboarding sulla struttura del progetto si è compresso significativamente.
Le utility duplicate sono scomparse. La validazione schema su ogni API response cattura disallineamenti nei dati prima che raggiungano la UI.
Questo approccio ha un costo. Costruire generator, configurare agent, mantenere il contratto aggiornato richiede tempo — tempo che non si spende su feature. Il contratto rischia di diventare troppo lungo se non viene controllato periodicamente.
Il ritorno è nella manutenibilità a medio termine: meno debito tecnico, review più veloci, onboarding più rapido. Ma è un investimento iniziale che va giustificato con la scala del progetto e la durata prevista.
Definisci l'architettura e rendila non aggirabile. Non importa il pattern scelto — l'importante è che sia documentata, imposta tramite linting e hook, e che ogni file abbia una posizione univoca.
Ogni dato esterno passa da uno schema. API response, form input, variabili d'ambiente — tutto validato al punto di ingresso.
Un generator per ogni nuova feature fa la differenza. Anche uno solo, che genera la struttura base con i pattern corretti, riduce le variazioni.
Separa le istruzioni per dominio. Agent o file separati per service layer, UI, testing permettono all'AI di caricare solo il contesto rilevante.
Automatizza l'enforcement. Hook di pre-commit, linting automatico, restrizioni sugli import: i vincoli che si possono aggirare verranno aggirati.
Il paradosso di questo approccio è che più vincoli strutturali ha il progetto, più l'AI riesce a essere utile. Senza dover decidere dove mettere un file o come chiamare una variabile, si concentra sulla soluzione del problema effettivo.
Non si tratta di limitare lo strumento. Si tratta di dargli un contesto dove le sue probabilità statistiche convergono verso le nostre convenzioni.
La domanda non è "dovrei usare l'AI per scrivere codice?", ma "come strutturare il progetto affinché il codice generato sia ancora manutenibile tra sei mesi?".
Hai già applicato pattern simili nel tuo progetto? Quali difficoltà hai incontrato nell'integrare l'AI nel workflow di sviluppo?