Modular iOS Architecture for Agentic Coding
I have always encouraged modular architecture. Clear ownership and explicit contracts make changes easier to understand, implementations easier to replace, and independent work easier to do in parallel. Focused modules also support focused tests and reuse of unchanged build artifacts, shortening the feedback loop.
For a long time, the constraint was time. Establishing boundaries, moving code, and updating dependencies competed with delivering the next product feature. Even when the benefits were clear, improving modularity could be difficult to prioritize.
Coding agents change that calculation. They reduce the manual work of establishing and maintaining those boundaries while making it practical to attempt more changes, more often, and in parallel. I no longer see modularity as an optional improvement to fit between product features. It is the foundation for keeping changes delivered by coding agents understandable and verifiable, and for keeping the feedback loop fast as implementation throughput grows.
This guide expands the architecture advice in my software factory series. It covers the boundaries that support those goals and the iOS workflow that makes them practical.
Design principles
1. Give each module one clear responsibility
Group code around a capability, policy, or invariant. Independently usable components should be independently importable. A shared label such as “utilities” or “design system” does not establish cohesion: ask whether the exported capabilities share a contract, maintain an invariant together, or form one feature whose parts are not consumed separately.
Keep a component’s private helpers and collaborating types together, along with its tests, resources, and localized strings. Splitting one owned decision across modules that always require synchronized edits creates unnecessary coordination. Resources used by only one module belong to that module; shared resources can have a focused shared owner.
Swift example: independently importable components
For example, a profile uses an avatar and a portfolio uses a price chart. Bundling both components into DesignSystem makes both consumers depend on the same target:
// Before — consumer excerpts; view declarations omitted
// ProfileImplementation/ProfileHeader.swift
import DesignSystem
AvatarView(image: user.image)
// PortfolioImplementation/PriceHistory.swift
import DesignSystem
PriceChart(prices: prices)Move each component, its private helpers, tests, and resources into its own target. Both may depend on focused token modules:
// After — the same consumers use independent modules
// ProfileImplementation/ProfileHeader.swift
import AvatarComponent
AvatarView(image: user.image)
// PortfolioImplementation/PriceHistory.swift
import PriceChartComponent
PriceChart(prices: prices)A chart-only edit now changes PriceChartComponent; Profile has no dependency on that target. This requires separate build targets and updated manifest dependencies. Folders inside DesignSystem would leave the shared dependency unchanged.
2. Make changes understandable locally
An agent working on Home should need Home and the contracts it consumes. Consumers should have enough domain meaning and interaction guarantees to make decisions within their own responsibility. Contracts should make relevant states, units, errors, ordering, cancellation, and lifetime explicit through API design and supporting documentation.
If a local change requires reconstructing those rules from unrelated implementations, improve the boundary. Express meaningful distinctions in values and operations, and let the owner manage required sequencing. A precise name can resolve a simple ambiguity; types and owner-controlled operations help when they enforce a real rule. Locality is about the knowledge required to make a change, not the number of files opened.
Swift example: distinguish loading from an empty result
Suppose an orders store exposes [] both while loading and after a successful empty response. Home needs different UI for those states, but its contract cannot distinguish them:
// Before — OrdersInterface
public protocol OrdersStore {
var orders: [Order] { get }
}
// HomeImplementation — rendering excerpt
if store.orders.isEmpty {
showEmptyState() // Also shown while the initial request is loading.
}Expose the states the store already owns, then let Home decide how to present them:
// After — OrdersInterface
public enum OrdersState {
case loading
case loaded([Order])
case failed
}
public protocol OrdersStore {
var state: OrdersState { get }
}
// HomeImplementation — rendering excerpt
switch store.state {
case .loading:
showLoadingState()
case .loaded(let orders) where orders.isEmpty:
showEmptyState()
case .loaded(let orders):
showOrders(orders)
case .failed:
showErrorState()
}The store implementation maps its loading, success, and failure outcomes to this contract. Home can now implement its presentation using OrdersInterface, without inspecting request or cache internals to infer what an empty array means.
3. Expose small contracts and hide implementation decisions
Consumers should depend on the capability they need while the owner retains control over its representation and invariants. Expose domain values and operations; keep storage details, vendor representations, and internal presentation objects private. Use access control to prevent callers from bypassing the owner’s rules.
A contract can have few methods and still expose storage, vendor, or mutation decisions that belong to its owner. Following Ousterhout’s information-hiding principle, keep those decisions behind the module boundary.
Swift example: hide storage decisions
// Before — SearchHistoryRepositoryInterface
import CoreData
public protocol SearchHistoryRepository {
var context: NSManagedObjectContext { get }
}Consumers must build queries and handle managed-object lifecycle. Changing storage can require changes in every consumer. Expose the operations they need instead:
// After — SearchHistoryRepositoryInterface
public protocol SearchHistoryRepository {
/// Saved entries, newest first; empty when none exist.
func recentEntries() throws -> [SearchHistoryEntry]
func save(_ entry: SearchHistoryEntry) throws
}
// Consumers use domain values, without knowing the storage format.4. Connect features through entry points
The caller decides when to invoke another feature and supplies its inputs and presentation or integration context. The destination owns its construction and internal flow. An entry point may be a factory, route handler, or focused operation; apply the same ownership rule to navigation, deep links, app intents, and background work.
Keep destination-specific assembly out of the caller’s views and view models. The caller can present a returned view or controller without knowing how the destination builds it.
Swift example: opening another feature
// Inside HomeCoordinatorImpl
func showAssetDetail(assetID: Asset.Id, marketID: Market.Id) {
@Dependency var assetDetailEntryPoint: AssetDetailEntryPoint
assetDetailEntryPoint.attach(
to: navigationController,
assetID: assetID,
marketID: marketID,
source: .home
).start()
}5. Introduce abstraction where it protects a useful boundary
A protocol, wrapper, factory, or separate Interface module should protect a concrete decision, variation, or invariant. Evaluate both the isolation it provides and the work it adds for consumers. A single implementation can justify a boundary for testing, policy ownership, or platform containment.
An Interface module contains the public contract: protocols, entry points, and input or result values, compiled separately from the Implementation. Consumers cannot accidentally reach concrete coordinators or vendor adapters. If Home and Accounts need to open each other, each Implementation can depend on the other’s independent Interface instead of forming an Implementation-to-Implementation cycle. Interface dependencies must still form an acyclic graph.
A standalone module exposes its concrete API directly and still provides access control and a build/cache boundary. Use it for shared values and SwiftUI components when additional wrappers or type erasure provide no useful isolation.
Also look for competing mechanisms serving the same concern. If callers must choose between equivalent networking, navigation, or DI stacks, consolidate them unless distinct requirements or an explicit migration justify their coexistence.
6. Keep composition explicit
Each module owns its internal assembly, while a discoverable application composition path selects implementations and their lifetimes. Registration, constructor assembly, and generated factories can all serve this purpose. Apps, extensions, tests, and previews may have separate composition roots.
Keep business decisions in features and services. Composition may select an export-policy implementation; that implementation decides whether a document can be exported.
In my project, implementations expose registration methods that the application composition root calls:
Swift example: registration stays with the implementation
// HomeImplementationModule — registration excerpt
public static func register() {
DependencyInjectionContainer.shared.register(
.lazy { HomeEntryPointImpl() },
for: HomeEntryPoint.self
)
}
// AppComposition calls HomeImplementationModule.register().
// Consumers depend on HomeInterface and resolve HomeEntryPoint.7. Enforce the dependency graph
Declare direct dependencies explicitly and reject forbidden relationships, including imports of another module’s Implementation except where composition or its own tests require them. Transitive availability does not replace a declared direct dependency. Automated validation should enforce the project’s cycle and dependency-direction rules before review; the compiler, project generator, or dedicated checks may provide that enforcement.
Review the graph’s shape as well as individual edges. Trace whether a change to one capability unnecessarily reaches consumers of unrelated capabilities. A widely imported foundation can be appropriate; identify the coupling before splitting it. I keep reusable Swift checks in Pitcrew alongside the app’s separate module-dependency checks.
8. Make module boundaries ergonomic to use and test
Consumers should express their intent and meaningful configuration without repeating destination-owned construction, conversions, or lifecycle recipes. Honor explicitly supplied dependency contexts and lifetimes. Focused tests should be able to provide the collaborators their subject actually uses without constructing unrelated parts of the application.
Injected is one way to keep registration explicit while consumers request dependencies through contracts with little ceremony. Constructor injection and owner-local factories can be equally ergonomic. Judge the work required to use, change, and test the boundary. Consistent patterns let an agent extend the application without inventing another wiring or adaptation recipe.
Swift example: resolving a dependency with Injected
import Injected
import HomeInterface
// HomeEntryPoint is registered by its owning implementation.
@MainActor
final class AppCoordinator {
@Injected(resolve: .lazy)
private var homeEntryPoint: HomeEntryPoint
}To evaluate a boundary, start with a concrete change: what changes, who owns that decision, and which consumers must participate? Explain why those consumers need to change or recover knowledge elsewhere. A public concrete type, a single implementation, or a widely imported module alone does not establish an architectural problem.
I turned these principles into Modular iOS Audit, a skill for Claude Code, Codex, and other Agent Skills-compatible agents. It audits an iOS codebase and produces an interactive report with source evidence and concrete correction examples.
Practical rules for a fast iOS feedback loop
-
Optimize for build speed from the beginning. Use Tuist’s module cache so unchanged modules can remain compiled binaries, with artifacts matching the build configuration and linking mode. My
mise run app:runcommand generates the cached workspace, prompts for an iPhone or simulator in the CLI, then builds, installs, and launches the app. With a warm cache, this usually takes around 30–40 seconds in my setup. - Open the smallest useful project. Focus the workspace on the module being edited and the sources required by its dependency graph, letting compatible modules outside that scope stay cached. I use separate commands for the app, current diff, explicit modules, and SwiftUI previews; the full-source workspace is a debugging fallback.
- Let responsibilities determine the module count. I split my design system of roughly 50 components into dedicated modules, which made a big practical difference to performance and reduced the dependency-graph problems caused by unrelated changes. Consumers now import the components and token sets they use. Keep each component’s private helpers with it.
- Use selective testing with granular dependencies. Run the changed modules’ tests and affected consumers. Shared contracts still need consumer validation, and uncertain impact should retain broader checks.
- Batch the selected tests. My merge workflow runs selected suites through one generated aggregate scheme and one simulator invocation. This avoids repeatedly paying setup costs for separate schemes while preserving the selected coverage.
- Automate module creation and resource access. A scaffolder should establish the folder layout, manifest, test setup, and composition wiring consistently. Generate typed asset and localization accessors for the owning resource bundle so moving a component does not leave it depending on application resources.
- Keep linking and previews deliberate. My app modules use static linking by default. A separate preview workflow generates selected modules dynamically, with compatible cache artifacts for that configuration.
- Measure the entire feedback loop. Record generation, builds, tests, cache reuse, and review effort to find the next bottleneck. Verify cache reuse through actual build or generation evidence, then improve the slowest recurring step.
Workflow examples from my project
# Cached app workspace — generation only
mise run tuist:generate:app
# App run — choose an iPhone/simulator, build, install, launch
mise run app:run
# Review — changed modules stay visible as source
mise run tuist:generate:diff
# Focus — HomeImplementation and graph-required source modules; rest cached
mise run tuist:generate:targets HomeImplementation
# SwiftUI preview — focused dynamic workspace for HomeImplementation
mise run tuist:generate:preview HomeImplementation
# Scaffold a module with consistent wiring
mise run module:new Scenes/Wallet --kind split
# Module tests and dependency checks
mise run tuist:test Home
mise run modules:lint
# Batch selected merge tests into one invocation
MERGE_TEST_SCHEME=1 AFFECTED_TESTS=1 mise run tuist:test