cat swift/dependency-injection-semplice.md

Lezione 56 50 min

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 Sendable se 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:

  1. individua le dipendenze nascoste;
  2. estrai funzioni o protocolli minimi;
  3. passa le dipendenze dall’initializer;
  4. crea una configurazione live;
  5. 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.