cat swift/separare-dominio-infrastruttura.md
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:
Domain:Product,Price,ProductCategory;Application:ProductRepository,ProductCatalog;Infrastructure:HTTPProductRepository, DTO, mapping, cache;Tests: fake repository per il caso d’uso.
Obiettivo: il test di ProductCatalog non deve importare tipi DTO e non deve creare una URLRequest.