What you see in the root directory the first time you open a project tells you a good deal about how it will age. If you see Network, Repositories, ViewModels, Views, the project is sliced by technical layer. If you see Search, Checkout, Profile, it is sliced by feature. The first tells you how the app is built, the second what it does. Both contain the same code; the difference is which axis sits on top, and how many places a change has to touch.
Our position, up front: in a native app that several teams work on and that has a few years behind it, the top-level slice should be the feature, with thin layers kept inside each one. For a small team and a young product whose boundaries are still moving, layer-based is the right call, and we defend that further down. The running example throughout is a hypothetical retail app: search, product detail, cart and checkout, profile.
Two ways to slice, at two levels
The split can be made at folder level or at module level, and the two get confused all the time. Folder level is a reading aid only: the compiler does not see folders, and anything can import anything. At module level (an SPM target, a Gradle module) the slice becomes a compilation boundary, and the direction of dependencies is written down in a build file.
The SPM version of a layer-first iOS project usually looks like this:
Packages/ShopKit/Sources/
├── Models/ ← Product, Cart, User, Order, Address, Coupon...
├── Networking/ ← APIClient + every endpoint
├── Repositories/ ← ProductRepository, CartRepository, UserRepository...
├── ViewModels/ ← SearchViewModel, CheckoutViewModel, ProfileViewModel...
├── Views/ ← every screen
└── Utils/ ← "I didn't know where else to put it"
On Android the same idea arrives as the three modules familiar from Clean Architecture templates:
:app
:presentation ← every screen and ViewModel
:domain ← every use case and model
:data ← every repository, Retrofit service, Room
:common ← extensions, Constants.kt, Utils.kt
In the feature-first version the top level changes: SearchFeature, CheckoutFeature, ProfileFeature, or :feature:search, :feature:checkout, :feature:profile. The layers do not disappear. They move one level down.
How layer-first projects age
The first year is fine. The structure is familiar, every new joiner knows where a file goes, and the templates assume this layout. The ageing starts when the number of screens and the number of teams grow, and it always shows the same symptoms.
One change, five folders
Here is what a developer touches to add a "gift note" field to the checkout screen:
Models/Order.swift (new field)
Networking/Endpoints/OrderEndpoints.swift (request body)
Repositories/OrderRepository.swift (parameter)
ViewModels/CheckoutViewModel.swift (state)
Views/Checkout/CheckoutView.swift (TextField)
Utils/Validators.swift (length check)
Six files, six folders, one piece of work. Logically the change lives in one place; physically it is spread across the whole project. The reviewer has to walk through those same folders, each full of unrelated files that belong to other teams.
Every layer depends on everything
Split the layer-first structure into modules and Package.swift looks like this:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "ShopKit",
platforms: [.iOS(.v17)],
products: [
.library(name: "ShopKit", targets: ["Views"]),
],
targets: [
.target(name: "Utils"),
.target(name: "Models", dependencies: ["Utils"]),
.target(name: "Networking", dependencies: ["Models", "Utils"]),
.target(name: "Repositories", dependencies: ["Networking", "Models", "Utils"]),
.target(name: "ViewModels", dependencies: ["Repositories", "Models", "Utils"]),
.target(name: "Views", dependencies: ["ViewModels", "Models", "Utils"]),
]
)
A tidy chain on paper. In practice, adding a field to Coupon in Models rebuilds the entire chain, because search, profile and checkout all depend on the same Models target. You have modules, but from the build's point of view you still have one application: a monolith cut into six pieces.
Android looks the same, with api leakage on top:
// presentation/build.gradle.kts
dependencies {
implementation(project(":domain"))
implementation(project(":data")) // "needed for DI"
api(project(":common")) // everyone uses it, make it api
}
CheckoutViewModel in :presentation can see ProductDao in :data directly, and one day somebody will. Gradle's api versus implementation distinction exists to prevent exactly this: a dependency declared with api lands on the consumer's compile classpath, so every change in :common recompiles every module that sees it transitively.
The Models and Utils dumping grounds
In a layer-first structure a type's location is decided by what kind of thing it is. Nobody asks who it belongs to. So the Models folder gradually becomes common property. The product in a search result and the product in the cart share one Product struct, the struct gains another optional field for each screen's needs, and it ends up with twenty-five fields that are never all populated on any single screen. We covered this separately in shared models versus domain models.
Utils grows faster still. String+Extensions.swift holds a function that formats an IBAN next to one that parses dates and one that rounds shipping fees. The only thing they have in common is that none of their authors could find a better place.
Ownership and merge conflicts
If you have three teams (search, payments, account), who owns the ViewModels folder? Nobody, or everybody. There is no meaningful line you could write in a CODEOWNERS file, because folder boundaries do not line up with team boundaries. Shared files also generate conflicts: Endpoints.swift, AppContainer, Routes.kt, strings.xml, the single-file DI module. Every team touches them every sprint.
This is easy to measure in your own project. List the files that change most:
git log --since="6 months ago" --no-merges --name-only --pretty=format: \
| grep -v '^$' | sort | uniq -c | sort -rn | head -20
If the top of that list is files that belong to no particular feature (Models/Product.swift, di/AppModule.kt, Utils/Extensions.swift), you have started paying the layer-first bill.
What feature-first buys
Locality of change. The gift note example stays inside one module. Whoever reads the diff can tell what is affected from the directory name.
Ownership. Sources/CheckoutFeature/ or feature/checkout/ can be assigned to a team in one line. The review load, the on-call responsibility and the person to ask "why is this written like this" all become clear.
Deletability. This is the one we care about most. When a campaign ends or an experiment fails, removing the feature should mean deleting a directory and two lines in the app target. In a layer-first structure the same job is archaeology across five folders, so it usually does not happen, and dead code keeps compiling for years.
If you cannot delete a feature in an afternoon, the boundary is in the wrong place.
Parallel builds and caching. Modules that do not depend on each other can compile in parallel, and a change in one does not invalidate the other. On the Gradle side that means parallel execution and the build cache; Xcode also builds independent targets in parallel. How much you gain depends on the shape of your graph, which is why we give no number. To measure it, change one line inside Checkout and look at the incremental build time and the number of modules recompiled (a build scan or build report in Gradle, the build timeline in Xcode).
And what it costs
Feature-first is not free. It has four costs, all real.
The first is duplication pressure. Search and cart both want to format a price. Either you write the same ten lines in two features, or you create a shared module for ten lines. Our rule: code moves to core when it is needed a third time and really changes for the same reason; until then the copy stays. Most code that looks alike in two features has drifted apart six months later.
The second is cross-feature navigation. Product detail has to say "go to cart", cart has to say "log in". Solve that with a direct import and you arrive at the third cost, feature-to-feature dependencies. Once CheckoutFeature imports ProfileFeature, the next sprint produces a need in the opposite direction, and breaking the cycle means moving the shared types somewhere. That somewhere tends to be a new Common module, and the dumping ground is back.
The fourth is the address of shared code. Creating one Shared module for "everything that is shared" just promotes the Utils folder to a module. Shared code should live in a small number of core modules whose names say what they do.
The structure we recommend at scale
Feature modules keep thin layers inside. Beneath them sits a small, stable set of core modules: design system, networking, persistence, analytics. Features never depend on each other directly; only the app target brings them together.
App (composition root, navigation)
┌──────────────┬──────┴───────┬───────────────┐
▼ ▼ ▼ ▼
SearchFeature ProductFeature CheckoutFeature ProfileFeature
│ │ │ │
└──────────────┴──────┬───────┴───────────────┘
▼
DesignSystem Networking Persistence Analytics
Forbidden edge: Feature ──► Feature
Forbidden edge: Core ──► Feature
Martin Fowler's piece on presentation-domain-data layering reaches the same conclusion: once the layers grow, split the top level into domain-oriented modules and keep the layering inside them. Android's official modularization guide and the Now in Android sample are likewise built on feature and core modules.
iOS: SPM targets
Packages/ShopKit/
├── Package.swift
└── Sources/
├── DesignSystem/
├── Networking/
├── Persistence/
├── Analytics/
├── SearchFeature/
├── ProductFeature/
├── ProfileFeature/
└── CheckoutFeature/
├── CheckoutEntry.swift ← the only public entry point
├── UI/ ← CheckoutView, CheckoutViewModel
├── Domain/ ← Cart, PaymentMethod, PlaceOrder
└── Data/ ← CheckoutAPI, DTOs, mappers
// swift-tools-version: 6.0
import PackageDescription
let core: [Target.Dependency] = ["DesignSystem", "Networking", "Persistence", "Analytics"]
let package = Package(
name: "ShopKit",
platforms: [.iOS(.v17)],
products: [
.library(name: "SearchFeature", targets: ["SearchFeature"]),
.library(name: "ProductFeature", targets: ["ProductFeature"]),
.library(name: "CheckoutFeature", targets: ["CheckoutFeature"]),
.library(name: "ProfileFeature", targets: ["ProfileFeature"]),
],
targets: [
// Core: depends downwards at most, knows nothing about features
.target(name: "DesignSystem"),
.target(name: "Networking"),
.target(name: "Persistence"),
.target(name: "Analytics"),
// Features: core only. If another feature's name shows up in this list, stop the PR.
.target(name: "SearchFeature", dependencies: core),
.target(name: "ProductFeature", dependencies: core),
.target(name: "CheckoutFeature", dependencies: core),
.target(name: "ProfileFeature", dependencies: core),
.testTarget(name: "CheckoutFeatureTests", dependencies: ["CheckoutFeature"]),
]
)
UI, Domain and Data inside the feature are folders and nothing more. We do not make them separate targets, because splitting every feature in three triples the module count and gives very little back (we did that arithmetic in detail in the cost of modularization). Layer discipline comes from the internal default: the only thing the feature exposes is its entry point.
// Sources/CheckoutFeature/CheckoutEntry.swift
import Analytics
import Networking
import SwiftUI
/// Everything the feature asks of the outside world.
public struct CheckoutDependencies: Sendable {
public let httpClient: any HTTPClient
public let analytics: any AnalyticsTracking
public init(httpClient: any HTTPClient, analytics: any AnalyticsTracking) {
self.httpClient = httpClient
self.analytics = analytics
}
}
/// Everything the feature can tell the outside world. The app decides where to go.
public enum CheckoutOutput: Sendable {
case orderPlaced(orderID: String)
case loginRequired
case productTapped(productID: String)
}
public enum CheckoutEntry {
@MainActor
public static func makeView(
cartID: String,
dependencies: CheckoutDependencies,
onOutput: @escaping @MainActor (CheckoutOutput) -> Void
) -> some View {
let api = CheckoutAPI(client: dependencies.httpClient) // internal
let viewModel = CheckoutViewModel( // internal
cartID: cartID,
placeOrder: PlaceOrder(api: api),
analytics: dependencies.analytics,
onOutput: onOutput
)
return CheckoutView(viewModel: viewModel) // internal
}
}
Note that CheckoutOutput carries a productID and no Product. Between features we pass identifiers in place of objects; the receiving side loads what it needs into its own model. Android's modularization guide recommends the same. The app target turns outputs into navigation:
// App target: the only place that knows the features
import CheckoutFeature
import ProductFeature
import ProfileFeature
import SwiftUI
enum AppRoute: Hashable {
case product(id: String)
case checkout(cartID: String)
case order(id: String)
}
struct AppRouter: View {
let container: AppContainer
@State private var path: [AppRoute] = []
@State private var isLoginPresented = false
var body: some View {
NavigationStack(path: $path) {
HomeView(onProductTapped: { path.append(.product(id: $0)) })
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .checkout(let cartID):
CheckoutEntry.makeView(
cartID: cartID,
dependencies: container.checkoutDependencies
) { output in
switch output {
case .orderPlaced(let id): path = [.order(id: id)]
case .loginRequired: isLoginPresented = true
case .productTapped(let id): path.append(.product(id: id))
}
}
case .product(let id):
ProductEntry.makeView(productID: id, dependencies: container.productDependencies) { _ in }
case .order(let id):
OrderDetailEntry.makeView(orderID: id, dependencies: container.profileDependencies)
}
}
}
.sheet(isPresented: $isLoginPresented) {
LoginEntry.makeView(dependencies: container.profileDependencies)
}
}
}
One note: the package access level introduced in Swift 5.9 opens a symbol to other targets in the same package and closes it to everything outside. It is useful for keeping core types from leaking out of the package, but it does nothing to stop one feature seeing another. That rule is carried by the target's dependencies list. It is also a known situation that in Xcode builds an undeclared target import can still compile because of the shared build products directory; try it on your Xcode version and check the rule separately in CI.
Android: Gradle modules
// settings.gradle.kts
include(":app")
include(":core:designsystem")
include(":core:network")
include(":core:database")
include(":core:analytics")
include(":feature:search")
include(":feature:product")
include(":feature:checkout")
include(":feature:profile")
feature/checkout/
├── build.gradle.kts
└── src/main/kotlin/com/example/shop/feature/checkout/
├── CheckoutRoute.kt ← the only public entry point
├── ui/ ← CheckoutScreen, CheckoutViewModel (internal)
├── domain/ ← Cart, PlaceOrderUseCase (internal)
└── data/ ← CheckoutService, DTOs, mappers (internal)
// feature/checkout/build.gradle.kts
plugins {
// The project's own convention plugin: Android library, Compose and shared settings in one place
id("shop.android.feature")
}
android {
namespace = "com.example.shop.feature.checkout"
}
dependencies {
implementation(project(":core:designsystem"))
implementation(project(":core:network"))
implementation(project(":core:database"))
implementation(project(":core:analytics"))
// No project(":feature:...") line belongs here.
}
shop.android.feature is a name we made up; collecting build configuration in convention plugins is what both Gradle and the Android guide recommend. Copying the same thirty lines into fifteen feature modules is the most irritating cost of feature-first and the easiest to avoid.
The navigation contract mirrors the iOS one: the feature declares what it can say to the outside through an interface, and the app implements it.
// :feature:checkout
package com.example.shop.feature.checkout
interface CheckoutNavigator {
fun onOrderPlaced(orderId: String)
fun onLoginRequired()
fun onProductClicked(productId: String)
}
@Composable
fun CheckoutRoute(
cartId: String,
navigator: CheckoutNavigator,
modifier: Modifier = Modifier,
) {
// CheckoutViewModel and CheckoutScreen stay internal to this module
val viewModel: CheckoutViewModel = hiltViewModel()
CheckoutScreen(cartId = cartId, viewModel = viewModel, navigator = navigator, modifier = modifier)
}
// :app
@Serializable data class CheckoutDestination(val cartId: String)
@Serializable data class ProductDestination(val productId: String)
@Serializable data class OrderDestination(val orderId: String)
@Serializable data object LoginDestination
fun NavGraphBuilder.checkoutGraph(navController: NavController) {
composable<CheckoutDestination> { entry ->
val destination = entry.toRoute<CheckoutDestination>()
CheckoutRoute(
cartId = destination.cartId,
navigator = object : CheckoutNavigator {
override fun onOrderPlaced(orderId: String) {
navController.navigate(OrderDestination(orderId)) {
popUpTo<CheckoutDestination> { inclusive = true }
}
}
override fun onLoginRequired() = navController.navigate(LoginDestination)
override fun onProductClicked(productId: String) =
navController.navigate(ProductDestination(productId))
},
)
}
}
The sample uses Navigation Compose's type-safe routes; the idea is the same with any other navigation library. Now in Android takes a slightly different road here: it splits each feature into an api module that contains only navigation keys and an impl module with everything else, and one feature's impl may depend on another feature's api only. That split makes sense when the feature count is high and keeping route definitions in the app module has become a bottleneck. In smaller projects, wiring in the app is enough and does not double the module count.
Where shared data lives
If "number of items in the cart" is needed on both product detail and the home screen, that data does not belong to a feature. We push it down into its own core or data module (CartStore, :core:cart) and both features depend on that. The questions in our domain boundaries article help when deciding where to draw the line. The one thing to watch is that the core set grows slowly: every new core module is a reason for all features to rebuild.
How this relates to team structure
In his 1968 paper Conway wrote that organisations which design systems produce designs that are copies of their own communication structures. On mobile the practical consequence is very concrete: if your teams are organised around product areas (a search team, a payments team, an account team) but your code is sliced by layer, every piece of work makes every team meet in the same files. The org chart says one thing, the folder tree says another, and you pay the difference in merge conflicts.
The reverse holds too. If your teams are organised by platform or layer (a "network team", a "UI team"), feature modules have no natural owner. In that case the team structure is the first conversation to have; moving folders will not fix an organisational problem.
With a feature-first layout the CODEOWNERS file becomes readable:
# .github/CODEOWNERS
/Packages/ShopKit/Sources/SearchFeature/ @shop/search-team
/Packages/ShopKit/Sources/CheckoutFeature/ @shop/payments-team
/Packages/ShopKit/Sources/ProfileFeature/ @shop/account-team
/Packages/ShopKit/Sources/DesignSystem/ @shop/platform-team
/Packages/ShopKit/Sources/Networking/ @shop/platform-team
If you cannot write this file, meaning you cannot name a single team for a directory, the boundary is misplaced either in the code or in the organisation.
When layer-based is the right call
We want to say this plainly, because feature-first has become the default advice in recent years and gets applied to every project without a second look.
If you are one team of three or four, you have no ownership problem; everyone owns every file. If the product is young and the screens are reshaped every quarter, drawing feature boundaries today means drawing them wrong. Fixing a module boundary drawn in the wrong place costs more than drawing one that was never there. If the app has fifteen or twenty screens and a full build finishes in a minute or two, the build gain from modules is too small to measure. Android's modularization guide gives the same warning: the dominating factor is the size of the codebase, and modularizing does not make sense for every project.
Our recommendation in that situation is a single module, layer folders inside it, and one extra rule: under Views and ViewModels, keep files in sub-folders named after the feature. When the boundaries firm up, the move is a matter of dragging a folder.
Migration: one feature at a time
We do not recommend a big rewrite. Going from layer-first to feature-first can be done while product work continues, with a shippable app at every step. The order:
1. Extract core first. Design system and networking will sit beneath every feature. Until they are separate modules, the first feature you extract stays tied to the old structure. Only code that carries no domain knowledge goes into core: APIClient yes, OrderEndpoints no.
2. Pick the most isolated feature. A vertical slice that few others import, that has its own model, and that one team touches. In most apps that is settings, onboarding or notifications. Do not start with checkout; the first extraction is always the most expensive, because you are establishing the entry point pattern, the DI wiring and the test setup for the first time.
3. Move the vertical slice. Take that feature's view, view model, repository and endpoint files into the new module. From Models, move only the types this feature alone uses. For shared types (such as Product) the shortcut is tempting: make the type public and leave it where it is. Have the feature define its own model with just the fields it needs and map at the boundary.
4. Narrow the entry point. After the move, make everything internal and leave only the Entry (or Route) and the dependencies type public. Every place the compiler complains about is a piece of the feature that had leaked out.
5. Automate the rule. Before extracting the second feature, put "a feature cannot depend on a feature" into CI. In its simplest form:
# iOS: list the dependencies of targets whose name ends in Feature
swift package dump-package \
| jq -r '.targets[] | select(.name | endswith("Feature"))
| "\(.name): \([.dependencies[] | (.byName // .target // .product)[0]] | join(", "))"' \
| grep -E ': .*Feature' && { echo "A feature cannot depend on a feature"; exit 1; } || true
# Android: does any feature build file declare a project dependency on another feature
grep -rn 'project(":feature' feature --include=build.gradle.kts \
&& { echo "A feature cannot depend on a feature"; exit 1; } || true
Confirm the JSON shape of dump-package once on your own toolchain version. Sturdier approaches (lint rules, dependency analysis plugins, architecture tests) have their own articles.
6. Repeat, then shrink the old layers. With each extracted feature, ViewModels, Repositories and Models get smaller. What is left at the end is what is really shared; you give it either to a core module or to the feature that uses it most. The usual surprise with Utils is that half of it is used by exactly one feature.
How to know it is working
Three measurements are enough, and all three come from git and your build tool. The first is the number of top-level directories touched per commit:
git log --since="3 months ago" --no-merges --pretty=format:'@@%h' --name-only -- Packages/ShopKit/Sources \
| awk '/^@@/ { if (c) print n, c; c = $0; n = 0; delete seen; next }
NF { split($0, p, "/"); if (!(p[4] in seen)) { seen[p[4]] = 1; n++ } }
END { if (c) print n, c }' \
| sort -rn | head -20
(p[4] is the module name in a Packages/ShopKit/Sources/<Module>/... path; adjust it to your directory depth.) In a layer-first structure a typical piece of work touches as many directories as there are layers; for extracted features the number should drop to one or two (the feature and the app). If it does not, the feature boundary does not match the natural boundary of the work.
The second is the most-changed-files list from earlier: shared files should slide down from the top. The third is the number of modules recompiled after a one-line change in an extracted feature: the feature and the app, nothing else.
Where to stop is clear as well. If the remaining code is small and truly shared, do not force it into features. An eighty percent migration beats an unfinished hundred percent one.
Decision table
| Situation | Recommendation | Verdict |
|---|---|---|
| One team (2 to 5 people), young product, boundaries in flux | Single module, layer folders, feature-named sub-folders inside | Stay layer-based |
One team, settled product, 30+ screens, bloated Models and Utils |
Move to feature folders inside the single module, split core out as modules | Feature-first at folder level |
| 2+ teams, regular merge conflicts in shared files | Feature modules + core modules, CODEOWNERS | Feature-first, start now |
Layer modules exist (:data, :domain, :presentation) and every change rebuilds all of them |
Stop adding layer modules, extract vertical slices one by one | Incremental migration |
| Feature modules exist but import each other | Output/navigator contract, wiring in the app, CI rule | Cut the dependencies first, then add features |
A single Shared/Common module holds everything |
Split into core modules named for what they do, hand domain-aware code back to features | Do not promote the dumping ground to a module |
| Teams are organised by layer (network team, UI team) | Talk about team structure first | Moving code alone will not fix it |
| Many features, route definitions in the app module are a bottleneck | api/impl split per feature |
Worth it only at this point |
Need support on this?
Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.
Sources
- Android Developers · Guide to Android app modularization
- Android Developers · Common modularization patterns
- Now in Android · Modularization learning journey
- Android Developers · Type safety in Kotlin DSL and Navigation Compose
- Gradle · The Java Library plugin (api vs implementation)
- Apple · Organizing your code with local packages
- Swift Evolution · SE-0386 New access modifier: package
- Martin Fowler · PresentationDomainDataLayering
- Melvin Conway · How Do Committees Invent?
- GitHub Docs · About code owners
Comments and questions
Disagree with something, or stuck on this in your own project? Write to us and let's talk.