In un ecosistema tech che corre velocemente, la flessibilità è tutto. Spesso, però, nello sviluppo di applicazioni moderne cadiamo in una trappola invisibile: l'accoppiamento diretto con i servizi Backend-as-a-Service (BaaS) come ad esempio le api dei CMS Headless o Rest.
Per garantire che un'applicazione possa adattarsi ai cambiamenti nel lungo periodo, è necessario isolare la logica di business dalla persistenza. L'obiettivo è introdurre un Layer di Astrazione (o Anti-Corruption Layer) che disaccoppi completamente l'interfaccia utente dalla tecnologia di gestione dei dati.
In questo articolo esploriamo come farlo attraverso il pattern Client-Side Repository & BaaS Abstraction.
Nello sviluppo rapido, è comune importare l'SDK del fornitore (es. Firebase, Supabase, CMS Headless) direttamente nei componenti della UI.
UserProfile importa l'SDK e interroga il database: sdk.collection('users').find(...) .
L'obiettivo è introdurre un Anti-Corruption Layer, una libreria o un modulo intermedio tra il Frontend e l'SDK esterno. Invece di far parlare la UI direttamente con il database, inseriamo un intermediario: il Client Layer.
Il flusso diventa pulito:
getPremiumUsers()). Non sa da dove arrivino i dati.
Per implementare correttamente questo pattern e garantirne l'efficacia nel tempo, ci basiamo su tre concetti architetturali fondamentali:
Il Client Layer diventa l'unico luogo dell'applicazione dove risiede la logica specifica dell'SDK (Single Responsibility).
Mentre l'applicazione (Frontend) evolve seguendo le logiche di business e di UI, la gestione del fornitore di dati rimane confinata in questo layer.
Vantaggio: se l'SDK introduce una "breaking change" o cambia la sintassi di una query, l'intervento di correzione è puntuale e localizzato in un solo file, senza dover rincorrere l'errore in decine di componenti sparsi per il progetto.
L'applicazione non deve dipendere dai dati grezzi o dai metodi del fornitore, ma da un contratto stabile e definito da noi. Il Client espone modelli di dati (Domain Models) puliti e metodi con firme costanti, indipendentemente da cosa accade "sotto il cofano".
Vantaggio: questo garantisce che il codice che consuma i dati (i componenti) dipenda da astrazioni stabili e non da implementazioni volatili. Se il backend cambia formato data da timestamp a ISO string, l'interfaccia unificata assorbe il cambiamento, mantenendo il resto dell'app funzionante senza modifiche.
Isolando l'SDK dietro un'astrazione, abilitiamo la possibilità di avere diverse implementazioni dello stesso servizio.
Il sistema è progettato per accettare nuovi "adattatori" (es. un DirectusClient, un SupabaseClient o un MockClient per i test) senza alterare il funzionamento del frontend.
Vantaggio: il software è aperto all'estensione (possiamo aggiungere nuovi provider o cambiare tecnologia di backend) ma chiuso alle modifiche (non dobbiamo riscrivere la logica di visualizzazione). Questo permette di scalare o migrare l'infrastruttura dati in modo trasparente e controllato.
Per comprendere la differenza tra un semplice "Service Wrapper" e un vero Client Repository Pattern, dobbiamo introdurre un'interfaccia astratta. In questo modo, il componente non sa nemmeno quale classe stia eseguendo il codice.
Qui il componente importa direttamente l'SDK o una classe concreta che lo wrappa. Se cambiamo DB, dobbiamo toccare il componente.
1// user-list.component.ts
2import { DirectusSdk } from 'some-baas-provider'; // ⛔ DIPENDENZA DIRETTA DALL'SDK
3
4class UserListComponent {
5 async load() {
6 // Il componente è "sporco": conosce i dettagli del DB (collezioni, filtri proprietari)
7 const res = await DirectusSdk.items('sys_users')
8 .readByQuery({ filter: { status: { _eq: 'active' } } });
9
10 this.users = res.data;
11 }
12}
In questo approccio, separiamo il Cosa (Interfaccia) dal Come (Implementazione Vendor).
Questa interfaccia definisce le regole del gioco. Non c'è traccia di Directus, Firebase o SQL. Solo business.
1// libs/domain/user.contract.ts
2import { User } from './user.model';
3
4export interface UserClient {
5 // Il componente chiamerà solo questo metodo.
6 // Non sa se dietro c'è una chiamata API, un DB locale o un file JSON.
7 getActiveUsers(): Promise<User[]>;
8}
Qui, e solo qui, risiede la logica "sporca" del fornitore specifico. Questa classe rispetta il contratto definito sopra.
1// libs/infrastructure/directus-user.client.ts
2import { UserClient } from '@libs/domain';
3import { DirectusSdk } from 'directus-sdk'; // L'SDK è isolato qui
4
5export class DirectusUserClient implements UserClient {
6
7 async getActiveUsers(): Promise<User[]> {
8 // 1. Traduzione: Converto l'intenzione in linguaggio Directus
9 const response = await DirectusSdk.items('sys_users')
10 .readByQuery({
11 filter: { status: { _eq: 'active' } }
12 });
13
14 // 2. Mapping: Converto il JSON grezzo in Modello pulito
15 return response.data.map(item => ({
16 id: item.uuid,
17 fullName: item.first_name + ' ' + item.last_name,
18 isActive: true
19 }));
20 }
21}
Il componente dipende dall'interfaccia UserClient. Non sa che esiste una classe DirectusUserClient.
1// user-list.component.ts
2import { UserClient } from '@libs/domain'; // ✅ Dipende solo dall'interfaccia
3
4class UserListComponent {
5 // Inietto l'interfaccia (Dependency Injection)
6 constructor(private userClient: UserClient) {}
7
8 async load() {
9 // ✅ Business Logic Pura
10 this.users = await this.userClient.getActiveUsers();
11 }
12}
Nel modulo principale (es. app.module.ts in Angular o nel container DI), decidiamo quale implementazione usare.
1// app.config.ts
2providers: [
3 {
4 provide: UserClient, // Quando qualcuno chiede UserClient...
5 useClass: DirectusUserClient // ...dagli l'implementazione Directus!
6 }
7]
Il vero vantaggio: se domani passiamo a Supabase, creiamo una classe SupabaseUserClient, cambiamo una riga nella configurazione (useClass: SupabaseUserClient) e l'intera applicazione funzionerà senza aver modificato una sola riga di codice nei componenti.
Adottare questo pattern trasforma l'architettura da "Monolitica/Accoppiata" a Modulare, garantendo manutenibilità e testabilità superiori. Tuttavia, questo disaccoppiamento **ha un costo in termini di verbosità. Richiede la scrittura di interfacce, classi e mapper aggiuntivi (boilerplate) che aumentano il volume di codice iniziale.
Se state realizzando una POC (Proof of Concept), un prototipo "usa e getta" o un'applicazione semplice dove non è prevista evoluzione o scalabilità futura, questo pattern è Over-Engineering. In questi casi, il tempo speso a scrivere astrazioni non verrà ripagato: l'accesso diretto all'SDK è la scelta più pragmatica ed efficiente.
Scegliete il Repository Pattern solo quando state costruendo software destinato a durare, dove la protezione dall'obsolescenza tecnologica vale l'investimento iniziale.
Considera la programmazione più una passione che un lavoro. È la stessa curiosità che spinge molti sviluppatori a esplorare nuovi linguaggi, framework e metodologie ogni giorno.
Per lui programmare è come un videogioco: davanti a ogni bug o requisito c’è un enigma da risolvere, una sfida da affrontare nel modo più elegante ed efficiente possibile. Ama sperimentare e provare cose nuove, soprattutto quando migliorano la developer experience e rendono il lavoro più fluido e “comodo”.
Sviluppa principalmente applicazioni web con Angular, Node.js, NestJS e Astro, ma nel tempo ha lavorato anche con PHP, MySQL, e JavaScript, senza disdegnare incursioni in altri linguaggi e tecnologie come Vue o Flutter.
Nel tempo libero gli piacciono i videogiochi e i gatti — due ottime scuole di pazienza, strategia e problem solving, qualità che tornano sorprendentemente utili anche nel codice.