iOS Architecture Expert — Clean Modular Architecture
Agent Behavior Contract
When this skill is active, follow these rules strictly:
- Feature modules have zero UIKit/SwiftUI imports — only Foundation. Domain models, use cases, presenters, and API/cache logic never depend on a UI framework.
- Define a boundary at every layer transition — protocol or closure. A layer never references a concrete type from another layer. Use a protocol when there are genuinely multiple strategies (
FeedStore,HTTPClient,ResourceView,FeedCache); use a plain composed closure (() async throws -> Resource) when there is one — see rule 12. - Domain models are value types —
struct,Hashable,Sendable. No classes for models. - Presentation logic is framework-agnostic — presenters output view models (structs). No
UIImage,UIColor, or SwiftUI types in the presentation layer. - All dependency wiring happens in the Composition Root — the app target (or a dedicated
CompositionRootmodule). Feature modules never create their own dependencies. - Tests drive the design — one test class per use case, named by behavior (
CacheFeedUseCaseTests, notLocalFeedLoaderTests). UsemakeSUT()factory in every test class. - Test only the public API — no
@testable importoutside the Composition Root. Test targets use a plainimport Feed; a behavior that can't be observed publicly is a missing public seam (a boundary, a return value, an injected collaborator), never a reason to widen the test's access. This is deliberately stricter than the source codebase, which uses@testablein a handful of places — the full argument and the Composition Root exception live intestability-and-seams.md. - Use SPM multi-target packages for module separation —
Feed(Foundation),FeediOS(UIKit),App(composition). - Prefer async/await over closures/Combine for async operations. Use
Task.immediatefor synchronous-first execution in adapters where the deployment target allows it (iOS 26+; plainTask+ observable-state tests is the documented fallback — seeconcurrency-at-boundaries.md). - Match the project's existing test framework; default to Swift Testing for greenfield suites. For test doubles, test design, deterministic async testing, Swift Testing syntax and XCTest migration, defer to the
swift-testing-expertskill. This skill keeps only the places where a testing decision is a design decision — seetestability-and-seams.md. Two things that bite regardless:trackForMemoryLeaks/addTeardownBlockis XCTest-only (the Swift Testing equivalent is a test-scoping trait, ST-0007), and Swift Testing runs tests in parallel by default, so suites sharing a store URL or on-disk artifacts need.serialized. - Mark shared mutable state with
@MainActor— presenters, adapters, view controllers, and composition code run on the main actor. - Do not add a protocol boundary that has only one implementation and no second strategy in sight — compose a closure in the Composition Root instead. Boundaries exist to select between strategies.
Architecture Diagnostic Table
| Symptom | First check | Smallest safe fix | Deep dive |
|---|---|---|---|
| Feature module imports UIKit/SwiftUI | Dependency graph | Move type to correct layer | references/architecture-layers.md |
| "MVVM or MVP or clean architecture?" | What problem you actually have | Start MVC; extract when duplication or cross-platform reuse appears | references/presentation-patterns.md |
| View controller coordinates too much | Count its views/models/state, not its lines | Split into tiny MVCs; move creation to a composer | references/presentation-patterns.md |
| Presentation logic duplicated across controllers | Value transformation in the controller | Extract a platform-agnostic view model or presenter | references/presentation-patterns.md |
| Protocol with exactly one implementation | Is there a second strategy? | Delete it; compose a closure in the root | references/design-principles.md |
| "Should this be a singleton?" | Which of the three kinds it actually is | Inject it; let the root decide the lifetime | references/dependency-management.md |
| Every feature depends on one shared API client | Which of the four levels you're on | Invert: each module declares the interface it needs | references/dependency-management.md |
| Checking connectivity before a request | There is no reliable check | Attempt the request; configure waitsForConnectivity |
references/infrastructure-and-networking.md |
@MainActor type races despite Swift 6 |
Is it reached via a sync protocol requirement? | Hop executors in the root via Scheduler |
references/concurrency-at-boundaries.md |
| Storyboard "forces" property injection | iOS version | instantiateViewController(identifier:creator:) |
references/dependency-management.md |
| Use case both returns data and mutates state | Command–Query Separation | Split into a query and a command use case | references/design-principles.md |
| Conformance with empty method bodies | Interface Segregation | Split the protocol, or compose optional capabilities in a struct | references/design-principles.md |
Domain model has Codable/@Model/CodingKeys |
Framework detail in an agnostic type | Add a private mirror type in the infrastructure | references/architecture-layers.md |
| Cross-cutting concern duplicated across UI types | Where the concrete types are known | Decorate at the composition site | references/adapters-and-proxies.md |
| Retain cycle between presenter and view | Proxy wiring | Add WeakRefVirtualProxy |
references/adapters-and-proxies.md |
| CoreData crashes on launch | Fallback strategy | Add InMemoryFeedStore fallback |
references/composition-root.md |
| Duplicate network requests on refresh | isLoading guard |
Use LoadResourcePresentationAdapter |
references/adapters-and-proxies.md |
| Test requires real infrastructure | Protocol boundary | Extract protocol at boundary | references/testability-and-seams.md |
| Sendable warning at composition boundary | Closure annotations | Add @Sendable + @MainActor |
references/concurrency-at-boundaries.md |
| Pagination won't load next page | loadMore closure |
Verify recursive composition in FeedViewAdapter |
references/composition-root.md |
| Cache validation doesn't run | Task.immediate usage |
Use fire-and-forget with Task.immediate |
references/concurrency-at-boundaries.md |
| Debug/test-only code in the production composition | #if DEBUG isolation |
Move it to a #if DEBUG SceneDelegate subclass |
references/composition-root.md |
| SwiftUI app has no obvious place to wire dependencies | Views constructing their own dependencies | Compose in @main App/Scene, inject via init or environment |
references/swiftui-composition.md |
Gotchas
Task.immediate(SE-0472, Swift 6.2) has runtime availability aligned to iOS 26/macOS 26 with no back-deployment. On earlier targets, fall back to plainTask— the work is then enqueued, not started synchronously, so handle the synchronousdidStartLoadingrequirement explicitly and make tests await observable state.CoreDataFeedStore(storeURL: URL(fileURLWithPath: "/dev/null"), contextQueue: .main)is effectively in-memory AND synchronous — it's what makes acceptance tests deterministic without sleeps.- Capture
[store], never[self], in@Sendableclosures created inside the@MainActorservice — capturing self drags MainActor isolation into the closure (compile error under Swift 6). UIWindow(frame:)andUIWindow()are deprecated on iOS 26 ("Use init(windowScene:)") — deprecated, not obsoleted: they still compile with a warning, which is acceptable in test targets. The(UIWindowScene.self as NSObject.Type).init() as? UIWindowScenedummy-scene trick works (no public initializer exists) but is an unsanctioned runtime bypass that may break in any release — seetestability-and-seams.md.didEndDisplaying(cell:forRowAt:)fires for OLD cells after a data-source update — never cancel by indexing the NEW model; cancel through a[IndexPath: CellController]registry populated incellForRowAt.- Diffable data sources don't reflow on Dynamic Type changes — reload when the content size category changes via
registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { ... }(traitCollectionDidChange(_:)is deprecated since iOS 17); useapplySnapshotUsingReloadDataguarded by anitemIdentifiersequality check. Thread.isMainThreadis not "on the main queue" (the main thread can run other queues) — prefer@MainActorisolation over runtime checks.- Load
NSManagedObjectModelonce and cache it statically — loading it twice registers duplicateNSEntityDescriptions claiming the sameNSManagedObjectsubclasses (undefined behavior). - A diffable data source has no sections at all until the first snapshot is applied — guard
numberOfSectionsin test DSLs, or they crash before the first render. adjustsFontForContentSizeCategorydefaults tofalse—UIFont.preferredFont(forTextStyle:)alone does not give you Dynamic Type.- An
NSKeyValueObservationmust be retained or the observation is removed immediately; release it deliberately (e.g. indidEndDisplaying). - Diffable data sources request cells eagerly, ahead of display, based on
estimatedRowHeight— set a realistic estimate, and move expensive work fromcellForRowAttowillDisplay. - A
@MainActortype reached through a non-isolated synchronous protocol requirement is not isolated — protocol witness tables don't carry isolation and there is no suspension point, so it runs on the caller's thread with no warning and no error. Hop executors in the Composition Root. URLSessionholds a strong reference to its delegate until you invalidate the session —invalidateAndCancel()or you leak until the app exits.- Setting anything on a
URLSessionConfigurationafter creating the session has no effect. Taskis notAnyCancellable: it does not cancel ondeinit, andTask.isCancelledmust be checked manually. Combine'sFutureruns eagerly,Taskdoes not — useTask.immediateto preserve that timing.
Architecture Layers
| Layer | Responsibility |
|---|---|
| Feature | Domain models (struct, Sendable) and abstract use-case protocols. Zero framework imports beyond Foundation. |
| API | Endpoint enums, static mappers with private Decodable types, HTTPClient protocol. |
| Cache | Store protocols, local models (decoupled from domain), use-case orchestrators, cache policy objects. |
| Presentation | Generic LoadResourcePresenter<Resource, View>, view model structs, localized error strings. |
| UI (UIKit) | View controllers, cells, DiffableDataSource, CellController type-erasure. Conforms to presenter view protocols. |
| UI (SwiftUI) | @Observable view models, View composition, environment-based DI. Same presenter patterns, different binding. |
| Composition | Composer static factories, PresentationAdapter, WeakRefVirtualProxy, FeedViewAdapter. App-target only. |
Which layer a line of code belongs to is decided by what kind of logic it is: application-specific (use cases), application-agnostic (business models and policies), or framework (infrastructure). That distinction — not the folder name — is the rule.
Full code examples: architecture-layers.md · The reasoning: design-principles.md · Choosing MVC/MVVM/MVP for the presentation + UI layers: presentation-patterns.md
Key Patterns Quick Reference
| Pattern | Purpose |
|---|---|
| Adapter | Connect two APIs that don't match (LoadResourcePresentationAdapter, FeedViewAdapter) |
| Decorator | Add behaviour keeping the same interface — cross-cutting concerns at the composition site |
| Composite | Combine implementations behind one interface — fallback, retry, layered caching |
| Interception | Inject a side effect at the composition site so neither collaborator knows (cache-on-success) |
| Null Object | Neutral behaviour instead of optionality — the production sibling of a dummy double |
LoadResourcePresenter<Resource, View> |
Reusable loading/error/success state machine with generic mapper |
WeakRefVirtualProxy<T> |
Break retain cycles in presenter->view binding via conditional conformance |
LoadResourcePresentationAdapter |
Generic async loader bridging use cases to presenters with cancellation |
FeedViewAdapter |
Maps domain models to CellController array, composes recursive loadMore |
Paginated<Item> |
Recursive pagination with optional loadMore closure (Sendable) |
Composition Root / FeedService |
@MainActor orchestrator with lazy init, Scheduler, and fallback strategy |
Scheduler protocol |
Abstract store execution context for CoreData/InMemory polymorphism |
InMemoryFeedStore |
@MainActor, NSCache-backed PRODUCTION fallback when CoreData fails to init — not a test double (acceptance tests use the real CoreDataFeedStore at /dev/null) |
LoaderSpy<Param, Resource> |
Generic async test spy using AsyncThrowingStream for UI integration tests |
| Specification Pattern | Protocol-driven shared test specs across store implementations |
| UI Composer (static factory) | Wire presenter->adapter->view chain per feature (FeedUIComposer.feedComposedWith) |
| Static Mapper | Pure function for data transformation — FeedItemsMapper.map(_:from:) |
| Cache Policy | Business rule encapsulation for cache validation — FeedCachePolicy.validate(_:against:) |
CellController |
Type-erased cell composition — wraps UITableViewDataSource + Delegate + Prefetching |
Feature Decision Tree
Starting a new feature? Follow this path:
- Define the domain model ->
structin Feature layer,Hashable,Sendable - Add concurrency annotations ->
@MainActoron view protocols,Sendableon models - Need remote data? -> Endpoint enum + static mapper in API layer
- Need persistence? -> Store protocol + local model + cache policy in Cache layer
- Need to display it? -> Start with MVC and split into tiny MVCs; reach for a view model or presenter only when duplication or cross-platform reuse demands it. Once two features share the loading/error/success shape, generalize to
LoadResourcePresenter+ view model struct in the Presentation layer - UIKit or SwiftUI? -> Build view layer, conform to
ResourceViewprotocols - Wire it up -> Composer + adapter + proxy in Composition Root
- Verify concurrency -> Build with
SWIFT_STRICT_CONCURRENCY = complete, run Thread Sanitizer
Step-by-step guide: feature-implementation-workflow.md
Guardrails
- Do not create concrete types inside feature modules — all instantiation belongs in the Composition Root
- Do not reach for
.sharedfrom a component — inject it and let the Composition Root own the lifetime (lazy varinFeedServicegives a shared instance without global state). Note "singleton" means three different things — seedependency-management.md - Do not reference a DI container, coordinator or router from a feature module — that is the Service Locator anti-pattern
- Do not check connectivity before making a request
- Do not add
@MainActorto domain types or store protocols — only presentation, adapters, and composition - Do not use
@unchecked Sendable— redesign the type as a value type or use@MainActor - Do not embed cache policy logic inside the loader — keep it as a separate type
- Do not put navigation logic in view controllers — use closure callbacks wired in the Composition Root
- Do not use
@testable import— test through the public API only; widen production access (public) intentionally rather than tunneling intointernal/privatefrom tests - Do not generalize with a single client. The case study's rhythm is duplicate → generalize → replace → delete: copy the working concrete component for the second feature, and only extract generics (
LoadResourcePresenter-style) once two green implementations exist side by side - Do not encode memory-management policy in a component — lifetime is the composer's decision, which is why the presenter holds its view strongly and the composer wraps it in a proxy
- Do not let a feature module depend on the Composition Root — invert it and pass a closure in
- Do not reach for coordinators, routers or a DI container before the complexity justifies them; when you do, they live in the Composition Root only
- Do not make a domain or store boundary
asyncbecause one implementation is — keep it synchronous and inject the execution context at composition time - Defer non-architectural concurrency questions (task groups, async sequences, actor reentrancy) to the
swift-language-expertskill
Verification Checklist
When implementing or reviewing architecture:
- Build with
SWIFT_STRICT_CONCURRENCY = complete— zero warnings - No UIKit/SwiftUI imports in Feature/API/Cache modules
- All protocol boundaries have corresponding test doubles
- Test targets
importmodules plainly — grep for@testablereturns nothing; every assertion goes through the public API makeSUT()exists in every test class- Store initialization failure is handled deliberately (in-memory fallback or Null Object — a logged product decision)
- Run Thread Sanitizer (
-enableThreadSanitizer YES) — zero data races WeakRefVirtualProxywraps all view references in UIKit composition (or@Observablein SwiftUI)- Pagination
loadMoreisnilfor the last page - Every protocol boundary has, or is credibly about to have, more than one implementation
- No conformance in the codebase has an empty method body
- No feature module imports the Composition Root
- Deleting
import UIKit/import SwiftUIfrom every presentation file still compiles
Reference Router
Open the smallest reference that matches the question:
- Why, before how
- design-principles.md — symptom→principle table, CQS, ISP, dependency inversion & rejection, DTOs, invalid states, Functional Core/Imperative Shell
- Dependencies & Ownership
- dependency-management.md — singleton taxonomy, the four levels, injection techniques, composer rules, Pure DI vs containers, lifetimes
- Infrastructure
- infrastructure-and-networking.md — connectivity (never pre-flight), URLSession configuration & delegate lifetime, representable states, CoreData details, logging & fallbacks
- Architecture & Layers
- architecture-layers.md — layer boundaries, domain models, protocols, do-you-need-persistence
- spm-project-structure.md — onion, horizontal/vertical slicing, module layout, Package.swift, CI
- Presentation
- presentation-patterns.md — MVC/MVVM/MVP, diagnosing a bloated controller, where memory policy lives
- SwiftUI
- swiftui-composition.md —
@main Appcomposition root, environment DI, NavigationStack routing at the root, @Observable presenters, acceptance seams
- swiftui-composition.md —
- Composition & Wiring
- composition-root.md — what the root is, lifetimes, FeedService, Scheduler, fallback, logging
- adapters-and-proxies.md — adapter, proxy, composite, decorator, interception, pagination wiring
- Concurrency in Architecture
- concurrency-at-boundaries.md — why boundaries stay synchronous, Scheduler, @Sendable, Task.immediate, cancellation
- Testing
- testability-and-seams.md — testing decisions that are design decisions: public-API testing, extracting boundaries, contract specs, acceptance tests through the root
- Everything else about testing → the
swift-testing-expertskill
- Workflow
- feature-implementation-workflow.md — step-by-step feature building, spy-first/protocol-last, legacy-code extraction