Triton/Documentation/ADR/ADR-012-dto-based-data-flow.md
Otávio 3e878667a1 Add Triton App
Signed-off-by: Otavio Cordeiro <otaviocc@users.noreply.github.com>
2025-12-15 20:39:07 +01:00

5.5 KiB

ADR-012: DTO-Based Data Flow

Status: Accepted

Date: 2025-01-11

Context:

When designing data flow through the application layers, I needed to decide how data should be represented and transformed as it moves from the network to the UI. Key considerations included:

  1. API coupling: Keep API response formats separate from domain models
  2. Type safety: Ensure compile-time safety at layer boundaries
  3. Mapping responsibility: Clear ownership of data transformation
  4. Flexibility: Enable API changes without affecting domain logic
  5. Testability: Easy to create test fixtures at each layer

Decision:

I adopted a DTO (Data Transfer Object) based approach where data flows through distinct representations at each layer:

Network API
    ↓
DTOs (OMGAPI package) ← Codable, API-shaped structures
    ↓
Repository ← Maps DTOs to domain models or persistence models
    ↓
Domain Models / Persistence Models ← SwiftData @Model or view-friendly structs
    ↓
ViewModels / Views

Layer Responsibilities:

  1. OMGAPI Package (DTOs)

    • Contains all request and response models
    • Matches API structure exactly (Codable)
    • Marked Sendable for concurrency safety
    • Public, shared across features
  2. NetworkService Layer

    • Fetches data from API
    • Decodes responses into DTOs
    • No mapping logic - returns DTOs as-is
  3. Repository Layer

    • Maps DTOs to domain/persistence models
    • Applies business logic during transformation
    • Coordinates between network DTOs and local storage
  4. PersistenceService Layer

    • Stores/retrieves domain or DTO representations
    • Uses SwiftData @Model types for persistence

Example Flow:

// 1. DTO in OMGAPI package
public struct PURLsResponse: Decodable, Sendable {
    public let request: RequestResponse
    public let response: Response

    public struct Response: Decodable, Sendable {
        public let message: String
        public let purls: [PURLResponse]
    }
}

public extension PURLsResponse.Response {
    struct PURLResponse: Decodable, Sendable {
        public let name: String
        public let url: URL
        public let counter: Int?
    }
}

// 2. NetworkService returns DTOs
actor PURLsNetworkService: PURLsNetworkServiceProtocol {
    func fetchPURLs(for address: String) async throws {
        let request = OMGAPIFactory.makeAllPURLsRequest(for: address)
        let response: PURLsResponse = try await client.run(request)
        // Emit DTOs through stream
        continuation.yield(response.response.purls)
    }
}

// 3. Repository maps to persistence model
actor PURLsRepository: PURLsRepositoryProtocol {
    private func startPURLsSync() {
        for await purls in networkService.purlsStream() {
            let storablePurls = purls.map { purlResponse in
                StorablePURL(
                    address: current,
                    purlResponse: purlResponse  // Map DTO to @Model
                )
            }
            try await persistenceService.storePURLs(purls: storablePurls)
        }
    }
}

// 4. SwiftData persistence model
@Model
final class StorablePURL {
    var address: String
    var name: String
    var url: URL
    var counter: Int?

    init(address: String, purlResponse: PURLResponse) {
        self.address = address
        self.name = purlResponse.name
        self.url = purlResponse.url
        self.counter = purlResponse.counter
    }
}

Mapping Patterns:

  • DTO → @Model: Repository maps during persistence operations
  • @Model → View Models: SwiftData queries provide models directly to views
  • DTO → Domain Structs: Some features use lightweight structs instead of @Model
  • No direct DTO to View: Views never see DTOs, only domain models

Consequences:

Positive

  • Decoupling: API changes don't ripple through all layers
  • Type safety: Each layer has appropriate types for its purpose
  • Clear boundaries: Transformation happens at well-defined points
  • Testability: Easy to create fixtures at each layer
  • Sendable safety: DTOs are Sendable, enabling actor isolation
  • Flexibility: Can change domain models without affecting API contract

Negative

  • Mapping boilerplate: Need code to transform between representations
  • Multiple representations: Same data exists in different forms
  • Memory overhead: Temporary DTO objects during transformation

Neutral

  • Mapping complexity: Simple for straightforward cases, more complex for nested structures
  • Performance impact: Mapping cost is generally negligible for typical data sizes

Why Not Direct API Models:

Alternatives considered:

  • Use DTOs everywhere: Couples domain logic to API structure
  • Use domain models for network: Forces Codable on domain types
  • Manual JSON parsing: Error-prone and verbose

Related Decisions:

Notes:

The DTO-based approach provides clean separation between API contracts and domain models. While it requires mapping code, the benefits of decoupling and type safety outweigh the boilerplate cost. The OMGAPI package serves as the single source of truth for API data structures.