←
Angular / Architecture

Building Bilancino: A Full-Stack Architecture with Angular 21, Signals, and Supabase AI

A technical deep dive into Bilancino's architecture: Angular Signals, Component Stores, deterministic caching, Supabase Edge Functions with Gemini AI, and zero-latency strategies.


When I set out to build Bilancino — a mobile-first web application for tracking family finances — my primary goal was not just to build a functional app, but to craft a modern architectural showcase.

Over 20 years of software engineering, I’ve watched countless frameworks and state management patterns come and go. With Bilancino, I wanted to prove how combining Angular 21 (Standalone + Signals), a decoupled Component-Store architecture, and a Supabase + Serverless AI backend can deliver native-level performance, zero boilerplate, and exceptional maintainability.

In this article, we’ll dive into the project’s key architectural choices with real code snippets.


1. Reactive State Without Boilerplate: Signals & Scoped Component Stores

One of the first architectural decisions was to ditch NgRx and Redux. For an application of this scope, a centralized global store would have introduced unnecessary complexity and dozens of action, reducer, and effect files.

Instead, I adopted the Component Store pattern paired with Angular Signals:

  1. Scoped Store: Every complex feature (e.g., SpeseStore, AnalisiStore) uses its own @Injectable() class scoped directly to the component lifecycle (providers: [SpeseStore]). When the view unmounts, the store and its memory footprint are garbage-collected automatically.
  2. Thin Shell Components: Angular components (.ts) act purely as visual orchestrators and contain zero business logic.

Global Reactive Caching with Async Mutex Locks

To avoid redundant API calls to Supabase (e.g., re-fetching categories on every modal open or route change), I built providedIn: 'root' state services implementing an async Promise-based mutex lock:

@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; // Wait if fetch is in-flight
    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. Custom Supabase Interception Without HttpClient

The official Supabase JavaScript SDK (@supabase/supabase-js) uses the browser’s native fetch API instead of Angular’s HttpClient. Consequently, traditional Angular HttpInterceptor classes cannot intercept database requests.

To drive a Global Loading Service with a concurrent request counter, I injected a custom fetch handler directly into the Supabase client configuration:

// Global registry outside Angular DI
export const loaderRegistry = {
  loadingService: null as any
};

export const supabase = createClient(supabaseUrl, supabaseKey, {
  global: {
    fetch: async (url, options) => {
      // Increment global loading counter
      loaderRegistry.loadingService?.show();
      try {
        return await fetch(url, options);
      } finally {
        // Ensure spinner hides even on network exceptions
        loaderRegistry.loadingService?.hide();
      }
    },
  },
});

At application boot (app.ts), we link the LoadingService singleton: loaderRegistry.loadingService = inject(LoadingService);. Every REST query, authentication attempt, or SQL mutation automatically triggers the loading spinner transparently.


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

A standout feature of Bilancino is its AI-powered Quick Add: users can type natural sentences like “Spotify subscription $10.99 recurring #entertainment”, and the AI parses and populates the entire form automatically.

Proactive Cold-Start Mitigation (Pre-Warming)

Supabase serverless Edge Functions suffer from cold starts when idle. To guarantee instant response times, I implemented a silent fire-and-forget pre-warming strategy that pings the Edge Function when the user logs in or opens a dialog:

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

  const urls = this.getEdgeFunctionUrl();
  urls.forEach((url) => {
    fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ping: true }), // Lightweight ping
    }).catch(() => {}); // Catch and fail silently
  });
}

Inside the Deno Edge Function, if the payload contains { ping: true }, it returns an immediate 200 OK without triggering Google 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' },
    });
  }

  // ... Process prompt with Google Gemini AI ...
});

Multi-Model Automatic Failover (Anti-503)

To handle rate limits or temporary upstream service disruptions, the Edge Function implements sequential model failover: it first attempts gemini-3.5-flash and automatically falls back to gemini-3.1-flash-lite if an error occurs.


4. Reactive Privacy Mode (Incognito)

Inspired by mobile banking apps, Bilancino includes a Privacy Mode toggled via the Topbar. When enabled, all sensitive financial figures are masked with •••• €.

The technical challenge was propagating this masking not only across HTML templates, but also inside ApexCharts dynamic tooltips and canvas layers.

The PrivacyService exposes a Signal obscureAmounts = signal<boolean>(false) persisted in localStorage. Charts reactively rebuild their options and tooltips using Angular computed() properties:

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

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

5. Mobile-First UX & Native Swipe Gestures

Bilancino was built from the ground up with a strict Mobile-First approach. On mobile screens, navigation shifts to a fixed Bottom Navigation Bar, and list items integrate native touch swipe gestures using @angular/cdk/drag-drop:

  • Left Swipe (←): Triggers item deletion with haptic feedback (navigator.vibrate).
  • Right Swipe (→): Opens quick edit mode.
onSwipeEnded(event: CdkDragEnd<any>, item: SpesaItem): void {
  const swipeDistance = event.distance.x;
  const threshold = 100; // Pixel distance threshold

  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(); // Instantly snap back row position
}

Conclusion & Key Takeaways

Building Bilancino reconfirmed a core engineering principle: the modern Angular ecosystem, paired with cloud-native AI services, easily matches the performance and developer velocity of lighter frameworks like React or Svelte.

By leveraging Signals, OnPush Change Detection, and Standalone architecture, Bilancino delivers:

  • Minimal bundle size: Lazy-loaded modals and chart modules.
  • Zero full-page reloads: Silky-smooth 60 fps reactive UI.
  • Maintainable codebase: Strict separation of concerns between UI, State, and Cache layers.

Check out the live application at bilancino.netlify.app or explore my portfolio for more projects!