Scaling an iOS Codebase: Features Vertical, Logic Horizontal
Layers or features? Wrong question. Split product code vertically, platform code horizontally, and let the compiler enforce the boundary.
Someone wants to split the app into layers. Someone else wants to split it into features. Both sides have conference talks to back them up.
I’ve shipped code both ways, and this is where I landed.
The two ways everyone does it
Slice horizontally and you get layers: UI, domain, networking, persistence. This is the Clean Architecture diagram everyone has seen. It looks great on a whiteboard. Then you need to change one screen and you’re editing four modules. Your PR touches everything, your teammate’s PR touches everything, and you spend the week resolving conflicts in the domain layer.
Slice vertically and you get feature modules instead: Checkout, Profile, Orders. Each one owns its screens, its logic, its storage. This fixes the collisions. It also means that six months in, three features have three slightly different retry implementations. Or someone got tired of duplicating code and made Checkout import Profile, and now nothing builds in isolation anymore.
For years I assumed I just had to pick which of these problems I’d rather live with. I don’t think that anymore.
Not all code is the same kind of code
Here’s what both camps miss: an app contains two kinds of code, and they want different treatment.
There’s code that changes because the product changes. The checkout flow. Onboarding. The settings screen that product keeps redesigning.
And there’s code that changes because the platform changes. Networking. Persistence. Auth. The design system. Nobody redesigns your keychain wrapper for a spring campaign.
Product code wants to live in vertical slices because that’s how you work on it, one feature at a time. Platform code wants to be horizontal because everyone needs it and it should exist exactly once. So stop forcing one shape on both:
One rule holds it together
Features depend on capabilities. Features never depend on each other. Capabilities never depend on features.
That’s the whole thing. In Package.swift:
targets: [
// Capabilities: horizontal, shared
.target(name: "CoreModels"),
.target(name: "Networking", dependencies: ["CoreModels"]),
.target(name: "Persistence", dependencies: ["CoreModels"]),
.target(name: "DesignSystem"),
// Features: vertical, isolated
.target(name: "Checkout", dependencies: ["Networking", "Persistence", "DesignSystem"]),
.target(name: "Profile", dependencies: ["Networking", "DesignSystem"]),
.target(name: "Orders", dependencies: ["Networking", "Persistence", "DesignSystem"]),
]
Now import Profile inside Checkout is a compile error. I like that a lot more than a written convention, because conventions lose arguments with deadlines. The compiler doesn’t.
”But Checkout needs to open Profile”
No, it doesn’t. It needs to say that the user wants to edit their profile. Actually opening the profile screen is someone else’s job.
// In the Checkout feature, with no import of Profile anywhere
public enum CheckoutExit {
case finished(orderID: OrderID)
case editProfileRequested
}
public struct CheckoutView: View {
public let onExit: (CheckoutExit) -> Void
// ...
}
// In the app target, the only place that knows every feature
CheckoutView { exit in
switch exit {
case .finished(let orderID): router.show(OrderConfirmationView(orderID: orderID))
case .editProfileRequested: router.show(ProfileView())
}
}
The app target is allowed to import everything because composing things is its whole job. It stays small, handling routing, wiring, and dependency injection. Everything interesting lives in modules you can build and test on their own.
When two features need the same logic, say Checkout and Orders both format prices, that’s not a reason to link them. Price formatting is telling you it’s actually a capability. Move it down a layer. Shared code moves down, never sideways.
Things I learned the hard way
Don’t start with forty modules. I’ve watched a project spend two weeks setting up a module graph before writing a single screen. Start with Core, DesignSystem, and two or three features. Split a module when it starts hurting, not because a diagram said so.
Keep product knowledge out of capabilities. The day Networking learns what a “checkout session” is, it stops being reusable. Capabilities deal in generic requests and generic storage; features give them meaning.
Test hardest at the bottom. A bug in a capability breaks every feature, so that’s where the thorough unit tests go. Features get quicker tests against mocked capabilities. If your capability interfaces are protocols, this costs almost nothing.
It works solo, too. This isn’t just a big-team thing. Most of my own apps share the same Networking, Persistence, and DesignSystem packages. A new app is mostly one new feature slice on top of code I already trust. That is the only reason shipping twenty-something apps as one person was possible at all.
If you remember one thing
Stop asking “layers or features?” and ask “which kind of code is this?” instead. Product code goes in a vertical slice. Platform code goes in a horizontal layer. Features never touch each other.
That’s the whole trick.