cat swift/dto-dominio-separare-api-modello.md

Lezione 50 45 min

DTO e dominio: separare API e modello interno

Il modello ricevuto dalla rete non deve contaminare il dominio: DTO, mapping e validazione tengono pulita l'architettura.

Una API esterna non è il tuo dominio. È un confine.

Se usi direttamente i modelli JSON in tutta l’app, stai permettendo alla forma della API di decidere la forma del tuo codice.

Meglio separare:

  • DTO: Data Transfer Object, rappresenta il formato esterno;
  • dominio: rappresenta valori validi per la tua applicazione.

DTO

struct ProductDTO: Decodable {
  let id: String
  let name: String
  let priceCents: Int
  let category: String?
}

Questo tipo risponde alla domanda: cosa manda la API?

Dominio

struct Product: Equatable, Sendable {
  let id: ProductID
  let name: String
  let price: Price
  let category: ProductCategory
}

Questo tipo risponde alla domanda: cosa considera valido il mio programma?

Mapping

extension ProductDTO {
  func toDomain() throws -> Product {
    guard let id = ProductID(id) else {
      throw ProductMappingError.invalidID
    }

    guard let price = Price(priceCents) else {
      throw ProductMappingError.invalidPrice
    }

    guard let category = ProductCategory(rawValue: category ?? "") else {
      throw ProductMappingError.invalidCategory
    }

    return Product(
      id: id,
      name: name,
      price: price,
      category: category
    )
  }
}

La conversione è il luogo giusto per validare.

Dove vive il mapping?

Il mapping dovrebbe stare vicino al DTO o nel target infrastruttura.

Infrastructure
  ProductDTO
  ProductDTO.toDomain()

Domain
  Product
  Price
  ProductCategory

Il dominio può essere conosciuto dall’infrastruttura, ma non il contrario. Se Product inizia a conoscere ProductDTO, il dominio dipende dalla forma della API.

Regola pratica:

DTO -> dominio: si
dominio -> DTO remoto: solo se stai costruendo una request e lo fai in un adapter

Errori di mapping

enum ProductMappingError: Error, Equatable {
  case invalidID
  case invalidPrice
  case invalidCategory
}

Non tutto e DecodingError. Il JSON può essere decodificabile ma non valido per il dominio.

Esempio:

{ "id": "p-1", "price_cents": -100 }

Decoding riuscito. Mapping fallito.

Perché non usare solo Codable nel dominio?

A volte va bene. Se la API è sotto il tuo controllo e coincide col dominio, puoi evitare DTO separati.

Ma separa quando:

  • la API e esterna;
  • i nomi non coincidono;
  • ci sono campi sporchi;
  • devi validare invarianti;
  • il dominio è usato anche da CLI/server/app;
  • vuoi testare mapping indipendentemente.

Mapping collection

let prodotti = try dtos.map { dto in
  try dto.toDomain()
}

Se vuoi scartare invalidi:

let prodottiValidi = dtos.compactMap { dto in
  try? dto.toDomain()
}

Attenzione: try? perde errori. Usalo solo se accetti di perdere diagnostica.

Esito ricco

Per mantenere errori:

let risultati = dtos.map { dto in
  Result {
    try dto.toDomain()
  }
}

Ora puoi sapere quali elementi sono falliti e perché.

Mapping parziale

A volte una lista contiene elementi validi e invalidi. Decidere cosa fare è una regola di prodotto.

Fallire tutto:

let prodotti = try dtos.map { try $0.toDomain() }

Restituire validi e diagnostica:

struct MappingReport<Value> {
  let values: [Value]
  let failures: [Error]
}
let report = dtos.reduce(into: MappingReport<Product>(values: [], failures: [])) { report, dto in
  do {
    report.values.append(try dto.toDomain())
  } catch {
    report.failures.append(error)
  }
}

Non usare compactMap { try? ... } se gli errori sono importanti per debug, analytics o supporto.

Test del mapping

Il mapping merita test propri:

@Test
func productDTORifiutaPrezzoNegativo() {
  let dto = ProductDTO(
    id: "p-1",
    name: "Manuale Swift",
    priceCents: -100,
    category: "books"
  )

  #expect(throws: ProductMappingError.invalidPrice) {
    try dto.toDomain()
  }
}

Questo test non coinvolge rete, cache o UI. Protegge il confine più importante: solo valori validi entrano nel dominio.

DTO in uscita

Se devi inviare dati alla API, crea DTO di request separati.

struct CreateProductRequestDTO: Encodable {
  let name: String
  let priceCents: Int

  enum CodingKeys: String, CodingKey {
    case name
    case priceCents = "price_cents"
  }
}

Non forzare il dominio a conformarsi al JSON solo per comodità.

Checklist

Separare DTO e dominio ti aiuta a:

  • proteggere invarianti;
  • evitare nomi API nel codice interno;
  • testare mapping;
  • gestire versioni API;
  • evitare optionals inutili nel dominio;
  • distinguere decoding da validazione.

In review, cerca questi segnali:

  • snake_case fuori dai DTO;
  • DTO passati alla UI;
  • DecodingError usato per regole di dominio;
  • optionals nel dominio solo perché la API manda null;
  • mapping non testato.

Esercizio

Crea:

  • ProductDTO;
  • Product;
  • ProductID;
  • Price;
  • ProductCategory;
  • ProductMappingError;
  • toDomain() throws -> Product.

Poi scrivi tre casi:

  • DTO valido;
  • prezzo negativo;
  • categoria sconosciuta.

Ogni caso deve produrre un risultato chiaro.