> ## Content Index
> Fetch the complete content index at: https://bitstales.eu/llms.txt
> Use this file to discover other available public pages before exploring further.

# L'eredità di Ottavio: perché la logica dell'applicazione non deve stare nel database
- URL: https://bitstales.eu/it/logica-nel-database-eredita/
- Published: 2025-09-25T17:15:00.000Z
- Updated: 2025-09-25T17:15:00.000Z
- Description: Erediti un gestionale con 412 stored procedure, 97 trigger e una tabella che manda email. Giro della casa: l'UPDATE che dice «1» e mente, la promo testabile solo di lunedì, le licenze a core, la migrazione impossibile. E le fondamenta che invece restano nel database.
- Author: Vittorio
- Tags: Databases, Software Engineering, Architecture, #it

*Giovedì 25 settembre 2025, ore 9:30\. Oggi esce PostgreSQL 18\. Tu, invece, ricevi un'eredità.*

Sala riunioni del Pastificio Bortolin, terzo piano. La CTO apre un documento di Word e si schiarisce la voce. Il file si chiama `PASSAGGIO_CONSEGNE_def_FINALE(2).docx` ed è l'ultimo lascito di Ottavio. Ottavio ha scritto il gestionale dell'azienda tra il 2004 e il 2019 e oggi coltiva bonsai a Bordighera.

> «Io sottoscritto Ottavio, nel pieno delle mie facoltà mentali (più o meno), lascio al nuovo sviluppatore il gestionale GESTIX, con tutte le sue 412 stored procedure, i suoi 97 trigger e la tabella `EMAIL_DA_MANDARE`, che non va mai svuotata. Il codice Java è poco, non preoccuparti: **la logica è tutta nel database**. È più sicuro lì.»

La CTO chiude il portatile. «Congratulazioni», dice. «È tutto tuo.»

Ti hanno appena lasciato in eredità una casa. Bella facciata, ottima posizione. Dentro però ogni stanza ha una sorpresa, e le pareti si muovono quando non guardi. Facciamo il giro.

## L'ingresso: l'UPDATE che dice «1» e mente

10:15\. Primo ticket della tua nuova vita: «Aggiornare il prezzo dei rigatoni da 1,80 a 2,10». Facile. Una riga di SQL, un minuto di lavoro, poi un caffè.

```sql
UPDATE prodotti SET prezzo = 2.10 WHERE id = 1;
-- UPDATE 1
```

`UPDATE 1`. Una riga. Come quando dici «faccio solo una cosa veloce» e torni a casa alle nove di sera.

Alle 10:40 telefona la Trattoria Da Rina, furiosa. Il preventivo di lunedì diceva 180 euro e la fattura dice 210\. Vai a vedere cosa è successo e trovi tre trigger, scritti in tre anni diversi, che si passano il testimone come in una staffetta:

```sql
-- 2009, Ottavio: "così gli ordini aperti restano allineati al listino"
CREATE FUNCTION allinea_righe() RETURNS trigger AS $$
BEGIN
  UPDATE righe r SET prezzo = NEW.prezzo
    FROM ordini o
   WHERE o.id = r.ordine_id AND o.stato = 'aperto' AND r.prodotto_id = NEW.id;
  RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_listino AFTER UPDATE OF prezzo ON prodotti
  FOR EACH ROW EXECUTE FUNCTION allinea_righe();

-- 2011, Ottavio: "temporaneo"
CREATE FUNCTION ricalcola_totale() RETURNS trigger AS $$
BEGIN
  UPDATE ordini SET totale = (SELECT sum(qta * prezzo) FROM righe WHERE ordine_id = NEW.ordine_id)
   WHERE id = NEW.ordine_id;
  RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_totale AFTER UPDATE ON righe
  FOR EACH ROW EXECUTE FUNCTION ricalcola_totale();

-- 2014, Ottavio: "NON TOCCARE"
CREATE FUNCTION scrivi_storico() RETURNS trigger AS $$
BEGIN
  INSERT INTO storico(cosa) VALUES ('ordine ' || NEW.id || ' di ' || NEW.cliente || ' ora vale ' || NEW.totale);
  RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_storico AFTER UPDATE OF totale ON ordini
  FOR EACH ROW EXECUTE FUNCTION scrivi_storico();
```

Lo rifai su una copia del database (PostgreSQL 17, in Docker) per vedere cosa ha combinato davvero quell'`UPDATE 1`:

```
 id  |      cliente      |  stato  | totale
-----+-------------------+---------+--------
 101 | Trattoria Da Rina | aperto  | 210.00
 102 | Mensa Aziendale   | aperto  | 420.00
 103 | Bar Centrale      | spedito |  90.00

                      cosa
-------------------------------------------------
 ordine 101 di Trattoria Da Rina ora vale 210.00
 ordine 102 di Mensa Aziendale ora vale 420.00
```

Hai toccato **una** riga. Il database ne ha cambiate sette, in quattro tabelle, e ha riscritto due ordini già confermati ai clienti. Nessuno di questi effetti si vede nel codice dell'applicazione: aprire il file Java non serve a niente, perché la regola «gli ordini aperti seguono il listino» non sta lì. Sta in un trigger, che sta in uno schema, che sta in un server, che sta nella stanza dove nessuno guarda.

Ecco il primo problema della logica nel database: **è invisibile**. Il codice applicativo lo leggi, lo cerchi, lo segui con l'IDE. Un trigger scatta e basta, come lo sciacquone del vicino alle tre di notte: sai che è successo qualcosa, ma non sai dove.

## La cucina: la tabella che manda le email

11:20\. In cucina trovi la famosa `EMAIL_DA_MANDARE`. Funziona così: un trigger sugli ordini prepara la mail «Grazie per il suo ordine!», e una procedura la spedisce direttamente dal database.

Il problema è che un'email **non si può annullare con un ROLLBACK**. Se la transazione fallisce dopo l'invio (un controllo sul magazzino, un errore sul pagamento), l'ordine sparisce, ma la mail è già nella casella del cliente. Così la Mensa Aziendale riceve un ringraziamento caloroso per un ordine che non esiste. E il giorno dopo chiama per sapere quando arrivano i suoi 200 kg di rigatoni.

Accanto c'è un'altra procedura che, per calcolare la spedizione, chiama il servizio web del corriere. Dall'interno di una transazione. Con le righe bloccate. Quando il corriere risponde in 200 millisecondi va tutto bene. Quando ci mette 30 secondi, tutti gli altri ordini restano in fila dietro a quei lucchetti, e il gestionale «è lento». Lo è: sta aspettando il corriere, con la porta della cucina chiusa a chiave.

## Il soggiorno: la promo che si può provare solo di lunedì

12:00\. In soggiorno, sul divano, c'è la regola degli sconti. Eccola, così com'è in produzione:

```sql
-- 2012: sconto quantità. 2013: clienti oro. 2016: "promo del lunedì, poi la togliamo"
CREATE FUNCTION calcola_sconto() RETURNS trigger AS $$
DECLARE liv text;
BEGIN
  SELECT livello INTO liv FROM clienti WHERE id = NEW.cliente_id;
  NEW.sconto := CASE WHEN NEW.imponibile >= 100 THEN 0.10 ELSE 0 END
              + CASE WHEN liv = 'oro' THEN 0.05 ELSE 0 END;
  IF extract(isodow FROM now()) = 1 THEN
    NEW.sconto := NEW.sconto + 0.02;
  END IF;
  NEW.totale := round(NEW.imponibile * (1 - NEW.sconto), 2);
  RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sconto BEFORE INSERT ON fatture
  FOR EACH ROW EXECUTE FUNCTION calcola_sconto();
```

Funziona: una fattura da 200 euro per un cliente oro, di giovedì, esce con sconto 0,15 e totale 170,00\. Ma prova a scriverci un test.

- Ti serve un database acceso, con le tabelle `clienti` e `fatture`, più tutte quelle che servono a loro (in GESTIX sono quaranta).
- Per inserire una fattura devi creare un cliente, che ha bisogno di un listino, che ha bisogno di un agente di vendita, che ha bisogno di una zona…
- E per provare la promo del lunedì… devi aspettare lunedì. Il trigger legge `now()`, l'orologio del server. Oppure sposti l'orologio del server di produzione, e allora ti conviene aggiornare il curriculum.

Ora la **stessa regola**, spostata nell'applicazione. Python, ma vale per qualsiasi linguaggio:

```python
from datetime import date
from decimal import Decimal

LUNEDI = 0

def sconto(imponibile: Decimal, livello: str, giorno: date) -> Decimal:
    s = Decimal("0")
    if imponibile >= 100:
        s += Decimal("0.10")          # sconto quantità (2012)
    if livello == "oro":
        s += Decimal("0.05")          # clienti oro (2013)
    if giorno.weekday() == LUNEDI:
        s += Decimal("0.02")          # promo del lunedì (2016, "poi la togliamo")
    return s

def totale(imponibile: Decimal, livello: str, giorno: date) -> Decimal:
    return (imponibile * (1 - sconto(imponibile, livello, giorno))).quantize(Decimal("0.01"))
```

E finalmente i test, senza database, senza container, senza aspettare lunedì:

```python
from datetime import date
from decimal import Decimal as D

from prezzi import totale

GIOVEDI = date(2025, 9, 25)
LUNEDI = date(2025, 9, 22)

def test_cliente_oro_sopra_i_100():
    assert totale(D("200.00"), "oro", GIOVEDI) == D("170.00")

def test_promo_del_lunedi():
    assert totale(D("200.00"), "oro", LUNEDI) == D("166.00")

def test_niente_sconto_sotto_i_100():
    assert totale(D("99.99"), "normale", GIOVEDI) == D("99.99")
```

```
$ python -m pytest -q
...                                                    [100%]
3 passed in 0.01s
```

Un centesimo di secondo. Il lunedì ora è un parametro, non un giorno della settimana da aspettare. La regola si legge in un file, ha una storia in git, compare nelle code review e si può rifattorizzare con l'IDE. Il database riceve solo il risultato e lo salva. È bravissimo a farlo.

## La cantina: la versione vera è quella in produzione

14:30\. Scendi in cantina e confronti le procedure nel repository con quelle in produzione. Nel repository c'è la cartella `sql/`, ultimo commit dell'aprile 2014\. In produzione invece trovi:

- `trg_fix`, `trg_fix2`, `trg_fix2_definitivo` e `trg_fix2_definitivo_VERO`, tutti attivi;
- una procedura con il commento `-- fix urgente 23:47, poi lo committo`, datato 2017;
- la funzione `nuova_calcola_iva`, che è la più vecchia del database.

Succede perché in PostgreSQL, come in molti altri database, `CREATE OR REPLACE FUNCTION` sostituisce la funzione e basta. Non c'è una cronologia, non c'è un «chi l'ha cambiata e perché». La versione precedente non viene archiviata da nessuna parte: viene semplicemente sovrascritta. Chiunque abbia la password del database può cambiare la logica di business in produzione, alle 23:47, con un client SQL. E la password di GESTIX ce l'hanno in sette, compreso un tirocinante del 2015.

La logica nell'applicazione, invece, segue la stessa strada di tutto il codice: commit, review, test, rilascio. Si può tenere anche l'SQL in git con le migrazioni, certo. Ma il database non ti costringe a farlo, e in quindici anni di vita vera nessuno l'ha fatto.

## La soffitta: l'unico pezzo che non si fotocopia

15:15\. In soffitta ci sono i conti. Quando il gestionale rallenta, i server applicativi si moltiplicano facilmente: ne accendi altri due e dividi il traffico, come aggiungere casse al supermercato. Il database principale no. Di solito ce n'è uno solo che accetta le scritture, ed è la macchina più cara e più delicata di tutto l'impianto.

Ogni ciclo di CPU che il database passa a calcolare sconti, generare email e rifare totali è un ciclo tolto all'unico lavoro che solo lui può fare: custodire i dati. E se il database è Oracle Enterprise Edition, ci sono anche le licenze: con la metrica «Processor» si conta per core, moltiplicando i core per un fattore che vale 0,5 per i comuni processori Intel Xeon e AMD EPYC. Tradotto: la promo del lunedì gira sul ferro più costoso dell'azienda, ed è pagata a core.

Anche il carico dei trigger è un problema reale, non un'ipotesi. Nel 2016 GitHub ha presentato **gh-ost**, il suo strumento per modificare lo schema delle tabelle MySQL, costruito apposta *senza trigger*. Gli strumenti basati sui trigger avevano causato blocchi quasi totali in produzione, e il server principale veniva messo in ginocchio dal carico dei trigger. In più, i trigger non si potevano mettere in pausa: toglierli significava perdere dati. Lì si parlava di migrazioni di schema, ma il principio è lo stesso di GESTIX: ogni trigger è codice che gira dentro ogni singola scrittura, che tu lo voglia o no.

## Il garage: «E se passassimo a PostgreSQL 18?»

16:00\. La CTO passa alla tua scrivania con il telefono in mano. «Hai visto? È uscito PostgreSQL 18\. Lasciamo Oracle e risparmiamo un sacco di licenze, no?»

Apri il garage. Ci sono 412 procedure in PL/SQL, e PL/SQL non è PL/pgSQL. Si somigliano come l'italiano e lo spagnolo: ti sembra di capire tutto, finché non ordini al ristorante. Gli strumenti di conversione esistono, ma non fanno miracoli. Il report di valutazione di AWS Schema Conversion Tool, per esempio, ha una scheda apposta, «Action items», che elenca proprio gli oggetti che **non si possono convertire automaticamente**, con i suggerimenti per riscriverli a mano.

Martin Fowler lo aveva scritto già nel 2003, in un articolo intitolato «Domain Logic and SQL»: *«If you want portability keep logic out of SQL»*, cioè «se vuoi la portabilità, tieni la logica fuori dall'SQL». Ventidue anni dopo, il tuo garage è la dimostrazione pratica. La migrazione del database sarebbe un trasloco. Con la logica dentro, è un trasloco in cui devi anche tradurre tutti i libri della libreria.

## Le fondamenta: quello che resta nel database (e guai a toccarlo)

17:00\. A questo punto potresti farti prendere la mano: via tutto, il database deve solo salvare e leggere. No. Prima di demolire, scendi a guardare le fondamenta, perché alcune cose nel database ci devono stare. E non sono «logica»: sono **igiene**.

Prova questa. Il form di registrazione controlla dal codice che l'email non esista già, poi la inserisce. Logica nell'applicazione, tutto pulito. Poi Rina della trattoria clicca due volte su «Registrati», perché il Wi-Fi è lento e la pazienza è poca:

```python
def registra(email):
    with psycopg.connect(DSN) as conn:
        esiste = conn.execute("SELECT count(*) FROM utenti WHERE email = %s", (email,)).fetchone()[0]
        partenza.wait()                       # i due click arrivano nello stesso istante
        if esiste == 0:
            try:
                conn.execute("INSERT INTO utenti (email) VALUES (%s)", (email,))
                print("registrato")
            except psycopg.errors.UniqueViolation as e:
                print("rifiutato:", e.diag.message_primary)
```

Lanciata con due thread in parallelo, prima senza vincolo e poi con un `UNIQUE` sulla colonna:

```
== senza vincolo
registrato
registrato
righe: 2
== con UNIQUE
registrato
rifiutato: duplicate key value violates unique constraint "utenti_email_key"
righe: 1
```

Senza vincolo, entrambi i click vedono «non esiste» e la stessa persona viene registrata due volte. Il controllo nell'applicazione non basta, perché tra il «controllo» e l'«inserisco» passa un istante, e in quell'istante può succedere di tutto. Solo il database vede tutte le transazioni insieme, quindi solo lui può garantire che un dato sia unico.

Quindi la regola onesta è questa:

- **Nel database:** chiavi primarie ed esterne, `NOT NULL`, `UNIQUE`, `CHECK` semplici (per esempio «lo sconto sta tra 0 e 0,5»), tipi giusti, transazioni. Il database decide *cos'è un dato valido*.
- **Nell'applicazione:** sconti, promozioni, email, chiamate al corriere, cosa succede quando cambia un listino. L'applicazione decide *cosa succede*.
- **Le query pesanti** restano al database. Nello stesso articolo del 2003, Fowler misurava che la versione in una sola query SQL era molto più veloce di quella in memoria. Un report che aggrega un milione di righe si fa in SQL, non scaricando il milione di righe in Java. Chiedere i dati al database va benissimo. Lasciargli prendere le decisioni no.

## Il notaio, di nuovo

18:30\. Torni dalla CTO con un foglio in mano.

«Accetto l'eredità», dici. «Ma con beneficio d'inventario.»

Lei ti guarda come si guarda qualcuno che ha passato troppo tempo in cantina. Ma l'espressione è giusta: nel codice civile è l'accettazione dell'eredità con cui gli eventuali debiti non diventano tuoi, e prima di tutto si fa l'inventario. Il tuo è questo: 412 procedure, 97 trigger, 4 versioni di `trg_fix2`, una tabella che manda email ai clienti immaginari e un bonsai, arrivato stamattina da Bordighera come regalo di benvenuto.

Il piano è semplice e per niente eroico: niente riscrittura totale. Si sposta un trigger per sprint, con i test scritti prima di toccarlo. Il primo a traslocare sarà la promo del lunedì. Poi l'email, prima che la Mensa Aziendale ordini altri rigatoni immaginari. I vincoli restano dove sono, e anzi ne aggiungi due.

Alle 18:45 arriva una cartolina digitale da Bordighera. C'è un bonsai, un tramonto e una sola riga:

> «Ricordati: `trg_storico` NON TOCCARLO. — O.»

Apri `trg_storico`. È l'unico dei 97 che non decide niente: scrive solo un diario di quello che combinano gli altri. Lo lasci dov'è. Per rispetto, e perché in questa casa è l'unico che dice la verità.

Fonti
- [PostgreSQL Global Development Group — «PostgreSQL 18 Released!» (25 settembre 2025)](https://www.postgresql.org/about/news/postgresql-18-released-3142/?ref=bitstales.eu)
- [Documentazione PostgreSQL 17 — CREATE FUNCTION (CREATE OR REPLACE)](https://www.postgresql.org/docs/17/sql-createfunction.html?ref=bitstales.eu)
- [Documentazione PostgreSQL 17 — Trigger functions in PL/pgSQL](https://www.postgresql.org/docs/17/plpgsql-trigger.html?ref=bitstales.eu)
- [GitHub Blog — Shlomi Noach, «gh-ost: GitHub's online schema migration tool for MySQL» (1 agosto 2016)](https://github.blog/news-insights/company-news/gh-ost-github-s-online-migration-tool-for-mysql/?ref=bitstales.eu)
- [Martin Fowler — «Domain Logic and SQL» (febbraio 2003)](https://martinfowler.com/articles/dblogic.html?ref=bitstales.eu)
- [AWS Schema Conversion Tool — Assessment report action items](https://docs.aws.amazon.com/SchemaConversionTool/latest/userguide/CHAP%5FAssessmentReport.ActionItems.html?ref=bitstales.eu)
- [Oracle — Processor Core Factor Table](https://www.oracle.com/assets/processor-core-factor-table-070634.pdf?ref=bitstales.eu)
- [Codice civile, art. 484 — Accettazione col beneficio d'inventario (Brocardi)](https://www.brocardi.it/codice-civile/libro-secondo/titolo-i/capo-v/sezione-ii/art484.html?ref=bitstales.eu)