Tutti abbiamo visto il progetto che parte direttamente dal codice. La prima settimana si va veloci. Poi arriva una domanda: «un commento può esistere senza un account?». Nessuno l'aveva posta, il database non lo permette e bisogna rifare tutto.
La progettazione serve a porre queste domande prima che costino care. Non è burocrazia. È il modo più economico di sbagliare.
Questo articolo segue un solo esempio dall'inizio alla fine: un blog con visitatori, membri e un amministratore. Si parte dal bisogno, scritto come user story, e si arriva allo script SQL che crea le tabelle.
Una catena, non un elenco di strumenti
Ogni passaggio risponde a una domanda precisa e alimenta il successivo.
| Passaggio | Domanda | Risultato |
|---|---|---|
| User story | Cosa vuole l'utente, e perché? | Una frase e dei criteri di accettazione |
| Caso d'uso | Chi fa cosa con il sistema? | Un diagramma e degli scenari |
| Diagramma di sequenza | Come comunicano le parti per arrivarci? | Un diagramma per ogni scenario importante |
| Modello concettuale | Quali dati e quali legami tra loro? | Entità, associazioni, cardinalità |
| Modello logico | Come organizzare questi dati in tabelle? | Tabelle, chiavi primarie ed esterne |
| Modello fisico | Come scriverlo per questo DBMS preciso? | Lo script SQL |
I primi tre passaggi vengono da UML e dai metodi agili. Gli ultimi tre seguono la progettazione concettuale, logica e fisica che in Italia si studia con il modello E-R (entità-relazione). In Francia lo stesso approccio si chiama Merise. In azienda questi strumenti si mescolano ogni giorno, ed è giusto così.
La user story
Il formato
Una user story descrive un bisogno dal punto di vista di chi ne trae vantaggio.
Come <ruolo>,
voglio <azione>,
così da <beneficio>.
Tre esempi per il nostro blog.
Come visitatore,
voglio cercare un articolo per parola chiave,
così da trovare in fretta un argomento che mi interessa.
Come membro,
voglio modificare il mio commento per qualche minuto,
così da correggere un errore di battitura.
Come amministratore,
voglio essere avvisato via email di un commento in attesa,
così da moderarlo rapidamente.
La terza parte, «così da», è quella che si dimentica più spesso. Eppure è la più utile. Spiega perché si fa il lavoro, e permette di proporre una soluzione migliore di quella richiesta.
I criteri di accettazione
Una story senza criteri di accettazione non può mai essere finita, perché nessuno sa che aspetto ha «fatto». La forma più diffusa viene da Gherkin: Dato, Quando, Allora.
Scenario: modificare il commento in tempo
Dato un membro connesso che ha pubblicato un commento 5 minuti fa
Quando modifica il testo e salva
Allora il nuovo testo sostituisce quello vecchio
Scenario: troppo tardi
Dato un membro connesso che ha pubblicato un commento 20 minuti fa
Quando apre l'articolo
Allora il pulsante «Modifica» non compare più su quel commento
Ogni scenario diventa un test automatico quasi parola per parola. È il vero valore del formato.
Una buona story rispetta INVEST
- Independent (indipendente): può essere consegnata senza aspettarne un'altra
- Negotiable (negoziabile): è l'inizio di una discussione, non un contratto rigido
- Valuable (di valore): porta qualcosa a un utente
- Estimable (stimabile): il team sa dire più o meno quanto costa
- Small (piccola): sta in un'iterazione
- Testable (verificabile): si sa come controllare che sia fatta
Le trappole
La story tecnica travestita.
Come sviluppatore,
voglio creare la tabella comments,
così da salvare i commenti.
Nessun utente trae vantaggio da una tabella. È un'attività tecnica, e va messa sotto una vera story.
La story troppo grande. «Come membro, voglio gestire il mio account» nasconde una registrazione, un accesso, un cambio di password e una cancellazione. È una epic. Va divisa.
Il diagramma dei casi d'uso
Cosa mostra
Dà una visione d'insieme: chi usa il sistema, e per fare cosa. Non mostra né le schermate, né l'ordine delle azioni, né i dati.
I suoi elementi:
- l'attore: un ruolo esterno al sistema, umano o no. «Visitatore», non «Paolo»
- il caso d'uso: un obiettivo dell'attore, espresso con un verbo
- il confine: il limite del sistema
- le relazioni: associazione, generalizzazione, inclusione ed estensione
L'esempio
Mermaid, che disegna gli altri diagrammi di questo articolo, non gestisce i diagrammi dei casi d'uso. Questo è quindi disegnato con PlantUML, uno strumento che rispetta la notazione UML: un omino per ogni attore, un'ellisse per ogni caso d'uso e una freccia con triangolo vuoto per l'ereditarietà.

Il suo codice sorgente, da incollare così com'è sul sito di PlantUML o in un plugin dell'IDE:
@startuml
left to right direction
actor Visitatore
actor Membro
actor Amministratore
Membro --|> Visitatore
Amministratore --|> Membro
rectangle "Blog" {
usecase "Leggere un articolo" as UC1
usecase "Cercare un articolo" as UC2
usecase "Commentare un articolo" as UC3
usecase "Rispondere a un commento" as UC4
usecase "Verificare il contenuto\n(anti-spam)" as UC5
usecase "Moderare i commenti" as UC6
usecase "Pubblicare un articolo" as UC7
}
Visitatore --> UC1
Visitatore --> UC2
Visitatore --> UC3
Amministratore --> UC6
Amministratore --> UC7
UC3 ..> UC5 : <<include>>
UC4 ..> UC3 : <<extend>>
@enduml
Le relazioni, senza fraintendimenti
La generalizzazione si legge «è un». Un membro è un visitatore. Può quindi fare tutto ciò che fa un visitatore, più le proprie azioni. Inutile collegare il membro a «Leggere un articolo»: è ereditato.
L'inclusione (include) indica un comportamento eseguito sempre dal caso base. Commentare avvia sempre il controllo anti-spam. La freccia va dal caso base verso il caso incluso.
L'estensione (extend) indica un comportamento opzionale, che avviene solo a una certa condizione. Rispondere a un commento è una variante di «commentare», usata solo quando si risponde a un messaggio preciso. La freccia va dall'estensione verso il caso base.
L'errore più frequente è spezzare un caso d'uso in passi con degli include: «scrivere il testo», «cliccare su invia», «mostrare il messaggio». Non è più un diagramma dei casi d'uso, è un diagramma di flusso disegnato male. Lo svolgimento di un caso si descrive altrove.
La descrizione testuale
Il diagramma non basta. Ogni caso importante è accompagnato da una scheda.
Caso d'uso: Commentare un articolo
Attore principale: Visitatore
Precondizione: l'articolo è pubblicato
Scenario principale
1. Il visitatore inserisce nome, email e commento
2. Il sistema verifica il contenuto (anti-spam)
3. Il sistema salva il commento in attesa di moderazione
4. Il sistema avvisa l'amministratore via email
5. Il sistema mostra un messaggio di conferma
Scenari alternativi
2a. Il contenuto viene rifiutato: il sistema mostra il modulo con l'errore
3a. L'attore è un membro connesso: il commento viene pubblicato subito
Postcondizione: il commento esiste nel database
Il diagramma di sequenza
Cosa mostra
Prende uno scenario e mostra chi invia quale messaggio a chi, in quale ordine. Il tempo scorre dall'alto verso il basso.
I suoi elementi:
- la linea di vita: un partecipante, con una linea verticale sotto di sé
- il messaggio sincrono (freccia piena,
->>in Mermaid): il mittente aspetta la risposta - il messaggio asincrono (freccia aperta,
-)): il mittente non aspetta. Un'email messa in coda, per esempio - il ritorno (freccia tratteggiata,
-->>): la risposta a un messaggio - la barra di attivazione: il periodo in cui il partecipante lavora. In Mermaid, un
+o un-subito dopo la freccia la apre o la chiude - i frammenti:
altper un'alternativa,optper un'opzione,loopper un ciclo
L'esempio
Lo scenario «un membro commenta un articolo», così come si svolge davvero in un'applicazione Laravel.
sequenceDiagram
actor Membro
participant Nav as Browser
participant Router as Router Laravel
participant Req as StoreCommentRequest
participant Ctrl as BlogController
participant Model as Comment
participant DB as Database
participant Mail as Servizio email
Membro->>Nav: invia il modulo
Nav->>+Router: POST /blog/{slug}/comment
Router->>+Req: validare i dati
alt dati non validi
Req-->>Nav: 302 verso il modulo + errori
else dati validi
Req-->>-Router: dati validati
Router->>+Ctrl: addComment(request, slug)
Ctrl->>Model: create(dati)
Model->>DB: INSERT INTO comments
DB-->>Model: id
Model-->>Ctrl: Comment
opt risposta a un commento
Ctrl->>Mail: avvisare l'autore interessato
end
Ctrl-->>-Nav: 302 verso l'articolo + messaggio
end
deactivate Router
Un dettaglio mostra perché l'esercizio vale la pena. In Laravel, una FormRequest viene validata prima che il controller sia chiamato. Quando i dati non sono validi, il controller non viene mai eseguito. Disegnare la sequenza obbliga a saperlo.
Il giusto livello di dettaglio
Un diagramma di sequenza non è codice disegnato. Non ci si mettono i getter né ogni chiamata di funzione. Si tengono gli scambi che servono a capire lo scenario, e se ne fa uno per ogni scenario importante, non uno per metodo.
I tre livelli della modellazione dei dati
La progettazione di un database si divide in tre livelli.
- Concettuale: quali dati, senza pensare alla tecnologia
- Logico: come organizzarli in tabelle
- Fisico: come scriverli per un DBMS preciso
Ogni livello si ricava dal precedente con regole semplici. È questo che rende l'approccio così pratico.
Il dizionario dei dati
Prima di disegnare, si elencano le informazioni da salvare, con il loro tipo e i loro vincoli.
| Dato | Tipo | Vincolo |
|---|---|---|
| username | testo, 30 caratteri | obbligatorio, unico |
| testo, 255 caratteri | obbligatorio, unico | |
| titolo | testo | obbligatorio |
| contenuto del commento | testo lungo | obbligatorio |
| data di pubblicazione | data e ora | facoltativo |
| etichetta del tag | testo, 50 caratteri | obbligatorio, unico |
È anche il momento di eliminare i dati calcolati. Il numero di commenti di un articolo non si salva: si conta.
Il modello concettuale
Il modello concettuale contiene tre tipi di elementi.
- L'entità: un oggetto del dominio, con le sue proprietà. UTENTE, ARTICOLO
- L'identificatore: la proprietà che distingue ogni occorrenza. id_articolo
- L'associazione: un legame tra entità, con il nome di un verbo. SCRIVE, RIGUARDA
Il nostro modello concettuale, in forma testuale:
UTENTE (id_utente, username, email, password)
ARTICOLO (id_articolo, titolo, contenuto, data_pubblicazione)
COMMENTO (id_commento, contenuto, data_creazione)
TAG (id_tag, etichetta)
UTENTE 0,n ---- SCRIVE ---- 0,1 COMMENTO
ARTICOLO 0,n ---- RIGUARDA -- 1,1 COMMENTO
ARTICOLO 0,n ---- ETICHETTA - 0,n TAG
UTENTE 0,n ---- APPREZZA -- 0,n ARTICOLO
(data_apprezzamento)
Leggere le cardinalità
In questa notazione, le cardinalità stanno accanto a ogni entità. Dicono quante volte un'occorrenza di questa entità partecipa all'associazione, prima il minimo, poi il massimo.
| Cardinalità | Significato |
|---|---|
| 0,1 | al massimo una volta, forse mai |
| 1,1 | esattamente una volta |
| 0,n | quante volte si vuole, forse mai |
| 1,n | almeno una volta |
Si legge sempre dall'entità verso l'associazione:
- un utente scrive da 0 a n commenti
- un commento è scritto da 0 o 1 utente
Quello 0,1 non è un errore. Sul nostro blog, un visitatore può commentare senza account. Il commento allora non ha nessun utente. È esattamente la domanda dell'inizio dell'articolo, e il modello concettuale l'ha posta prima che esistesse una sola tabella.
Al contrario, un commento riguarda esattamente un articolo (1,1). Un commento senza articolo non ha senso.
Attenzione se vieni da UML: in un diagramma delle classi, le molteplicità stanno dall'altra parte dell'associazione. Il significato è lo stesso, cambia solo la posizione.
L'associazione che porta dati
APPREZZA porta una proprietà: la data in cui il membro ha apprezzato l'articolo. Questa data non appartiene né all'utente né all'articolo. Appartiene alla coppia. È il segno che deve stare nell'associazione.
Il modello logico: tre regole di traduzione
Regola 1. Ogni entità diventa una tabella. Il suo identificatore diventa la chiave primaria.
Regola 2. Un'associazione con un lato x,1 e un lato x,n scompare. L'identificatore dell'entità sul lato x,n diventa una chiave esterna nella tabella sul lato x,1.
- lato 1,1: la chiave esterna è obbligatoria (NOT NULL)
- lato 0,1: la chiave esterna è facoltativa (NULL ammesso)
Regola 3. Un'associazione x,n da entrambi i lati diventa una tabella. La sua chiave primaria è composta dalle due chiavi esterne. Anche le proprietà dell'associazione vanno lì.
Il risultato, in notazione testuale. La chiave primaria è in grassetto, le chiavi esterne iniziano con #.
- UTENTE (id_utente, username, email, password)
- ARTICOLO (id_articolo, titolo, contenuto, data_pubblicazione)
- TAG (id_tag, etichetta)
- COMMENTO (id_commento, contenuto, data_creazione, #id_utente, #id_articolo)
- ETICHETTA (#id_articolo, #id_tag)
- APPREZZA (#id_utente, #id_articolo, data_apprezzamento)
SCRIVE e RIGUARDA sono scomparse: sono diventate le chiavi esterne di COMMENTO (regola 2). ETICHETTA e APPREZZA sono diventate tabelle (regola 3).
Un ultimo caso merita di essere conosciuto: l'associazione 0,1 / 1,1, un legame uno a uno. La chiave esterna va sul lato 1,1, con un vincolo di unicità per garantire che compaia una sola volta.
Le forme normali
Un modello logico ben costruito rispetta almeno le prime tre forme normali.
- 1FN: ogni colonna contiene un solo valore. Niente colonna tags che vale «php, laravel, sql»
- 2FN: in 1FN, e ogni colonna dipende da tutta la chiave primaria. In APPREZZA, una colonna username dipenderebbe solo da id_utente: non c'entra nulla
- 3FN: in 2FN, e nessuna colonna dipende da un'altra colonna non chiave. Salvare il CAP e la città nella stessa tabella ricopia la città ogni volta
Un modello concettuale corretto dà in genere un modello logico già normalizzato. Le forme normali servono soprattutto a verificare.
Il modello fisico
Il modello fisico traduce il modello logico per un DBMS preciso. Porta decisioni che prima non esistevano: tipi esatti, lunghezze, indici, comportamento alla cancellazione, convenzioni di nomenclatura.
Qui, MySQL con le convenzioni di Laravel: nomi in inglese, tabelle al plurale, in snake_case. UTENTE diventa users, ETICHETTA diventa article_tag. Rinominare fa parte delle decisioni fisiche, il modello non cambia.
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(30) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE articles (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content LONGTEXT NOT NULL,
published_at TIMESTAMP NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE tags (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(50) NOT NULL UNIQUE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE comments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
user_id BIGINT UNSIGNED NULL,
article_id BIGINT UNSIGNED NOT NULL,
CONSTRAINT fk_comments_user
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT fk_comments_article
FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE article_tag (
article_id BIGINT UNSIGNED NOT NULL,
tag_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (article_id, tag_id),
FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
CREATE TABLE likes (
user_id BIGINT UNSIGNED NOT NULL,
article_id BIGINT UNSIGNED NOT NULL,
liked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, article_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
Lo stesso schema, disegnato da Mermaid:
erDiagram
users |o--o{ comments : "scrive"
articles ||--o{ comments : "riguarda"
articles ||--o{ article_tag : "ha"
tags ||--o{ article_tag : "etichetta"
users ||--o{ likes : "apprezza"
articles ||--o{ likes : "è apprezzato"
users {
bigint id PK
varchar username UK
varchar email UK
varchar password
}
articles {
bigint id PK
varchar title
longtext content
timestamp published_at
}
comments {
bigint id PK
text content
timestamp created_at
bigint user_id FK "NULL ammesso"
bigint article_id FK
}
tags {
bigint id PK
varchar label UK
}
article_tag {
bigint article_id PK, FK
bigint tag_id PK, FK
}
likes {
bigint user_id PK, FK
bigint article_id PK, FK
timestamp liked_at
}
Questo diagramma usa la notazione detta «a zampa di gallina» (crow's foot). || significa esattamente uno, |o zero o uno, o{ zero o molti. Sono le cardinalità del modello concettuale, messe dall'altra parte della linea: lo |o vicino a users si legge «un commento ha zero o un autore».
Ogni scelta si giustifica con il modello concettuale:
user_idaccetta NULL perché la cardinalità era 0,1ON DELETE SET NULLconserva i commenti di un account cancellato, senza autoreON DELETE CASCADEsuarticle_idcancella i commenti insieme al loro articolo, perché un commento non esiste senza articolo- la chiave primaria composta di
likesimpedisce di apprezzare due volte lo stesso articolo utf8mb4accetta tutti i caratteri, comprese le emoji che i visitatori non mancheranno di scrivere
Con Laravel, il modello fisico si scrive sotto forma di migrazioni invece che in SQL puro. Il ragionamento resta esattamente lo stesso.
E il diagramma delle classi?
Nei progetti software, i dati si modellano spesso anche con un diagramma delle classi UML. I due approcci si somigliano, ma non servono alla stessa cosa.
- il modello concettuale descrive dei dati e i loro legami. Nient'altro
- il diagramma delle classi descrive degli oggetti: i loro dati, ma anche i loro metodi, l'ereditarietà e le interfacce
Con un ORM, le cardinalità si ritrovano direttamente nel codice.
| Cardinalità del modello | Eloquent | JPA |
|---|---|---|
| x,n / x,1 | hasMany e belongsTo |
@OneToMany e @ManyToOne |
| x,1 / 0,1 | hasOne e belongsTo |
@OneToOne |
| x,n / x,n | belongsToMany |
@ManyToMany |
| x,n / x,n con proprietà | belongsToMany e withPivot() |
un'entità dedicata con due @ManyToOne |
L'ultima riga è importante. Appena un'associazione porta dati, come la data di APPREZZA, JPA richiede una vera entità per la tabella di associazione. Un @ManyToMany non sa salvare una colonna in più.
Cosa tenere a mente
La progettazione è uno strumento di discussione. Un diagramma che nessuno legge non serve a nulla. Un diagramma che fa dire a qualcuno «ah no, un commento può esistere senza account» ha appena fatto risparmiare una settimana.
Non si fanno tutti i diagrammi per ogni funzionalità. Un modulo di contatto non ha bisogno di un diagramma di sequenza. Una funzionalità che tocca i pagamenti sì.
Un modello vive con il progetto. Quando il database cambia, cambia anche il modello. Altrimenti mente, e un documento che mente è peggio di nessun documento.
Per approfondire
- Mermaid (in inglese), la sintassi dei diagrammi di questo articolo, che anche GitHub e GitLab mostrano direttamente
- Mermaid Live Editor (in inglese), per provare un diagramma e vedere il risultato in diretta
- PlantUML (in inglese), per la notazione UML completa, compresi i veri diagrammi dei casi d'uso
- La specifica UML dell'OMG (in inglese), il riferimento ufficiale, da consultare più che da leggere
- User Stories Applied di Mike Cohn, il libro di riferimento sulle user story
- UML Distilled di Martin Fowler, breve e pragmatico
Un esercizio per finire. Prendi una funzionalità del tuo progetto attuale e scrivi la sua user story con due criteri di accettazione. Poi disegna la parte del modello dei dati che tocca. Se una cardinalità ti fa esitare, è una domanda da fare a qualcuno prima di scrivere codice.
Commenti (0)
Lascia un commento
Puoi commentare indicando il tuo nome e la tua email. Il messaggio sarà pubblicato dopo la moderazione. Con un account appare subito e resta modificabile.
Ancora nessun commento
Sii il primo a commentare questo articolo!