cat swift/laboratorio-prototipo-multitraccia.md

Lezione 71 100 min

Laboratorio: prototipo multi-traccia

Laboratorio del Modulo 9: costruire un nucleo Swift condiviso e portarlo in una mini app SwiftUI, una CLI, una route Vapor e una libreria documentabile.

Questo laboratorio chiude il modulo applicativo.

Costruiremo un prototipo multi-traccia basato su un dominio unico: task manager.

Non serve creare un prodotto completo. Serve dimostrare che lo stesso nucleo può vivere in destinazioni diverse.

Output finale

TaskDomain
  TaskItem
  TaskDraft
  TaskPriority
  TaskError

TaskApplication
  TaskRepository
  CreateTaskUseCase
  LoadTasksUseCase
  CompleteTaskUseCase

TaskCLI
  task add/list/complete

TaskServer
  GET /tasks
  POST /tasks
  PATCH /tasks/:id/complete

TaskUI
  TaskListViewModel
  TaskListScreen

TaskKit.docc
  overview del dominio condiviso

Passo 1: dominio

public enum TaskPriority: String, Codable, Sendable {
  case low
  case normal
  case high
}

public struct TaskItem: Equatable, Codable, Sendable, Identifiable {
  public let id: ID
  public var title: String
  public var priority: TaskPriority
  public var isCompleted: Bool

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

    public init(_ rawValue: String) {
      self.rawValue = rawValue
    }

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

Draft:

public struct TaskDraft: Equatable, Sendable {
  public let title: String
  public let priority: TaskPriority

  public init(title: String, priority: TaskPriority = .normal) throws {
    let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)

    guard trimmed.isEmpty == false else {
      throw TaskError.emptyTitle
    }

    self.title = trimmed
    self.priority = priority
  }
}

Errore:

public enum TaskError: Error, Equatable, Sendable {
  case emptyTitle
  case notFound(TaskItem.ID)
}

Passo 2: application

public protocol TaskRepository: Sendable {
  func all() async throws -> [TaskItem]
  func save(_ task: TaskItem) async throws
}

Create:

public struct CreateTaskUseCase: Sendable {
  private let repository: TaskRepository
  private let makeID: @Sendable () -> TaskItem.ID

  public init(
    repository: TaskRepository,
    makeID: @escaping @Sendable () -> TaskItem.ID
  ) {
    self.repository = repository
    self.makeID = makeID
  }

  public func execute(draft: TaskDraft) async throws -> TaskItem {
    let task = TaskItem(
      id: makeID(),
      title: draft.title,
      priority: draft.priority,
      isCompleted: false
    )

    try await repository.save(task)
    return task
  }
}

Complete:

public struct CompleteTaskUseCase: Sendable {
  private let repository: TaskRepository

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

  public func execute(id: TaskItem.ID) async throws {
    var tasks = try await repository.all()

    guard let index = tasks.firstIndex(where: { $0.id == id }) else {
      throw TaskError.notFound(id)
    }

    tasks[index].isCompleted = true
    try await repository.save(tasks[index])
  }
}

Passo 3: repository in memoria

public actor InMemoryTaskRepository: TaskRepository {
  private var tasks: [TaskItem.ID: TaskItem]

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

  public func all() async throws -> [TaskItem] {
    Array(tasks.values)
  }

  public func save(_ task: TaskItem) async throws {
    tasks[task.id] = task
  }
}

Questo repository può servire per test, demo SwiftUI e primi prototipi server.

Passo 4: CLI

import ArgumentParser

@main
struct TaskTool: AsyncParsableCommand {
  static let configuration = CommandConfiguration(
    commandName: "task",
    subcommands: [Add.self, List.self, Complete.self]
  )
}

Add:

struct Add: AsyncParsableCommand {
  @Argument var title: String
  @Option var priority: TaskPriority = .normal

  mutating func run() async throws {
    let env = try CLIEnvironment.live()
    let draft = try TaskDraft(title: title, priority: priority)
    let task = try await env.createTask.execute(draft: draft)

    print("Creata: \\(task.id.rawValue)")
  }
}

Il comando adatta argomenti terminale a dominio.

Passo 5: Vapor route

import Vapor

struct CreateTaskRequest: Content {
  let title: String
  let priority: String?
}

struct TaskResponse: Content {
  let id: String
  let title: String
  let priority: String
  let completed: Bool

  init(_ task: TaskItem) {
    self.id = task.id.rawValue
    self.title = task.title
    self.priority = task.priority.rawValue
    self.completed = task.isCompleted
  }
}

Route:

app.post("tasks") { req async throws -> TaskResponse in
  let input = try req.content.decode(CreateTaskRequest.self)
  let priority = input.priority.flatMap(TaskPriority.init(rawValue:)) ?? .normal
  let draft = try TaskDraft(title: input.title, priority: priority)

  let useCase = req.application.taskUseCases.createTask
  let task = try await useCase.execute(draft: draft)

  return TaskResponse(task)
}

La route non decide la validazione del titolo. Costruisce TaskDraft è lascia parlare il dominio.

Passo 6: SwiftUI

import Observation
import SwiftUI

@MainActor
@Observable
final class TaskListViewModel {
  private let loadTasks: LoadTasksUseCase
  var state: LoadingState<[TaskItem]> = .idle

  init(loadTasks: LoadTasksUseCase) {
    self.loadTasks = loadTasks
  }

  func load() async {
    state = .loading

    do {
      state = .loaded(try await loadTasks.execute())
    } catch {
      state = .failed("Impossibile caricare le attività.")
    }
  }
}

View:

struct TaskListScreen: View {
  @State private var viewModel: TaskListViewModel

  var body: some View {
    List {
      switch viewModel.state {
      case .idle, .loading:
        ProgressView()
      case .loaded(let tasks):
        ForEach(tasks) { task in
          Text(task.title)
        }
      case .failed(let message):
        Text(message)
      }
    }
    .task {
      await viewModel.load()
    }
  }
}

La UI non importa Vapor o ArgumentParser.

Passo 7: test condivisi

@Test
func draftRifiutaTitoloVuoto() {
  #expect(throws: TaskError.emptyTitle) {
    try TaskDraft(title: "   ")
  }
}

Test caso d’uso:

@Test
func createTaskSalvaNelRepository() async throws {
  let repository = InMemoryTaskRepository()
  let useCase = CreateTaskUseCase(
    repository: repository,
    makeID: { TaskItem.ID("t-1") }
  )

  let task = try await useCase.execute(
    draft: TaskDraft(title: "Preparare modulo", priority: .high)
  )

  let tasks = try await repository.all()
  #expect(tasks == [task])
}

Questo test vale per tutte le tracce.

Passo 8: DocC

TaskKit.md:

# TaskKit

Model tasks once and reuse the same Swift domain in apps, servers, CLI tools and libraries.

## Overview

Use ``TaskDraft`` to validate user input before creating a ``TaskItem``.

La documentazione racconta il nucleo, non solo le singole API.

Criteri di completamento

Il laboratorio è completo quando:

  • il dominio compila senza SwiftUI, Vapor e ArgumentParser;
  • i casi d’uso dipendono da protocolli;
  • CLI, server e UI usano lo stesso TaskDraft;
  • gli errori di dominio vengono tradotti in ogni destinazione;
  • i test del dominio sono condivisi;
  • ogni traccia ha un adattatore minimo;
  • DocC spiega come usare il nucleo.

Estensioni

Per continuare:

  • sostituisci repository in memoria con JSON file nella CLI;
  • aggiungi database alla traccia Vapor;
  • aggiungi cache al client HTTP;
  • aggiungi una schermata dettaglio SwiftUI;
  • pubblica TaskDomain come libreria separata;
  • crea esempi DocC per ogni traccia.

Il valore del modulo è nella prospettiva: Swift non è una singola destinazione. È un modo coerente di modellare software che poi può prendere forme diverse.