cat swift/laboratorio-prototipo-multitraccia.md
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
TaskDomaincome 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.