cat swift/milestone-dominio-use-case-confini.md

Lezione 73 70 min

Milestone 1: dominio, use case e confini

Costruire il nucleo del progetto finale: tipi di dominio, errori, invarianti, protocolli e primi use case testabili.

La prima milestone non deve toccare rete, UI o database.

Deve rispondere a questa domanda:

Qual è il cuore del problema, espresso in Swift?

Se il dominio è debole, tutto il resto diventerà compensazione.

Output della milestone

Alla fine devi avere:

Sources/
  ProjectDomain/
    tipi
    errori
    invarianti
  ProjectApplication/
    protocolli
    use case

Tests/
  ProjectDomainTests/
  ProjectApplicationTests/

E almeno:

  • 4 tipi di dominio;
  • 2 errori custom;
  • 2 use case;
  • 5 test;
  • nessuna rete;
  • nessuna UI;
  • nessun database reale.

Esempio: Hacker News client

Dominio:

public struct Story: Equatable, Sendable, Identifiable {
  public let id: ID
  public let title: String
  public let url: URL?
  public let score: Int

  public struct ID: RawRepresentable, Equatable, Hashable, Sendable {
    public let rawValue: Int

    public init(rawValue: Int) {
      self.rawValue = rawValue
    }
  }
}

Invariante:

public struct SearchQuery: Equatable, Sendable {
  public let value: String

  public init(_ value: String) throws {
    let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)

    guard trimmed.count >= 2 else {
      throw SearchError.queryTooShort
    }

    self.value = trimmed
  }
}

Errore:

public enum SearchError: Error, Equatable, Sendable {
  case queryTooShort
  case storyNotFound(Story.ID)
}

Repository protocol

Il caso d’uso non sa da dove arrivano i dati:

public protocol StoryRepository: Sendable {
  func search(query: SearchQuery) async throws -> [Story]
  func story(id: Story.ID) async throws -> Story
}

Questo protocollo è una porta. L’infrastruttura arriverà dopo.

Use case

public struct SearchStoriesUseCase: Sendable {
  private let repository: StoryRepository

  public init(repository: StoryRepository) {
    self.repository = repository
  }

  public func execute(query: SearchQuery) async throws -> [Story] {
    let stories = try await repository.search(query: query)

    return stories.sorted { left, right in
      left.score > right.score
    }
  }
}

La regola “ordinare per score” è applicativa. Non deve stare nella view.

Fake repository

public actor InMemoryStoryRepository: StoryRepository {
  private var stories: [Story.ID: Story]

  public init(stories: [Story]) {
    self.stories = Dictionary(uniqueKeysWithValues: stories.map { ($0.id, $0) })
  }

  public func search(query: SearchQuery) async throws -> [Story] {
    stories.values.filter { story in
      story.title.localizedCaseInsensitiveContains(query.value)
    }
  }

  public func story(id: Story.ID) async throws -> Story {
    guard let story = stories[id] else {
      throw SearchError.storyNotFound(id)
    }

    return story
  }
}

Un fake actor è perfetto per test async con stato.

Test dominio

@Test
func queryRifiutaTestoTroppoCorto() {
  #expect(throws: SearchError.queryTooShort) {
    try SearchQuery("s")
  }
}

@Test
func queryRimuoveSpazi() throws {
  let query = try SearchQuery("  swift  ")

  #expect(query.value == "swift")
}

Questi test proteggono regole che valgono ovunque.

Test use case

@Test
func ricercaOrdinaPerScoreDecrescente() async throws {
  let repository = InMemoryStoryRepository(stories: [
    Story(id: .init(rawValue: 1), title: "Swift", url: nil, score: 10),
    Story(id: .init(rawValue: 2), title: "Swift Server", url: nil, score: 40)
  ])

  let useCase = SearchStoriesUseCase(repository: repository)

  let result = try await useCase.execute(query: try SearchQuery("swift"))

  #expect(result.map(\\.score) == [40, 10])
}

La rete non serve per sapere se la regola funziona.

Confini

In questa milestone sono vietati:

  • URLSession;
  • JSONDecoder per payload remoti;
  • SwiftUI.View;
  • Vapor.Request;
  • FileManager.default;
  • singleton;
  • global mutable state.

Se ti sembra impossibile, hai probabilmente messo nel dominio qualcosa che appartiene all’infrastruttura.

Errori comuni

Evita:

  • tipi anemici con tutte String e Int;
  • Optional dove serve un errore preciso;
  • protocollo repository enorme;
  • use case che restituisce DTO;
  • test che copiano l’implementazione;
  • modelli chiamati Manager;
  • dominio guidato dal JSON.

Checklist

La milestone 1 è completa quando:

  • il dominio compila da solo;
  • gli invarianti sono nei tipi;
  • gli errori sono espliciti;
  • i use case dipendono da protocolli;
  • i test non usano rete o disco;
  • un fake repository permette flussi async;
  • puoi spiegare il nucleo in 2 minuti.

Esercizio

Per il tuo progetto:

  1. crea target Domain e Application;
  2. definisci 4-6 tipi;
  3. crea almeno 2 errori custom;
  4. crea 2 protocolli o 1 protocollo piccolo;
  5. implementa 2 use case;
  6. scrivi 5 test.

Obiettivo: far passare i test prima di creare qualsiasi interfaccia.