Feature Modules in iOS: Vertical Slices vs Layers
Folder structure is not architecture, but it can reveal architecture.
It can also hide it.
An iOS app can be organized by technical layers, by features, by packages, by screens, by domains, or by some mixture of all of them. None of these shapes is automatically correct. The right organization is the one that makes change easier for the team working in that codebase.
Still, there is one question worth asking often:
When a feature changes, how much of the project do you have to touch?
#Layer-Based Organization
A classic structure groups code by technical role:
Sources/
Networking/
Persistence/
Views/
ViewModels/
Repositories/
UseCases/
Models/
This can be easy to understand at first because every file has an obvious category. If you create a ViewModel, it goes in ViewModels. If you create a repository, it goes in Repositories.
The problem appears when product work is feature-shaped.
Adding favorites might require touching:
Views/FavoritesView.swift
ViewModels/FavoritesViewModel.swift
UseCases/LoadFavoritesUseCase.swift
Repositories/FavoritesRepository.swift
Models/Favorite.swift
Networking/FavoritesAPI.swift
The feature is spread across the app. The folder structure tells you what kind of object each file is, but not what product capability those files belong to.
#Feature-Based Organization
A feature-based structure groups code by product capability:
Sources/
Features/
Products/
ProductsView.swift
ProductsViewModel.swift
LoadProductsUseCase.swift
ProductsRepository.swift
Favorites/
FavoritesView.swift
FavoritesViewModel.swift
LoadFavoritesUseCase.swift
FavoritesRepository.swift
Shared/
Networking/
Persistence/
DesignSystem/
Now a feature's UI, state, use cases, and local abstractions live close together. When favorites changes, you mostly work in Features/Favorites.
This is a vertical slice. It organizes around user-facing capability instead of technical category.
#Vertical Does Not Mean Everything Is Duplicated
A bad version of feature slicing duplicates shared infrastructure in every feature.
That is not the goal.
Networking clients, design system components, persistence primitives, analytics clients, and shared domain concepts can still live outside features.
The distinction is ownership.
If a type belongs to one feature's behavior, keep it in the feature. If it is a stable cross-feature tool, move it to shared infrastructure.
Features/
Checkout/
PlaceOrderUseCase.swift
CheckoutViewModel.swift
CheckoutView.swift
Shared/
Networking/
HTTPClient.swift
Analytics/
AnalyticsTracking.swift
DesignSystem/
PrimaryButton.swift
The checkout feature can depend on shared tools without losing its own boundary.
#Layers Inside a Feature
Feature-based organization does not mean layers disappear. It means layers become local.
Features/
Products/
UI/
Domain/
Data/
Composition/
This can be useful for larger features. A small feature may not need subfolders at all. A larger feature may benefit from local structure.
The key is that the feature remains the top-level unit of change.
#When Packages Help
Swift packages can enforce boundaries more strongly than folders.
A folder says, "Please do not import this from there." A package can make that impossible or at least explicit.
Modularizing into packages can help when:
- multiple teams own different features
- build times are painful
- boundaries are frequently violated
- features need independent previews or test targets
- shared code has become too easy to import casually
But packages add cost. They introduce manifests, targets, dependency rules, and sometimes resource management complexity. Do not split into packages just to make the project look modular.
Start with folders. Move to packages when enforcement is worth the overhead.
#Dependency Direction
Feature modules should depend inward or sideways through stable shared abstractions, not randomly across product features.
This is suspicious:
Checkout imports Products
Products imports Favorites
Favorites imports Checkout
Those dependencies make features harder to change independently.
Instead, shared concepts can move to a common module:
Checkout imports CatalogDomain
Products imports CatalogDomain
Favorites imports CatalogDomain
The goal is not zero dependencies. The goal is understandable dependencies.
#Composition Across Features
If features are isolated, where do they connect?
At composition boundaries.
The app composition root can assemble shared infrastructure and pass dependencies into feature factories:
struct AppCompositionRoot {
let httpClient: HTTPClient
let analytics: AnalyticsTracking
func makeProductsFeature() -> ProductsFeatureFactory {
ProductsFeatureFactory(
repository: RemoteProductsRepository(httpClient: httpClient),
analytics: analytics
)
}
}
This keeps feature modules from creating concrete infrastructure on their own. It also keeps the app-level dependency graph visible.
See The Composition Root in SwiftUI Apps for a concrete version of this idea.
#Choosing a Structure
I would not use the same structure for every app.
For a small app:
Features/
Shared/
For a medium app:
Features/
Products/
UI/
Domain/
Data/
Checkout/
Shared/
Networking/
Persistence/
DesignSystem/
For a large app:
Packages/
ProductsFeature/
CheckoutFeature/
SharedNetworking/
DesignSystem/
App/
CompositionRoot.swift
The structure should grow with the reasons for it.
#What You Should Remember
Layer-based organization groups files by what they are. Feature-based organization groups files by what they change with.
Most product work is feature-shaped, so vertical slices often make iOS apps easier to navigate and change. Keep shared infrastructure shared, keep feature behavior close to the feature, and use packages only when stronger boundaries are worth the cost.
Architecture is not the folder tree, but the folder tree should make the architecture easier to see.
Read next
Testing Architecture, Not Implementation Details
Learn how to test architecture in Swift by focusing on behavior, boundaries, contracts, and meaningful composition.