cat swift/laboratorio-motore-regole-coupon.md

Lezione 14 75 min

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:

  1. sostituisci Int con Prezzo dove ha senso;
  2. crea CodiceCoupon per impedire codici vuoti;
  3. valida percentuali fuori dal range 0...100;
  4. crea EsitoCoupon con totale originale, totale finale e sconto applicato;
  5. 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.