Devs for Devs: Domain, Data, Presentation - What Clean Architecture Actually Buys You on iOS
Devs for Devs: Domain, Data, Presentation - What Clean Architecture Actually Buys You on iOSFrom world-class developers, straight to you.
It’s already the 5th issue, wow, time flies! Thank you for reading. 🥳 Today, Vadim Krutov is sharing his architectural planning vision. He’s an iOS developer who has been building apps since 2017, with a strong interest in Swift, SwiftUI, architecture, and modern Apple development. As the creator of Swiftloop, he shares practical lessons drawn from real-world iOS development, from debugging tricky issues to exploring APIs and architectural decisions. Vadim also contributes to the Swift ecosystem through open-source packages and technical writing. Let’s check out what CLEAN architecture will bring us.👇 P.S. You can check out Vadim’s work on LinkedIn, and website. Most iOS codebases don’t start layered. They start as a SwiftUI view or a view model that fetches, decodes, maps, validates, and renders, all in one file, because that’s the fastest way to ship the first screen. It works right up until the second screen needs the same business rule, or the backend changes a field, or someone tries to unit test a network call without booting a simulator. At that point the tax comes due: business logic is welded to URLSession, UI state is welded to Core Data, and touching any one of them risks breaking all three. Clean Architecture’s answer is to split the app into three layers — Domain, Data, Presentation — with one rule holding them together: dependencies point inward, toward Domain. Data and Presentation both know about Domain; Domain knows about neither. That single rule is where most of the payoff comes from, and most of the ways teams get it wrong come from quietly breaking it. Here’s what that looks like in practice, fifteen lessons in. Why Bother Splitting into Three Layers at AllThe immediate win is navigability. When every piece of code has exactly one of three possible homes — business rules in Domain, persistence and networking in Data, screens and state in Presentation — a developer who has never seen the codebase can still find their way around it. “Where’s the discount logic?” has one answer, not “check the view model, or maybe the API client, or possibly that extension file.” The bigger win shows up when you make a large decision later. Decide to move from MVVM to TCA, or from Combine to async/await in your view layer? That only touches Presentation. Decide to replace Core Data with SwiftData, or REST with GraphQL? That only touches Data. Domain doesn’t move either time, because Domain never depended on how data was fetched or how the screen was built — only on the contracts. Layering doesn’t prevent big rewrites; it just makes sure a rewrite in one layer doesn’t force a rewrite in the other two. Start With the Domain Layer, and Keep It PureIf you’re building all three layers from scratch, start with Domain. Both Data and Presentation end up depending on it, so the contracts it defines — protocols, models, use cases — are the thing everything else is built against. Defining them early isn’t mandatory, but it saves you from designing a repository interface and a view model in parallel, guessing at what the other side needs. The part that’s easy to get wrong isn’t the ordering, it’s the purity. Domain should stay clear of UIKit and SwiftUI — nothing in your business rules needs to know a screen or a view exists. Foundation basics like Date, UUID, and Data are a different case; they’re everyday vocabulary, not framework leakage, and most domain models end up depending on at least one of them. Combine is more of a team call than a hard line — if your Data layer is already built on it, Domain may end up depending on it too, since there’s often no realistic way around that. Beyond keeping UI frameworks out, how strictly you draw the line is ultimately a team agreement. Some teams knowingly let a specific framework into Domain because it clearly pays for itself in their context. That’s a fine trade-off to make deliberately; it’s just worth making it on purpose rather than by accident, since every exception is one more thing standing between Domain and being buildable and testable on its own.
Nothing here says how orders are fetched or stored. That’s what makes the “develop independently” promise real: you can write and test every business rule against this protocol before a single Data or Presentation type exists, with no simulator and no mocked network layer, just a fake conforming to OrderRepository. The Repository Is a Facade, Not a PipeDomain depends on repository contracts, never on individual data source contracts. Everything that can go wrong on the data side — a flaky network, a cache that needs merging with a fresh response, a backend field that got renamed — should be invisible past the repository boundary. That only works if you design the contract carefully up front, because loosening it later usually means touching every call site. Two things tend to hide inside that facade: mapping and error translation. A DTO and a domain model often look identical, and that similarity makes people want to collapse them into one type. Don’t — the DTO is what absorbs an API change; the domain model is what the rest of the app is allowed to depend on. Error translation has the same shape, and the same rule about which layer owns what: OrderError has to be defined in Domain, not Data, because Domain can never depend on Data — if the error type lived next to RemoteOrderRepository, nothing above it in Domain would be allowed to catch it.
That trailing Nothing in Order ever needs to know that status_code used to be a String and became an Int in some backend release, and nothing in a view model needs to catch a URLError — it catches Use Cases Have to Earn Their KeepA use case that only forwards to a single repository method is dead weight with extra steps:
This shows up in a lot of codebases that adopted Clean Architecture by the checklist rather than by need, and it adds a maintenance cost — one more type, one more place to inject, one more file to open — without adding any behavior. Strictly speaking, this is what canonical Clean Architecture calls for: Presentation and Data both depend on Domain, and a use case is the shape that dependency is supposed to take, even when all it does is forward to one repository method. Following that to the letter is correct by the pattern; it’s also exactly where the maintenance burden shows up fastest. Plenty of teams bend the rule slightly and inject repositories directly into view models or reducers instead, skipping the pass-through use case entirely. That’s a small deviation from the canonical shape, not a different architecture — and whether to make that trade is a team decision, not something the pattern settles for you. A use case earns its place when it’s actually doing something: coordinating more than one repository, enforcing a business rule, or sharing logic across several view models or reducers that would otherwise duplicate it.
That’s worth a dedicated type. Depending on two repository protocols here is fine, and doesn’t bend the dependency rule — Let Swift’s Own Capabilities Do Some of the WorkKeeping track of what the language itself gives you matters as much as the layering does. If you’re designing a new repository contract today, reach for async/await before Combine — it maps more directly onto what a repository call actually is, a single request with a single result, and it keeps Data-layer code out of publisher composition that Presentation didn’t ask for. That same instinct — use the language, don’t route around it — is where a lot of Clean Architecture guidance for iOS shows its Android or Java roots most clearly, usually one level below the repository. A
Same behavior as a dedicated data source type, a fraction of the code to maintain. Not every data source deserves a full type — some deserve one line. Make the Boundaries Real: Modules and Composition RootsFolder structure and good intentions don’t stop someone from importing a Presentation type into Domain under deadline pressure — and once that happens once, “Domain is pure” becomes a claim nobody can actually verify by reading the code. Splitting Domain, Data, and Presentation into separate Swift Package targets turns that mistake into a compiler error instead of a code review miss, with faster incremental builds as a side effect:
Data and Presentation can both import Domain, but neither can import each other, and Domain can’t import either. That’s the dependency rule, enforced by the build system rather than by convention. Somewhere, all of this still has to get wired together — a concrete RemoteOrderRepository handed to a PlaceOrderUseCase handed to a view model. Keep that assembly in one obvious place, whether that’s your app’s entry point, a set of factories, or a small DI container, rather than letting each screen construct its own dependency graph. A composition root is the one place in the app that’s allowed to know about all three layers at once; that’s a feature, not a violation of the rule. Why this matters even more when an agent is writing the codeClear layering used to be mostly a human-navigation aid. It’s now also a determinism aid. An agent generating a new feature against a codebase with well-defined layers produces far more predictable output than one working against a flat pile of SwiftUI views, because “where does this code go” has an answer before the agent has to invent one. And the same clarity that helps the agent generate code helps you review it afterward — you know a repository implementation belongs in Data, so when an agent’s diff puts networking code in a view model, that’s immediately visible as wrong, not something you have to reconstruct from context. Architecture that used to be a courtesy to the next developer is now also a courtesy to the next agent, and to whoever reviews what it wrote. GotchasDon’t reach for three layers and three modules on a three-screen prototype. Clean Architecture’s cost is real and mostly fixed — it doesn’t scale down. Applied to something that will be thrown away in a sprint, it’s not rigor, it’s overhead with nothing to show for it. A repository contract designed around one data source rarely survives a second one untouched. If Module boundaries pay for themselves at team and codebase size, not before. Three SPM targets on a solo two-week project is mostly extra SummaryEverything above is really one rule wearing different clothes: dependencies point inward, and Domain doesn’t know Data or Presentation exist. Start there, keep it pure, make repositories the only seam between Domain and the outside world, and let use cases prove they’re doing real work before you write another one. The layering doesn’t make big decisions disappear — switching frameworks, backends, or architectures inside a layer is still work — it just makes sure that work stays inside the layer it started in, whether the one making the change is you, a teammate, or an agent.
|
