cat swift/laboratorio-motore-regole-coupon.md
Laboratorio: motore di regole coupon
Progetto guidato del Modulo 2: costruire un motore di regole con enum, pattern matching, generics, errori custom, access control ed extensions.
Questo laboratorio chiude il modulo sulla modellazione del dominio. Costruiremo un motore di regole per coupon.
L’obiettivo non è creare un sistema e-commerce completo. L’obiettivo è mostrare come Swift può rappresentare regole reali con tipi chiari e fallimenti espliciti.
Requisiti
Il motore deve:
- rappresentare prodotti, categorie, carrello e utente;
- rappresentare coupon con sconti diversi;
- applicare regole di validità;
- spiegare perché un coupon non è valido;
- calcolare il totale finale;
- evitare stati incoerenti dove possibile.
Modello base
enum CategoriaProdotto: Equatable {
case libri
case elettronica
case accessori
case casa
}
struct Prodotto: Equatable {
let id: String
let nome: String
let categoria: CategoriaProdotto
let prezzoInCent: Int
}
struct RigaCarrello: Equatable {
let prodotto: Prodotto
let quantita: Int
var subtotaleInCent: Int {
prodotto.prezzoInCent * quantita
}
}
Per concentrarci sul motore di regole, qui lasciamo prezzoInCent e quantita come Int. In un refactor successivo potremmo reinserire Prezzo e Quantita come value object validati.
Carrello
struct Carrello: Equatable {
private(set) var righe: [RigaCarrello]
init(righe: [RigaCarrello] = []) {
self.righe = righe
}
mutating func aggiungi(_ riga: RigaCarrello) {
righe.append(riga)
}
}
extension Carrello {
var totaleInCent: Int {
righe.reduce(0) { totale, riga in
totale + riga.subtotaleInCent
}
}
var categorie: Set<CategoriaProdotto> {
Set(righe.map { $0.prodotto.categoria })
}
}
private(set) permette di leggere le righe ma controlla le modifiche attraverso metodi espliciti.
Utente è contesto
struct Utente: Equatable {
let id: String
let haGiaOrdinato: Bool
}
struct ContestoCoupon: Equatable {
let carrello: Carrello
let utente: Utente
}
Il contesto raccoglie le informazioni necessarie alla validazione. Questo evita di passare molti parametri scollegati.
Sconti e regole
enum Sconto: Equatable {
case percentuale(Int)
case importoFisso(cent: Int)
case spedizioneGratuita
}
enum RegolaCoupon: Equatable {
case minimoCarrello(cent: Int)
case categoriaRichiesta(CategoriaProdotto)
case primoOrdineSolo
case utenteSpecifico(id: String)
}
struct Coupon: Equatable {
let codice: String
let sconto: Sconto
let regole: [RegolaCoupon]
}
Questo modello è compatto ma espressivo. Un coupon non ha flag sparsi: ha uno sconto è una lista di regole.
Errori custom
enum ErroreCoupon: Error, Equatable {
case carrelloSottoMinimo(richiesto: Int, attuale: Int)
case categoriaMancante(CategoriaProdotto)
case nonPrimoOrdine
case utenteNonAutorizzato
case scontoNonValido
}
Ogni errore rappresenta un motivo leggibile e testabile.
Motore
struct CouponEngine {
func applica(_ coupon: Coupon, al carrello: Carrello, utente: Utente) throws -> Int {
let contesto = ContestoCoupon(carrello: carrello, utente: utente)
try valida(coupon, nel: contesto)
return totaleApplicando(coupon.sconto, a: carrello.totaleInCent)
}
}
La funzione restituisce il totale finale in centesimi. In una versione più ricca potremmo restituire un tipo EsitoCoupon.
Validazione
extension CouponEngine {
func valida(_ coupon: Coupon, nel contesto: ContestoCoupon) throws {
for regola in coupon.regole {
try valida(regola, nel: contesto)
}
}
func valida(_ regola: RegolaCoupon, nel contesto: ContestoCoupon) throws {
switch regola {
case .minimoCarrello(let cent):
guard contesto.carrello.totaleInCent >= cent else {
throw ErroreCoupon.carrelloSottoMinimo(
richiesto: cent,
attuale: contesto.carrello.totaleInCent
)
}
case .categoriaRichiesta(let categoria):
guard contesto.carrello.categorie.contains(categoria) else {
throw ErroreCoupon.categoriaMancante(categoria)
}
case .primoOrdineSolo:
guard contesto.utente.haGiaOrdinato == false else {
throw ErroreCoupon.nonPrimoOrdine
}
case .utenteSpecifico(let id):
guard contesto.utente.id == id else {
throw ErroreCoupon.utenteNonAutorizzato
}
}
}
}
Lo switch è esaustivo. Se aggiungerai una regola, Swift ti chiederà di gestirla.
Calcolo dello sconto
private extension CouponEngine {
func totaleApplicando(_ sconto: Sconto, a totale: Int) -> Int {
switch sconto {
case .percentuale(let valore):
let riduzione = totale * valore / 100
return max(0, totale - riduzione)
case .importoFisso(let cent):
return max(0, totale - cent)
case .spedizioneGratuita:
return totale
}
}
}
La spedizione gratuita non cambia il totale prodotti. In un dominio più completo avremmo separato totale prodotti e costo spedizione.
Scenario
let libro = Prodotto(
id: "p-1",
nome: "Manuale Swift",
categoria: .libri,
prezzoInCent: 3000
)
let riga = RigaCarrello(prodotto: libro, quantita: 2)
let carrello = Carrello(righe: [riga])
let utente = Utente(id: "u-1", haGiaOrdinato: false)
let coupon = Coupon(
codice: "LIBRI10",
sconto: .percentuale(10),
regole: [
.minimoCarrello(cent: 5000),
.categoriaRichiesta(.libri),
.primoOrdineSolo
]
)
let engine = CouponEngine()
do {
let totale = try engine.applica(coupon, al: carrello, utente: utente)
print("Totale finale: \(totale)")
} catch {
print("Coupon non applicabile: \(error)")
}
Refactor guidato
Il codice funziona, ma non è finale. Miglioralo in questi passaggi:
- sostituisci
IntconPrezzodove ha senso; - crea
CodiceCouponper impedire codici vuoti; - valida percentuali fuori dal range
0...100; - crea
EsitoCouponcon totale originale, totale finale e sconto applicato; - aggiungi una funzione che raccoglie tutti gli errori invece di fermarsi al primo.
Checklist finale
Prima di chiudere il modulo, verifica:
- gli stati del dominio hanno nomi espliciti?
- gli enum eliminano stringhe e flag fragili?
- gli errori spiegano il fallimento?
- lo switch è esaustivo dove serve?
- l’access control protegge le modifiche?
- le extensions organizzano il codice senza nascondere confusione?
Se riesci a rispondere si, stai iniziando a scrivere Swift come linguaggio di modellazione, non come traduzione di pseudocodice.