Come relazionare le tabelle in un'architettura a microservizi

Quando si passa da un monolite ai microservizi, una delle prime cose da dimenticare è che il database non è più uno spazio comune dove collegare liberamente tutte le entità.
In un monolite ragioniamo con tabelle, primary key, foreign key e JOIN. Abbiamo users e posts, creiamo una relazione, e il database garantisce che ogni user_id in posts corrisponda a un utente esistente.
Con i microservizi la situazione cambia. Nel mio progetto Spring Boot spring-services, user-service gestisce gli utenti e content-service gestisce i post, ciascuno con il proprio database PostgreSQL. La domanda è: come rappresentiamo la relazione tra un post e il suo autore?
La risposta non è "non possiamo farlo". La relazione esiste ancora nel dominio. Cambia come la rappresentiamo e chi è responsabile di garantire che sia valida.
Nel mio caso l'ho rappresentata con un UUID, senza foreign key tra i database. Vediamo perché.
Il caso classico: relazioni in un monolite
Un'applicazione monolitica ha di solito due tabelle così:
users posts
---------------- ----------------
id id
username title
email body
user_idPossiamo creare la foreign key:
FOREIGN KEY (user_id) REFERENCES users(id)e PostgreSQL rifiuta un post con un user_id inesistente. Possiamo anche fare una JOIN:
SELECT posts.id, posts.title, users.username
FROM posts
JOIN users ON users.id = posts.user_id;Con JPA basta una relazione:
@ManyToOne
private User user;È comodissimo: il database contiene tutto e garantisce l'integrità referenziale. Il problema nasce quando User e Post non appartengono più alla stessa applicazione.
Cosa cambia quando dividiamo l'applicazione
Con due servizi abbiamo due database:
user-service → user-db (users)
content-service → content-db (posts)Non esiste più un unico DATABASE con users e posts dentro, ed è questa separazione a cambiare il problema.
content-service non deve accedere direttamente a user-db, altrimenti accoppiamo due servizi che dovrebbero essere indipendenti. Che PostgreSQL permetta tecnicamente certe soluzioni non le rende corrette dal punto di vista architetturale.
Principio: il database di un microservizio è di proprietà del servizio stesso.
La relazione esiste ancora (solo non è una foreign key)
Questo è il punto più importante dell'articolo. Non poter creare una foreign key tra i due database non fa sparire la relazione. Nel dominio continuiamo ad avere:
Post → appartiene → UserSemplicemente content-service non può più rappresentarla come relazione relazionale locale. La tabella posts contiene user_id, che è un UUID: non una foreign key verso user-db.users, ma solo l'identificativo dell'utente a cui il post appartiene.
La differenza è netta. Il database di content-service può dire:
questo post ha come autore l'utente 550e8400-e29b-41d4-a716-446655440000
ma non può garantire che quell'utente esista. Quella verifica appartiene a un altro servizio.
Perché non si può creare una foreign key tra due database
Una foreign key è un vincolo che il database deve poter verificare. Qui posts.user_id e users.id stanno in due database distinti, di due servizi distinti.
Si potrebbero costruire soluzioni particolari (database condiviso, foreign data wrapper, altri meccanismi), ma significherebbe introdurre di proposito una dipendenza tra i database, cioè esattamente quello che stiamo cercando di evitare.
Quindi la domanda giusta non è "come creo una foreign key tra due microservizi?", ma:
Come rappresento una relazione tra due aggregati di servizi diversi senza rompere il confine tra i servizi?
È una domanda architetturale, non più solo SQL.
content-service non conosce la classe User: non la importa da user-service, non usa @ManyToOne, non condivide un modulo common con le entità. Conosce solo:
private UUID userId;Tra i due servizi passa un identificativo, non un oggetto Java. Se condividessimo User.java, una modifica nel servizio utenti potrebbe obbligare anche il servizio contenuti a ricompilare, testare e ridistribuire. Formalmente separati, ma fortemente accoppiati.
Con il solo userId il contratto è molto più piccolo: content-service non ha bisogno di sapere come user-service rappresenta internamente un utente.
Perché UUIDv7 è utile nei sistemi distribuiti
Nel progetto gli ID sono UUIDv7, generati dall'applicazione tramite Hibernate:
@UuidGenerator(style = UuidGenerator.Style.VERSION_7)Ogni servizio può generare identificativi senza dipendere da un contatore centrale del database:
user-service → User id = 0199...
content-service → Post id = 019a... userId = 0199...La relazione si legge così:
Post
└── userId ──────→ User.idLa freccia esiste nel dominio, ma non è una foreign key: è una relazione logica tra servizi.
Chi controlla che l'utente esista?
Nel monolite lo garantiva il database. Nei microservizi questa responsabilità può passare all'applicazione. Per esempio, prima di creare un post, content-service può chiamare user-service:
GET /users/{id}Soluzione semplice da capire, ma introduce una dipendenza: content-service deve poter raggiungere user-service.
Nel mio progetto, per la sola identificazione dell'autore, non serve nemmeno questa chiamata: l'identità arriva dal token firmato. Ne parlo in Autenticazione tra microservizi con JWT, RSA e JWKS.
Una JOIN diventa una chiamata HTTP
Nei microservizi non abbiamo più la JOIN SQL. Per ottenere qualcosa di simile content-service chiama user-service e compone la risposta:
{
"id": "019...",
"title": "Primo post",
"body": "Scritto da Luca",
"author": {
"id": "019...",
"username": "luca"
}
}Funzionalmente è simile a una JOIN, tecnicamente è tutt'altra cosa: una JOIN avviene dentro un database, una chiamata HTTP attraversa la rete. E la rete, come impariamo tutti prima o poi nel modo meno piacevole, può fallire.
Il problema della disponibilità
Se user-service è spento, content-service e content-db funzionano, ma una parte della risposta non si può costruire. Abbiamo ottenuto indipendenza nei deploy e nei database, ma anche:
- timeout
- retry
- circuit breaker
- gestione degli errori e della disponibilità
Non esiste una bacchetta magica: abbiamo spostato il problema.
Dobbiamo sempre chiamare l'altro servizio?
No, ed è qui che l'architettura diventa interessante.
- Se content-service ha bisogno solo dell'ID, salva posts.user_id e lo usa direttamente, senza chiamate.
- Se deve mostrare dati dell'utente, ci sono due strade:
- una chiamata sincrona a user-service
- una copia locale dei dati che servono
La copia locale potrebbe essere una tabella così:
content-db
└── user_snapshot
├── id
├── username
└── ...content-service non possiede l'utente, ma solo una rappresentazione locale dei dati che gli servono.
Copia locale e eventual consistency
Se Luca cambia username, user-service aggiorna subito users.username, ma content-service potrebbe non ricevere l'aggiornamento subito:
user-service username = luca.terribili
content-service username = lucaÈ la eventual consistency (consistenza eventuale): i dati possono essere temporaneamente diversi e convergere dopo. Per ottenerla si possono usare eventi:
user-service ── UserUpdated ──▶ message broker ──▶ content-service
│
▼
aggiorna lo snapshot localeNiente chiamata HTTP a ogni richiesta, ma abbiamo aggiunto infrastruttura e complessità. Ogni soluzione risolve alcuni problemi e ne introduce altri.
Perché non condividere il database
È la scorciatoia più naturale: un solo PostgreSQL con users e posts, accessibile da entrambi i servizi. E funziona.
Ma indebolisce uno dei principi fondamentali dei microservizi: ora content-service conosce lo schema interno di user-service. Se user-service rinomina una colonna, tocca users.email o ristruttura le tabelle, può rompere content-service.
Un database condiviso diventa un contratto condiviso, molto più difficile da evolvere in modo indipendente.
In pratica sono due applicazioni separate che litigano per lo stesso appartamento: processi diversi, ma architetturalmente ancora sposate.
Perché non usare una relazione JPA tra servizi
Stesso ragionamento per JPA. Nel monolite avremmo:
@Entity
class Post {
@ManyToOne
private User user;
}Ma User appartiene a user-service, e content-service non deve dipendere dall'entità. Rappresentiamo solo l'identificativo:
@Entity
class Post {
private UUID userId;
}Il modello è volutamente più povero: Post conosce solo l'identità dell'utente associato. Il resto lo si recupera attraverso il contratto previsto tra i servizi.
Una relazione non è per forza una foreign key
Siamo abituati a pensare:
relazione tra entità → foreign keyma questa equivalenza vale soprattutto quando le entità stanno nello stesso database e nello stesso confine applicativo. Nei microservizi diventa:
relazione di dominio → UUID → comunicazione tra serviziLa relazione continua a esistere, solo che non è più il database a conoscerla e garantirla per intero. Quindi dobbiamo decidere dove applicare ogni regola di integrità.
Cosa succede se l'utente viene cancellato
Nel monolite basta ON DELETE CASCADE e il database fa tutto. Tra due database separati questo meccanismo non esiste: se cancelliamo un utente da user-db, i suoi post possono restare in content-db.
Non è per forza un errore. Possiamo decidere che i post:
- restino
- vengano anonimizzati
- vengano cancellati
Ma la decisione non può più essere delegata a una foreign key: serve un comportamento applicativo, per esempio tramite evento:
user-service ── UserDeleted ──▶ content-service
│
├── elimina i post
└── oppure anonimizza userIdPunto chiave: la scelta dipende dal dominio, ma la relazione tra servizi deve avere regole esplicite.
L'approccio nel progetto Spring Boot
Nel progetto spring-services ho scelto una soluzione volutamente semplice:
- user-service possiede users
- content-service possiede posts
- posts contiene user_id UUID, senza foreign key verso users
- l'entità User non è condivisa tra i servizi
- content-service conosce l'ID dell'utente, non la sua implementazione interna
Così i due servizi restano indipendenti e il confine architetturale è evidente. È adatta a un progetto didattico perché fa vedere chiaramente cosa si perde rispetto a un monolite e cosa si guadagna in indipendenza.
Attenzione: non estremizzare
Dire che "nei microservizi non si usano foreign key" è sbagliato. Se content-service ha due tabelle sue, posts e comments, nello stesso database e nello stesso servizio, una foreign key è perfettamente appropriata:
content-db
posts ◀── FK ── commentsNon c'è motivo di rinunciare agli strumenti relazionali dentro il confine del servizio. Il problema nasce quando usiamo il database per creare dipendenze oltre quel confine.
Non stiamo dicendo "le foreign key sono cattive". Stiamo dicendo: una foreign key deve rispettare il confine del database e del servizio che possiede le tabelle.
Il criterio che uso per decidere
Parto da una domanda semplice: le due entità appartengono allo stesso servizio?
| Caso | Come rappresento la relazione |
|---|---|
| Stesso servizio | Foreign key, JOIN e JPA vanno benissimo |
| Servizi diversi | Un identificativo (UUID) + contratto esplicito tra i servizi |
Nel mio progetto:
User { id: UUID }
Post { id: UUID, userId: UUID }La relazione esiste, la foreign key no. E non è una mancanza del modello: è la conseguenza di aver separato i due domini.
La domanda giusta da farsi
Quando si progetta un sistema a microservizi è un errore partire da "come mantengo le relazioni che avevo nel monolite?". La domanda dovrebbe essere:
Quale servizio è proprietario di questo dato, e quale informazione serve davvero all'altro servizio?
In pratica:
- se content-service deve solo sapere chi è l'autore, probabilmente basta userId
- se deve mostrare il nome dell'autore, può chiamare user-service
- se deve fare ricerche massive sugli utenti, una chiamata HTTP per record non regge: meglio una proiezione locale o un meccanismo a eventi
- se due entità vengono interrogate e aggiornate insieme di continuo, forse il confine che hai tracciato è sbagliato
Ed è quest'ultimo il punto più importante:
I microservizi non si progettano separando le tabelle, ma separando responsabilità e confini del dominio. Il database viene dopo.
Se separi due tabelle che devono essere aggiornate insieme a ogni operazione, hai probabilmente creato un confine artificiale. Se invece due gruppi di dati hanno responsabilità diverse e possono evolvere indipendentemente, la separazione ha senso.
In spring-services il servizio utenti possiede gli utenti, il servizio contenuti possiede i post, e la relazione è un identificativo. Non abbiamo eliminato la relazione: abbiamo eliminato la dipendenza diretta dal database dell'altro servizio. Ed è questa la differenza tra una relazione tra tabelle in un monolite e una relazione tra dati di microservizi diversi.