←
Angular / Architecture

Come ho progettato Bilancino: Architettura Full-Stack con Angular 21, Signals e Supabase AI

Deep-dive tecnico sull'architettura di Bilancino: Signals, Component Store, caching deterministico con mutex, Edge Functions con Gemini AI e strategie per azzerare i cold start.


Quando ho deciso di sviluppare Bilancino — un’applicazione web mobile-first per la gestione del bilancio familiare — il mio obiettivo primario non era solo creare uno strumento utile, ma realizzare una vetrina architetturale moderna.

In oltre 20 anni di ingegneria software ho visto cambiare e sovrapporsi decine di pattern. Con Bilancino volevo dimostrare come la combinazione di Angular 21 (Standalone + Signals), un’architettura Component-Store disaccoppiata e un backend servito da Supabase + AI Serverless possa offrire prestazioni native, azzerare il codice boilerplate e garantire una manutenzione impeccabile.

In questo articolo esploreremo le scelte tecniche più significative del progetto, accompagnate da snippet di codice reali.


1. Stato Reattivo senza Boilerplate: Signals & Scoped Component Store

Una delle prime decisioni architetturali è stata eliminare NgRx o Redux. Per un’applicazione di questa portata, uno store globale centralizzato avrebbe introdotto una complessità inutile e decine di file tra azioni, reducer ed effetti.

Ho optato invece per il pattern Component Store accoppiato ai Signals di Angular:

  1. Scoped Store: Ogni feature complessa (es. SpeseStore, AnalisiStore) possiede una propria classe @Injectable() registrata esclusivamente nel provider del componente (providers: [SpeseStore]). Quando la pagina si smonta, lo store e la sua memoria vengono distrutti automaticamente.
  2. Componenti Thin Shell: I componenti Angular (.ts) agiscono come puri orchestratori grafici e non contengono logica di business.

Caching globale reattivo con Mutex (Lock Asincrono)

Per evitare round-trip inutili verso il database (es. ricaricare le categorie ad ogni apertura di modali o cambi di pagina), ho creato dei servizi di stato providedIn: 'root' che implementano un lock asincrono basato su Promise per prevenire fetch concorrenti:

@Injectable({ providedIn: 'root' })
export class CategorieStateService {
  readonly categorie = signal<CategoriaItem[]>([]);
  private loaded = false;
  private loadingPromise: Promise<void> | null = null; // Mutex

  async ensureLoaded(): Promise<void> {
    if (this.loaded) return;
    if (this.loadingPromise) return this.loadingPromise; // Se un fetch è in corso, accodati
    this.loadingPromise = this.fetchAndStore();
    await this.loadingPromise;
    this.loadingPromise = null;
  }

  private async fetchAndStore(): Promise<void> {
    const { data, error } = await supabase
      .from('categorie')
      .select('id, nome, colore, budget, tipo_movimento, icona')
      .order('nome');

    if (!error && data) {
      this.categorie.set(data.map(row => this.normalizeCategoria(row)));
      this.loaded = true;
    }
  }
}

2. Intercettazione Custom di Supabase senza HttpClient

Il client JavaScript ufficiale di Supabase (@supabase/supabase-js) si basa sulla funzione nativa fetch del browser anziché su HttpClient di Angular. Di conseguenza, i classici HttpInterceptor non funzionano.

Per gestire un Loading Service globale con contatore di richieste concorrenti, ho iniettato una funzione fetch customizzata direttamente nell’inizializzazione del client Supabase:

// Registro fuori dalla Dependency Injection di Angular
export const loaderRegistry = {
  loadingService: null as any
};

export const supabase = createClient(supabaseUrl, supabaseKey, {
  global: {
    fetch: async (url, options) => {
      // Attiva lo spinner globale
      loaderRegistry.loadingService?.show();
      try {
        return await fetch(url, options);
      } finally {
        // Garantisce lo spegnimento anche in caso di eccezioni di rete
        loaderRegistry.loadingService?.hide();
      }
    },
  },
});

All’avvio dell’app (app.ts), colleghiamo il singleton LoadingService al registro: loaderRegistry.loadingService = inject(LoadingService);. Qualsiasi query REST, login o mutazione SQL attiva automaticamente lo spinner di caricamento in modo trasparente.


3. Natural Language Processing & AI su Edge Functions (Deno)

Una delle feature più amate dagli utenti è il Quick Add basato su Intelligenza Artificiale: l’utente può scrivere una frase del tipo “Abbonamento Spotify 10,99€ ricorrrente #svago” e l’AI estrae e pre-compila tutti i campi del form.

Strategia Anti-Cold Start (Pre-warming Proattivo)

Le Edge Function serverless di Supabase soffrono del classico problema del “cold start” quando rimangono inattive. Per garantire risposte istantanee, ho implementato un meccanismo di pre-warming silenzioso (fire-and-forget) che invia un ping di riscaldamento quando l’utente fa il login o apre un dialog:

// In AiService (Angular)
prewarm(): void {
  const now = Date.now();
  if (now - this.lastPrewarmTime < 5 * 60 * 1000) return; // Throttling 5 minuti
  this.lastPrewarmTime = now;

  const urls = this.getEdgeFunctionUrl();
  urls.forEach((url) => {
    fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ping: true }), // Ping leggero
    }).catch(() => {}); // Intercetta e ignora silenziosamente
  });
}

Nella Edge Function in Deno, se il body contiene { ping: true }, la funzione risponde immediatamente con un 200 OK bypassando la chiamata a Gemini:

// In Supabase Edge Function (Deno)
Deno.serve(async (req) => {
  const body = await req.json();

  if (body.ping === true) {
    return new Response(JSON.stringify({ pong: true }), {
      status: 200,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  }

  // ... Elaborazione prompt con Google Gemini ...
});

Failover Multi-Modello (Anti-503)

Per evitare disservizi dovuti ai limiti di rate-limiting o sovraccarichi temporanei dei server di Google, l’Edge Function implementa un failover automatico sequenziale: prova prima il modello primario (gemini-3.5-flash) e, in caso di errore, effettua una transizione istantanea sul modello di riserva (gemini-3.1-flash-lite).


4. Modalità Privacy (Incognito) Reattiva

Ispirandomi alle principali applicazioni di Home Banking, ho introdotto una Modalità Privacy attivabile dalla Topbar. Quando è attiva, tutti i dati finanziari sensibili vengono oscurati con la stringa •••• €.

La sfida tecnica è stata propagare questa trasformazione non solo nel DOM HTML, ma anche all’interno dei canvas dei grafici dinamici (ApexCharts).

Il PrivacyService espone un Signal obscureAmounts = signal<boolean>(false) persistito in localStorage. I grafici ri-generano reattivamente i loro dati e tooltip sfruttando le funzioni computed() di Angular:

readonly chartKey = computed(
  () => `${this.themeService.theme()}_${this.privacy.obscureAmounts()}`
);

// Nel formatter del Tooltip ApexCharts:
formatter: (value: number) => {
  if (this.privacy.obscureAmounts()) {
    return '•••• €';
  }
  return `€ ${value.toLocaleString('it-IT')}`;
}

5. Mobile-First UX e Gestures Native

L’interfaccia di Bilancino è stata costruita con un approccio Mobile-First rigoroso. Su dispositivi mobili la navigazione si sposta su una Bottom Navigation Bar, mentre per la gestione dei movimenti ho integrato le Swipe Gestures native sfruttando il modulo @angular/cdk/drag-drop:

  • Swipe a Sinistra (←): Attiva la cancellazione della riga con vibrazione tattile (navigator.vibrate).
  • Swipe a Destra (→): Apre la modalità di modifica rapida.
onSwipeEnded(event: CdkDragEnd<any>, item: SpesaItem): void {
  const swipeDistance = event.distance.x;
  const threshold = 100; // Pixel di tolleranza

  if (swipeDistance < -threshold) {
    this.privacy.vibrate(25);
    this.deleteClicked.emit(item);
  } else if (swipeDistance > threshold) {
    this.privacy.vibrate(25);
    this.editClicked.emit(item);
  }

  event.source.reset(); // Riallinea istantaneamente la riga
}

Conclusioni & Lezioni Apprese

Sviluppare Bilancino mi ha confermato una grande verità: l’ecosistema moderno di Angular, combinato con i giusti servizi Cloud/AI, non ha nulla da invidiare ai framework apparentemente più ‘leggeri’ come React o Svelte.

Grazie ai Signals, OnPush Change Detection e all’eliminazione dei moduli monolitici, l’applicazione garantisce:

  • Bundle size ridotto: caricamento lazy di modali e grafici.
  • 0 ricaricamenti di pagina: aggiornamenti fluidi a 60 fps.
  • Codice manutenibile: separazione netta tra UI, Store e Caching.

Puoi provare l’applicazione dal vivo su bilancino.netlify.app o dare un’occhiata agli altri progetti sul mio portfolio!