cat swift/dependency-injection-semplice.md
Dependency injection semplice
Capire la dependency injection in Swift come scelta di design quotidiana: passare dipendenze esplicite invece di nasconderle.
Dependency injection significa una cosa molto semplice: un tipo non si costruisce da solo tutto ciò di cui ha bisogno.
Invece di fare questo:
struct ProductService {
private let session = URLSession.shared
private let decoder = JSONDecoder()
}
fai questo:
struct ProductService {
private let client: HTTPClient
init(client: HTTPClient) {
self.client = client
}
}
La differenza sembra piccola, ma cambia tutto: il codice diventa configurabile, testabile e meno accoppiato al mondo esterno.
Il problema delle dipendenze nascoste
Una dipendenza nascosta è una decisione presa dentro un tipo, invece che ricevuta dall’esterno.
Esempi tipici:
URLSession.shared;Date();UUID();FileManager.default;UserDefaults.standard;- database globale;
- singleton applicativi;
- variabili statiche mutabili;
- clock reale;
- rete reale.
Queste cose non sono sbagliate. Sono sbagliate quando appaiono nel posto che vuoi testare.
struct SubscriptionService {
func isExpired(_ subscription: Subscription) -> Bool {
subscription.expiresAt < Date()
}
}
Il codice funziona, ma il test dipende dall’orologio reale. Oggi passa, domani magari no.
Iniettare un clock
Partiamo con una versione esplicita:
struct SubscriptionService {
let now: () -> Date
func isExpired(_ subscription: Subscription) -> Bool {
subscription.expiresAt < now()
}
}
Nel codice di produzione:
let service = SubscriptionService(now: Date.init)
Nel test:
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
let service = SubscriptionService(now: { fixedDate })
Non abbiamo introdotto framework, container o magia. Abbiamo solo reso esplicita una dipendenza.
Funzioni come dipendenze
In Swift, una dipendenza può essere una funzione.
Questo è ottimo per dipendenze piccole:
struct IDGenerator {
let makeID: () -> UUID
}
Oppure:
struct PriceFormatter {
let format: (Price) -> String
}
Le funzioni sono leggere, leggibili e facili da sostituire nei test.
Protocolli come dipendenze
Quando una dipendenza ha più operazioni o rappresenta un confine importante, un protocollo è spesso più chiaro.
protocol ProductRepository: Sendable {
func product(id: Product.ID) async throws -> Product
func search(query: String) async throws -> [Product]
}
Il servizio di dominio dipende dal comportamento, non dall’implementazione:
struct ProductCatalog {
private let repository: ProductRepository
init(repository: ProductRepository) {
self.repository = repository
}
func featuredProduct(id: Product.ID) async throws -> ProductCard {
let product = try await repository.product(id: id)
return ProductCard(product: product)
}
}
Nel codice reale puoi usare un repository HTTP. Nel test puoi usare un fake in memoria.
Iniezione tramite initializer
La forma più sana, nella maggior parte dei casi, è l’iniezione tramite initializer.
struct CheckoutService {
private let pricing: PricingEngine
private let payments: PaymentGateway
private let clock: Clock
init(
pricing: PricingEngine,
payments: PaymentGateway,
clock: Clock
) {
self.pricing = pricing
self.payments = payments
self.clock = clock
}
}
Vantaggi:
- le dipendenze si vedono subito;
- il tipo non può esistere in uno stato incompleto;
- i test non devono modificare stato globale;
- il compilatore ti aiuta quando cambia una dipendenza.
Valori di default: utili, ma con misura
Per rendere ergonomica l’API puoi offrire default di produzione:
extension ProductService {
static func live(baseURL: URL) -> ProductService {
ProductService(
client: URLSessionHTTPClient(baseURL: baseURL)
)
}
}
Oppure:
init(
client: HTTPClient = URLSessionHTTPClient.live
) {
self.client = client
}
Il rischio è nascondere di nuovo le decisioni. Usa default quando migliorano l’esperienza, ma non quando rendono ambiguo il comportamento.
Dipendenze e Swift 6
Con Swift 6 e il controllo più severo della concorrenza, le dipendenze devono essere ragionate anche rispetto a Sendable.
protocol HTTPClient: Sendable {
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
Se una dipendenza attraversa task, actor o codice concorrente, chiediti:
- conserva stato mutabile?
- è condivisa tra più task?
- è protetta da actor?
- può essere
Sendable?
La testabilità moderna non è separata dalla concurrency safety.
Evitare il service locator
Un service locator nasconde le dipendenze dietro un contenitore globale:
let client = Dependencies.shared.httpClient
Questo rende il codice comodo da scrivere è difficile da capire.
Il problema non è avere un oggetto di configurazione. Il problema è quando ogni tipo può prendere qualunque cosa da un contenitore globale senza dichiararlo.
Meglio:
struct SearchFeature {
let repository: ProductRepository
let analytics: AnalyticsClient
}
Il tipo dice di cosa ha bisogno.
Mini refactor
Versione fragile:
struct CurrencyConverter {
func convert(_ amount: Decimal, from: String, to: String) async throws -> Decimal {
let url = URL(string: "https://api.example.com/rates")!
let (data, _) = try await URLSession.shared.data(from: url)
let rates = try JSONDecoder().decode(RatesDTO.self, from: data)
return amount * rates.value(from: from, to: to)
}
}
Versione testabile:
protocol RatesRepository: Sendable {
func rate(from: Currency, to: Currency) async throws -> Decimal
}
struct CurrencyConverter: Sendable {
private let rates: RatesRepository
init(rates: RatesRepository) {
self.rates = rates
}
func convert(_ amount: Money, to target: Currency) async throws -> Money {
let rate = try await rates.rate(from: amount.currency, to: target)
return Money(amount: amount.value * rate, currency: target)
}
}
Ora il dominio non conosce URL, JSON o sessioni.
Checklist
Una dipendenza è ben gestita quando:
- è visibile nell’initializer;
- ha un nome di dominio, non tecnico quando possibile;
- può essere sostituita nei test;
- non obbliga i test a usare rete, disco o tempo reale;
- non espone dettagli inutili;
- è compatibile con
Sendablese entra in codice concorrente; - non richiede un framework di dependency injection per essere capita.
Esercizio
Prendi un servizio che usa Date(), UUID() o URLSession.shared.
Refactor richiesto:
- individua le dipendenze nascoste;
- estrai funzioni o protocolli minimi;
- passa le dipendenze dall’initializer;
- crea una configurazione
live; - scrivi un test con valori controllati.
Obiettivo: il test deve poter fallire solo per un errore di logica, non per rete, orologio o stato globale.