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:
- API coupling: Keep API response formats separate from domain models
- Type safety: Ensure compile-time safety at layer boundaries
- Mapping responsibility: Clear ownership of data transformation
- Flexibility: Enable API changes without affecting domain logic
- 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:
-
OMGAPI Package (DTOs)
- Contains all request and response models
- Matches API structure exactly (Codable)
- Marked
Sendablefor concurrency safety - Public, shared across features
-
NetworkService Layer
- Fetches data from API
- Decodes responses into DTOs
- No mapping logic - returns DTOs as-is
-
Repository Layer
- Maps DTOs to domain/persistence models
- Applies business logic during transformation
- Coordinates between network DTOs and local storage
-
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:
- ADR-002: Layered Architecture and Dependency Direction - DTOs flow through layers
- ADR-006: Swift Data over Core Data - @Model types are mapping targets
- ADR-007: MicroClient for HTTP Communication - NetworkRequest uses DTOs for response types
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.