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:
- 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. - 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!