2026-10-16 6 min read Software Engineer

Modular iOS Architecture for Agentic Coding

This guide expands the architecture advice in my software factory series. It collects the principles I find most useful for staying agile as agents take on more implementation work.

I see modular architecture as the foundation for that agility. Without boundaries that keep changes understandable and verifiable, I would find it very difficult to maintain quality as implementation throughput grows. Modularity is an established idea. Coding agents make its benefits especially valuable because we can attempt more changes, more often, and in parallel.

Design principles

1. Give each module one clear responsibility. A feature owns its experience, a service owns domain behavior, and a utility owns infrastructure. Keep its code, tests, resources, and localized strings together.

2. Make changes understandable locally. An agent working on Home should need Home and the contracts it consumes. If it must inspect several unrelated implementations, improve the boundary or dependency graph.

3. Expose small contracts and hide implementation decisions. Interfaces contain the behavior, inputs, results, and entry points consumers need; storage details, vendor types, and internal presentation objects stay private. Document behavioral and concurrency promises, following Ousterhout’s information-hiding principle.

Swift example: a contract that hides storage
// SearchHistoryRepositoryInterface — excerpt
public protocol SearchHistoryRepository {
    func recentEntries() -> [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 and where to navigate; the destination owns how it builds and runs its flow. Keep cross-feature assembly out of views and view models.

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. Separate contracts when that separation helps. An Interface module contains the public contract: protocols, entry points, and input or result values, compiled separately from the Implementation. Consumers import that contract, so they cannot reach concrete coordinators or vendor adapters by accident. 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 hiding their concrete types would add wrappers or AnyView without useful isolation.

6. Keep composition explicit. Each implementation knows how to register its dependencies, while one application composition root selects the implementations. Keep business decisions in features and services, with dependency injection handling the wiring.

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 dependencies explicitly and reject imports of another module’s Implementation, except where composition or its own tests require it. Automated checks should catch these violations before review. I keep reusable Swift checks in Pitcrew alongside the app’s separate module-dependency checks.

8. Make module boundaries ergonomic to use. Keep navigation, view construction, and dependency wiring simple at the call site. Injected is a good example: registration stays explicit, while consumers request dependencies through their contracts with little ceremony. This applies dependency inversion without threading service dependencies through every constructor: consumer-side wiring is almost boilerplate-free, with contracts and registration defined once. Consistent wiring lets an agent add a feature without inventing another builder, router, or dependency pattern.

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
}

Practical rules for iOS projects

  1. Optimize for build speed from the beginning. Use Tuist’s module cache so unchanged modules remain compiled binaries, with artifacts matching the build configuration and linking mode. My mise run app:run command 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.

  2. 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.

  3. Do not be afraid of dozens or hundreds of modules. 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, rather than one umbrella; keep a component’s private helpers with it instead of fragmenting every type.

  4. Use selective testing with granular dependencies. Run the changed modules’ tests and the affected consumers, rather than every suite for every change. Narrow module dependencies make that selection more precise; shared contracts still need consumer validation, and uncertain impact should retain broader checks.

  5. Batch the selected tests. After selecting the relevant suites, my merge workflow runs their tests through one generated aggregate scheme and one simulator invocation. This avoids repeatedly paying setup costs for separate schemes while preserving the selected coverage.

  6. 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.

  7. Keep linking and previews deliberate. My app modules use static linking by default; modularity does not require loading hundreds of dynamic frameworks at launch. A separate preview workflow generates selected modules dynamically, with compatible cache artifacts for that configuration.

  8. Measure the entire feedback loop. Record generation, builds, tests, cache reuse, and review effort to find the next bottleneck. An artifact existing is not evidence that the build reused it; use 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 in the CLI, 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