§ 06 · 2026 · 05 · 01 · Modularity

Modular iOS apps with Swift Concurrency

Modularity isn't a build-system question. It's about whether features can be reasoned about in isolation — read alone, tested alone, replaced alone.

Ask three iOS developers what "modular" means and you'll get three answers. One will talk about Package.swift targets and binary-framework boundaries. Another will mention "feature modules" with FooKit, BarKit, and a top-level glue package. The third — usually the most senior — will say something like "I just want each screen to make sense without reading the rest of the app."

The third answer is the only one that scales. Build-system modularity helps with compile times. It doesn't help with feature comprehension; you can have a perfectly factored package graph where every feature still reaches into a god-object AppCoordinator and reads from a singleton UserSession. The graph is modular; the runtime isn't.

Modularity by tree, not by graph

What you actually want is for each feature to:

  • Declare its needs. A list of services it requires from above. Compile-time check, not "I'll grab a singleton."
  • Own its outputs. The intents it can produce. Other features observe these explicitly, not via a global event bus.
  • Be replaceable. Swapping a feature for a different one shouldn't require touching unrelated parts of the app.
  • Be removable. Deleting a feature should remove a self-contained chunk of code and exactly its references.

The pattern that gives you this — independent of build-system choices — is the dependency-tree architecture: every feature is a unit, units compose into a tree, dependencies flow down, intents flow up. Uber's RIBs made this concrete in 2017. napkin is the Swift 6 version: final actor for business logic, @MainActor for routing, protocol composition for "inheritance."

What a feature looks like

Every feature is a "napkin": one folder, 5–6 files. The shape is identical across the app:

CartNapkin/
CartNapkin/
├── CartNapkinBuilder.swift              # Dependency + Component + Builder
├── CartNapkinInteractor.swift           # Routing/Listener/Presentable protocols + actor
├── CartNapkinRouter.swift               # ViewControllable + Router
├── CartNapkinView.swift                 # SwiftUI view
└── CartNapkinHostingViewController.swift # UIHostingController + PresentableListener

Open the folder, read those five files in order, and you know what the cart does. The dependency protocol tells you what it needs from above. The listener protocol tells you what it sends to its parent. The interactor tells you the business logic. The router tells you what children it can spawn. The view tells you what it renders.

That's modularity at the unit you actually care about — the feature — not at the unit your build system happens to operate on.

What composition looks like

The tree assembles top-down. Each parent's Component conforms to its children's Dependency protocols:

RootBuilder.swift
final class RootComponent: Component<RootDependency>, @unchecked Sendable {
    var authService: AuthService { dependency.authService }
    var analyticsService: AnalyticsService { dependency.analyticsService }
}

extension RootComponent: HomeDependency, ProfileDependency, CartDependency {}

Adding a new feature is a four-line change to the parent: import the builder, add its Dependency protocol to the component's conformance list, instantiate it, attach it. The compiler enforces the dependency contract — if Cart's CartDependency requires a PaymentService and Root doesn't satisfy it, Root won't compile.

You don't need a binary framework per feature

One trap to avoid: splitting every feature into its own SwiftPM target. It feels modular and it kills your build time for years.

Targets-per-feature only helps when (a) features genuinely have no cross-references, and (b) the build system can parallelize across them. In practice, features have cross-listeners and cross-types ("home wants to push to profile"), and you end up with a binary-framework graph that still has every feature talking to every other through a shared "interfaces" module. You've added build-system complexity without buying isolation.

The dependency-tree pattern gives you runtime isolation without build-system isolation. Every feature is in one folder of the same target, and the type system enforces the contracts. If your build is slow, fix it with module structure later, when you understand the dependency graph well enough to know which seams are real.

Removing a feature

Pull a feature's builder out of its parent's component conformance list and delete the feature's folder. The compiler will tell you about every remaining reference. There should be zero — listeners are protocols, the parent stops conforming, the dependency is gone.

That's the test of whether your modularity is real. If deleting a feature feels safe and the diff is local, you have it. If you'd have to grep half the app to find the references, you don't.

Try it

The RibHouse example is a three-feature tree: a Launch parent, a LoggedOut child, a LoggedIn child. ~600 lines of Swift, every file in its own folder, every dependency declared. Delete a child, the build complains in one place, and the parent's extension RootComponent: ... tells you which conformance to remove. The tutorial walks through how each piece composes.

You don't need napkin to do this. The pattern is older than napkin. But you do need some framework — or to roll your own — to get the protocol-composition pieces right under Swift 6 strict concurrency. napkin is one option, and it ships with the Xcode templates so the boilerplate stays cheap.