cat swift/separare-dominio-infrastruttura.md

Lezione 5755 min

Separare dominio e infrastruttura

Imparare a distinguere regole di business, adattatori tecnici e confini di sistema per mantenere il dominio stabile.

Separare dominio e infrastruttura non significa disegnare cartelle eleganti. Significa impedire che una regola importante dipenda da dettagli fragili.

Il dominio parla il linguaggio del problema:

  • prodotto;
  • prezzo;
  • prenotazione;
  • scadenza;
  • coupon;
  • disponibilità;
  • ordine;
  • saldo;
  • transazione.

L’infrastruttura parla il linguaggio dei mezzi:

  • HTTP;
  • JSON;
  • database;
  • file;
  • cache;
  • keychain;
  • notifiche;
  • SDK esterni;
  • URLSession;
  • UserDefaults.

Quando questi due vocabolari si mescolano troppo presto, il codice diventa difficile da cambiare.

Segnale di confusione

Questo servizio sembra innocente:

struct BookingService {
  func availableRooms(date: Date) async throws -> [Room] {
    let url = URL(string: "https://hotel.example.com/api/rooms?date=\\(date)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    let dto = try JSONDecoder().decode(RoomsResponseDTO.self, from: data)

    return dto.rooms
      .filter { $0.is_available }
      .map { Room(id: $0.id, name: $0.name) }
  }
}

Fa troppe cose:

  • costruisce URL;
  • decide il formato della query;
  • chiama la rete;
  • decodifica JSON;
  • conosce snake_case;
  • filtra disponibilità;
  • costruisce dominio.

Se cambia il backend, tocchi una regola di prenotazione. Se cambia la regola, rischi di rompere il networking.

Tre livelli pratici

Per un corso Swift completo basta una separazione pragmatica:

Domain
  tipi, regole, errori di dominio

Application
  casi d'uso, orchestration, protocolli richiesti

Infrastructure
  URLSession, database, file system, DTO, mapping tecnico

Non serve chiamarla Clean Architecture. Serve capire la direzione delle dipendenze.

Il dominio non deve importare l’infrastruttura.

Dominio

struct Room: Equatable, Sendable, Identifiable {
  let id: String
  let name: String
  let capacity: Int
}

struct Stay: Equatable, Sendable {
  let checkIn: Date
  let checkOut: Date
}

enum BookingError: Error, Equatable, Sendable {
  case invalidStay
  case noRoomAvailable
}

Il dominio non sa da dove arrivano le camere.

Porta applicativa

Il caso d’uso definisce cosa gli serve:

protocol RoomRepository: Sendable {
  func availableRooms(for stay: Stay) async throws -> [Room]
}

struct FindRoomUseCase: Sendable {
  private let repository: RoomRepository

  init(repository: RoomRepository) {
    self.repository = repository
  }

  func execute(stay: Stay, guests: Int) async throws -> Room {
    guard stay.checkIn < stay.checkOut else {
      throw BookingError.invalidStay
    }

    let rooms = try await repository.availableRooms(for: stay)

    guard let room = rooms.first(where: { $0.capacity >= guests }) else {
      throw BookingError.noRoomAvailable
    }

    return room
  }
}

Il caso d’uso conosce il protocollo, non l’implementazione.

Infrastruttura

L’adattatore HTTP implementa il protocollo:

struct HTTPRoomRepository: RoomRepository {
  let client: HTTPClient
  let decoder: JSONDecoder

  func availableRooms(for stay: Stay) async throws -> [Room] {
    let endpoint = RoomsEndpoint(stay: stay)
    let data = try await client.get(endpoint)
    let response = try decoder.decode(RoomsResponseDTO.self, from: data)
    return try response.rooms.map { try $0.toDomain() }
  }
}

Qui è normale vedere DTO, HTTP e decoder. Sono dettagli di infrastruttura.

DTO separato dal dominio

struct RoomDTO: Decodable {
  let id: String
  let name: String
  let capacity: Int?
  let isAvailable: Bool

  enum CodingKeys: String, CodingKey {
    case id
    case name
    case capacity
    case isAvailable = "is_available"
  }
}

Mapping:

extension RoomDTO {
  func toDomain() throws -> Room {
    guard let capacity, capacity > 0 else {
      throw MappingError.invalidCapacity
    }

    return Room(id: id, name: name, capacity: capacity)
  }
}

Il DTO può essere brutto perché rappresenta il mondo esterno. Il dominio deve restare pulito.

La regola delle importazioni

Un controllo semplice:

Domain        importa Foundation se serve, ma non importa Networking
Application   importa Domain
Infrastructure importa Domain + Application + Foundation
UI/CLI        importa Application + Infrastructure

Se Domain importa URLSession, probabilmente hai spostato un dettaglio tecnico troppo in alto.

Foundation nel dominio

Foundation non è proibita. Date, Decimal, URL o UUID possono apparire nel dominio se hanno significato reale.

La domanda non è “posso importare Foundation?”. La domanda è:

Questo tipo rappresenta una regola del problema o un dettaglio tecnico?

Date in una prenotazione è dominio. URLRequest no.

Errori di dominio ed errori tecnici

Errore di dominio:

enum BookingError: Error, Equatable {
  case invalidStay
  case noRoomAvailable
}

Errore tecnico:

enum NetworkError: Error {
  case invalidResponse
  case statusCode(Int)
  case decoding
  case transport(Error)
}

Il caso d’uso dovrebbe esporre errori utili al chiamante. Non sempre devi nascondere ogni errore tecnico, ma devi decidere consapevolmente.

Test più semplici

Separare dominio e infrastruttura rende il test del caso d’uso banale:

struct FakeRoomRepository: RoomRepository {
  let rooms: [Room]

  func availableRooms(for stay: Stay) async throws -> [Room] {
    rooms
  }
}

Test:

import Testing

@Test
func sceglieCameraConCapienzaSufficiente() async throws {
  let repository = FakeRoomRepository(rooms: [
    Room(id: "small", name: "Small", capacity: 1),
    Room(id: "suite", name: "Suite", capacity: 4)
  ])

  let useCase = FindRoomUseCase(repository: repository)

  let room = try await useCase.execute(
    stay: Stay(checkIn: .now, checkOut: .now.addingTimeInterval(86_400)),
    guests: 3
  )

  #expect(room.id == "suite")
}

Nessuna rete. Nessun JSON. Nessun backend.

Quando non separare troppo

Non ogni app piccola ha bisogno di tre target, venti protocolli e cartelle cerimoniali.

Segnali che puoi restare semplice:

  • script CLI monouso;
  • prototipo esplorativo;
  • feature con una sola chiamata banale;
  • dominio quasi inesistente;
  • costo del cambiamento molto basso.

Segnali che conviene separare:

  • regole importanti;
  • più fonti dati;
  • test necessari;
  • UI o CLI alternative;
  • API instabile;
  • team di più persone;
  • package riusabile.

Checklist

Una separazione è buona quando:

  • il dominio si legge senza conoscere HTTP o database;
  • i casi d’uso dipendono da protocolli piccoli;
  • gli adattatori tecnici stanno ai bordi;
  • i DTO non invadono l’app;
  • gli errori sono mappati con intenzione;
  • puoi testare una regola senza rete o file system;
  • le cartelle riflettono responsabilità, non moda.

Esercizio

Prendi il client SDK del Modulo 7.

Separalo in:

  1. Domain: Product, Price, ProductCategory;
  2. Application: ProductRepository, ProductCatalog;
  3. Infrastructure: HTTPProductRepository, DTO, mapping, cache;
  4. Tests: fake repository per il caso d’uso.

Obiettivo: il test di ProductCatalog non deve importare tipi DTO e non deve creare una URLRequest.