cat swift/snapshot-concettuale-architettura.md

Lezione 61 55 min

Snapshot concettuale dell'architettura

Disegnare una mappa mentale dell'architettura Swift senza trasformarla in dogma: dipendenze, moduli, flussi e confini.

Uno snapshot concettuale è una fotografia ragionata del sistema.

Non è un diagramma decorativo. Serve a rispondere velocemente a domande pratiche:

  • dove vive questa regola?
  • chi chiama la rete?
  • dove si mappano i DTO?
  • cosa posso testare senza infrastruttura?
  • quale modulo dipende da quale?
  • dove entra la UI?

Architettura minima

Per il corso, useremo questa mappa:

UI / CLI
  |
  v
Application
  |
  v
Domain

Infrastructure
  ^
  |
Composition Root

In pratica:

  • Domain contiene tipi e regole;
  • Application contiene casi d’uso e protocolli;
  • Infrastructure implementa i protocolli con rete, cache, file, database;
  • UI / CLI presenta stato e comandi;
  • Composition Root assembla le dipendenze concrete.

Direzione delle dipendenze

Il punto più importante:

Application dipende da Domain.
Infrastructure dipende dai protocolli applicativi.
Domain non dipende da Infrastructure.

Questo permette di testare l’applicazione con fake e stub.

Composition root

Il composition root è il punto in cui costruisci il grafo reale.

In una CLI può essere main.swift:

@main
struct ProductsCLI {
  static func main() async throws {
    let baseURL = URL(string: "https://api.example.com")!
    let client = URLSessionHTTPClient(baseURL: baseURL)
    let repository = HTTPProductRepository(client: client)
    let useCase = LoadProductUseCase(repository: repository)
    let command = ProductCommand(loadProduct: useCase)

    try await command.run()
  }
}

In una app SwiftUI può essere l’entry point dell’app o un factory dedicato.

Il resto del codice non deve sapere come tutto viene assemblato.

Feature verticali

Quando il progetto cresce, una divisione solo per livelli può diventare scomoda.

Alternativa:

Products/
  Domain/
  Application/
  Infrastructure/
  Presentation/

Cart/
  Domain/
  Application/
  Infrastructure/
  Presentation/

Questo mantiene vicini i file di una feature.

La scelta dipende dalla dimensione del progetto:

  • package piccolo: cartelle per livello;
  • app media: feature verticali;
  • sistema grande: moduli SwiftPM separati.

Moduli SwiftPM

Quando i confini diventano importanti, puoi usare target SwiftPM.

let package = Package(
  name: "ShopKit",
  platforms: [.macOS(.v14), .iOS(.v17)],
  products: [
    .library(name: "ShopDomain", targets: ["ShopDomain"]),
    .library(name: "ShopAPI", targets: ["ShopAPI"])
  ],
  targets: [
    .target(name: "ShopDomain"),
    .target(
      name: "ShopApplication",
      dependencies: ["ShopDomain"]
    ),
    .target(
      name: "ShopAPI",
      dependencies: ["ShopDomain", "ShopApplication"]
    ),
    .testTarget(
      name: "ShopApplicationTests",
      dependencies: ["ShopApplication"]
    )
  ]
)

I target rendono i confini compilabili. Se ShopDomain prova a importare ShopAPI, il package non compila.

Snapshot di un client API testabile

Product
Price
ProductCategory
  ^
  |
LoadProductUseCase ----> ProductRepository protocol
                             ^
                             |
             HTTPProductRepository
                 |       |
                 v       v
             HTTPClient  ProductDTO
                 |
                 v
             URLSessionHTTPClient

Questo diagramma dice:

  • il caso d’uso vede il protocollo;
  • il repository HTTP è un dettaglio sostituibile;
  • il DTO non entra nel dominio;
  • URLSession resta al bordo.

Stato applicativo

Per UI o CLI, spesso serve uno stato leggibile:

enum LoadingState<Value: Equatable>: Equatable {
  case idle
  case loading
  case loaded(Value)
  case failed(String)
}

View model:

@MainActor
final class ProductViewModel: ObservableObject {
  @Published private(set) var state: LoadingState<Product> = .idle

  private let loadProduct: LoadProductUseCase

  init(loadProduct: LoadProductUseCase) {
    self.loadProduct = loadProduct
  }

  func load(id: Product.ID) async {
    state = .loading

    do {
      state = .loaded(try await loadProduct.execute(id: id))
    } catch {
      state = .failed("Impossibile caricare il prodotto.")
    }
  }
}

Il view model conosce il caso d’uso, non URLSession.

Dove mettere i protocolli

Regola pratica: il protocollo vive vicino a chi lo usa, non per forza vicino a chi lo implementa.

Se LoadProductUseCase ha bisogno di ProductRepository, il protocollo può vivere in Application.

L’implementazione HTTP vive in Infrastructure.

Questo mantiene la direzione giusta:

Application definisce bisogno.
Infrastructure soddisfa bisogno.

Overengineering

Non introdurre architettura solo per imitare progetti grandi.

Segnali di overengineering:

  • protocolli con una sola implementazione e nessun test;
  • factory annidate senza necessità;
  • nomi generici come Manager, Coordinator, Helper;
  • cartelle vuote create “per il futuro”;
  • mapping DTO identico al dominio senza motivo;
  • test che richiedono più setup del codice reale.

L’architettura deve ridurre attrito, non produrlo.

Checklist

Uno snapshot utile risponde a:

  • quali sono i tipi di dominio?
  • quali sono i casi d’uso?
  • quali protocolli rappresentano porte esterne?
  • quali implementazioni sono infrastruttura?
  • dove vengono assemblate le dipendenze?
  • quali parti posso testare senza rete?
  • quali target SwiftPM impediscono dipendenze sbagliate?

Esercizio

Disegna lo snapshot del tuo SDK prodotti.

Poi verifica il codice:

  1. Product non conosce ProductDTO;
  2. LoadProductUseCase non crea URLRequest;
  3. HTTPProductRepository non decide regole UI;
  4. il view model o comando CLI riceve un caso d’uso;
  5. i test del caso d’uso usano un fake repository.

Obiettivo: il diagramma deve essere abbastanza semplice da stare in una schermata.