Keep AI agents inside your iOS architecture
- AI
- Clean Architecture
- iOS
On this page
Coding agents are now part of the everyday iOS toolbox. Xcode 27 runs them directly, and tools like Claude Code or Cursor handle the bigger, multi-file changes. They are fast, and in my experience the code they write usually compiles.
But “it compiles” and “it fits our architecture” are two very different things. This article shows how to turn the architecture rules that live in your head into rules that an agent cannot silently break. The sample package is built with Swift 6.4 and Xcode 27 for iOS 27 and macOS 27, and its tests pass with swift test. The package settings below need tools version 6.2 or later, and the view uses iOS 27 SDK features.
Motivation
Let’s say we have an app with a clean, modular structure and we ask an agent to add a simple feature: users can mark items as favourites, with a maximum of twenty.
The agent will do what any new developer in a hurry does. It takes the shortest path that makes the feature work. Most of the time it looks something like this.
@MainActor @Observable
final class FavouritesViewModel {
private(set) var ids: [UUID] = []
func add(_ id: UUID) async {
if ids.count >= 20 { return }
let url = URL(string: "https://api.example.com/favourites/\(id)")!
_ = try? await URLSession.shared.data(for: URLRequest(url: url))
ids.append(id)
}
}
Nothing is wrong with this code if you only look at the feature. It works, it is short and the preview even renders. But look closer and you will see the usual suspects:
- Business rule in the view model – the limit of twenty is a magic number inside a presentation class
- Networking in the presentation layer –
URLSessionis called directly, so we can’t test it without the network - Swallowed errors –
try?hides the failure and we append the id anyway - No second place to reuse the rule – the widget that shows favourites will need to copy it
Every one of these is a decision that our architecture already made. The agent just didn’t know about it.
Why instructions alone are not enough
The first reaction is to write everything down in a prompt. And I highly suggest that you do write an AGENTS.md. I will show you the one from the sample later. But instructions have two problems.
The first one is that they are advice. An agent reads “never call URLSession from a view model” at the start of a session, and a few hundred lines of code later it is just one more sentence in a long context.
The second one is that you are the only checker. If the rule is not enforced by anything, then you find the violation in code review, after the agent already built three more things on top of it.
What we want is a rule that fails loudly and early, ideally before the agent even reports that it is done. Agents are good at fixing a failing build. They are much worse at remembering a sentence. So let’s move our rules from sentences into the build.
Solution
We will use four guardrails:
- Package boundaries – the build refuses some of the code that reaches the wrong layer
- Compiler settings per layer – warnings become errors, imports become explicit and isolation becomes a layer rule
- A rich domain model – the rule lives in the type, so there is only one place to put it
- An architecture test – a small test that catches what the compiler can’t
Package boundaries
Instead of one big app target, we split the feature into four targets of a local Swift package:
- Domain – business rules and the interfaces they need
- Data – networking and persistence behind those interfaces
- Presentation – view models that turn domain state into something to show
- UI – the SwiftUI views and nothing else
The important part is the direction of the dependencies.
// swift-tools-version: 6.4
import PackageDescription
let package = Package(
name: "Favourites",
platforms: [.iOS(.v27), .macOS(.v27)],
products: [
.library(name: "FavouritesDomain", targets: ["FavouritesDomain"]),
.library(name: "FavouritesData", targets: ["FavouritesData"]),
.library(name: "FavouritesPresentation", targets: ["FavouritesPresentation"]),
.library(name: "FavouritesUI", targets: ["FavouritesUI"]),
],
targets: [
.target(name: "FavouritesDomain"),
.target(name: "FavouritesData", dependencies: ["FavouritesDomain"]),
.target(name: "FavouritesPresentation", dependencies: ["FavouritesDomain"]),
.target(name: "FavouritesUI", dependencies: ["FavouritesPresentation"]),
.testTarget(name: "FavouritesDomainTests", dependencies: ["FavouritesDomain"]),
.testTarget(name: "ArchitectureTests"),
]
)
FavouritesDomain depends on nothing. FavouritesData and FavouritesPresentation depend on the domain, but not on each other. FavouritesUI depends only on Presentation, so a view shouldn’t talk to a business rule or a network call directly. The .macOS platform is there for a practical reason: agents like to run swift test from the terminal, and that builds for the Mac. As far as I can tell, without it the Presentation and UI targets don’t compile on the Mac and the agent’s very first check fails for the wrong reason.
Now, if the agent tries to use FavouritesData inside a view model, a clean build fails with unable to resolve module dependency: 'FavouritesData'. That is a message the agent understands and fixes by itself, usually by going through the domain protocol, which is exactly what we want.
But there is a catch that I only found by trying it. If FavouritesData was already built, which is the normal state of any working copy, the same import compiles. Both swift build and Xcode only print a warning:
warning: 'FavouritesPresentation' is missing a dependency on 'FavouritesData' because
dependency scan of Swift module 'FavouritesPresentation' discovered a dependency on 'FavouritesData'
And an agent that sees Build complete! moves on. With Swift Package Manager we can turn the warning into an error with swift build --explicit-target-dependency-import-check error. The option is defined in SwiftPM’s source and takes none, warn or error, with none as the default. Note that the build still prints Build complete! after the error: line and only the exit code is non-zero, so an agent has to look at that and not just the text.
Xcode has no such option, but SwiftPM’s flag only sets a build setting, and in my testing xcodebuild accepts the same one:
xcodebuild build -scheme Favourites-Package -destination 'platform=macOS' \
DIAGNOSE_MISSING_TARGET_DEPENDENCIES=YES_ERROR
It fails with the same message as an error:. That is also why treating warnings as errors, with the package setting we will add in a moment, doesn’t catch it: the check belongs to the build system, not to the compiler.
The second catch is worse. FavouritesUI depends on Presentation, and Presentation depends on Domain, so the domain is a transitive dependency of the UI. An import FavouritesDomain in a view builds without even a warning: on a clean build, on an incremental one, and with the flag above. In my testing, as far as the build is concerned, a transitive dependency is not a boundary at all.
So the package boundaries are a strong signal, but not a guarantee. We will close both gaps with the architecture test.
What I like about this is that the rule is not written anywhere in prose. It is just the shape of the package.
Compiler settings per layer
Tools version 6.2 added defaultIsolation to the settings a package can set per target, next to treatAllWarnings. Swift also ships upcoming features, which are changes that would otherwise only come with a new language mode. A package can turn them on one by one today with enableUpcomingFeature. It takes the feature’s name as a string, and a target ignores a name it doesn’t know. In my testing a typo builds without a single warning, even with warnings as errors, so copy the names exactly. Together they let the compiler hold a few more of our rules.
let strict: [SwiftSetting] = [
.enableUpcomingFeature("InternalImportsByDefault"),
.enableUpcomingFeature("MemberImportVisibility"),
.enableUpcomingFeature("ExistentialAny"),
.treatAllWarnings(as: .error),
]
let settings = strict + [.defaultIsolation(MainActor.self)]
let package = Package(
/* name, platforms and products as before */
targets: [
.target(name: "FavouritesDomain", swiftSettings: strict),
.target(name: "FavouritesData", dependencies: ["FavouritesDomain"], swiftSettings: strict),
.target(
name: "FavouritesPresentation",
dependencies: ["FavouritesDomain"],
swiftSettings: settings
),
.target(
name: "FavouritesUI",
dependencies: ["FavouritesPresentation"],
swiftSettings: settings
),
.testTarget(name: "FavouritesDomainTests", dependencies: ["FavouritesDomain"]),
.testTarget(name: "ArchitectureTests"),
]
)
Let’s go through them one by one:
treatAllWarnings(as: .error)– for an agent,Build complete!with twelve warnings reads as success. Now an unused variable or a deprecated API stops the build, and the agent has to deal with itInternalImportsByDefault– everyimportis internal unless we writepublic import. A module that exposes another module’s types in its public API now has to say so in the import lineMemberImportVisibility– a file can only use members of modules it imports itself, not ones that happen to be imported somewhere else in the target. In the sample it stopped the view model from callinguuidStringwithproperty 'uuidString' is not available due to missing import of defining module 'Foundation'. The imports at the top of a file become an honest list of what it uses, which is exactly what our architecture test will readExistentialAny– a protocol used as a type has to be writtenany FavouritesStore, so every dependency hidden behind a protocol is visible as onedefaultIsolation(MainActor.self)– only for Presentation and UI. Everything in them runs on the main actor unless it says otherwise, so the agent doesn’t have to remember@MainActoron a new view model. Domain and Data stay nonisolated, so main actor code can’t creep into them by default
Let me show you why InternalImportsByDefault matters. Say the agent is asked to give favourites a colour, and it adds this to the domain.
import SwiftUI
extension Favourites {
public var tint: Color { ids.isEmpty ? .gray : .yellow }
}
The build fails, because Color comes from an import that is internal. The obvious next move is public import SwiftUI, and then the build passes. But now the leak is one loud line at the top of the file instead of something hidden, and that line is exactly what the architecture test below rejects.
The rule lives in the type
Package boundaries tell the agent where code goes. They don’t tell it what the rules are. For that we use what we already know from Domain Driven Design: a rich model that owns its business rules.
public import Foundation
public typealias ItemID = UUID
public struct Favourites: Equatable, Sendable {
public static let limit = 20
public enum Error: Swift.Error, Equatable {
case alreadyAdded
case limitReached
}
public private(set) var ids: [ItemID] = []
public init() {}
public mutating func add(_ id: ItemID) throws {
guard !ids.contains(id) else { throw Error.alreadyAdded }
guard ids.count < Self.limit else { throw Error.limitReached }
ids.append(id)
}
}
Look at private(set). The only way to put an id into the list is add, and add is the only place where the limit exists. An agent that wants to add a favourite has no other door. It can’t forget the rule because it can’t reach the array.
Notice also public import Foundation. ItemID is a UUID, so our public API exposes Foundation, and with InternalImportsByDefault the compiler makes us say it.
This is the same idea as in value objects and policies: make the invalid state impossible instead of documenting it.
The rule also gets a test of its own. For an agent this is the most precise specification we can give. If it changes the limit or the duplicate check by accident, the test tells it which rule it broke. That is why the test says 20 instead of reading Favourites.limit.
import FavouritesDomain
import Foundation
import Testing
struct FavouritesTests {
@Test func rejectsTheSameItemTwice() throws {
var favourites = Favourites()
let id = ItemID()
try favourites.add(id)
#expect(throws: Favourites.Error.alreadyAdded) { try favourites.add(id) }
}
@Test func rejectsItemsPastTheLimit() throws {
var favourites = Favourites()
for _ in 0..<20 {
try favourites.add(ItemID())
}
#expect(throws: Favourites.Error.limitReached) { try favourites.add(ItemID()) }
}
}
Persistence is an interface owned by the domain, and the use case that ties the rule and the store together lives there as well.
public protocol FavouritesStore: Sendable {
func save(_ favourites: Favourites) async throws
}
public struct AddFavourite: Sendable {
private let store: any FavouritesStore
public init(store: any FavouritesStore) {
self.store = store
}
public func callAsFunction(_ id: ItemID, to favourites: Favourites) async throws -> Favourites {
var updated = favourites
try updated.add(id)
try await store.save(updated)
return updated
}
}
The URLSession call from the beginning moves to FavouritesData, behind the protocol. Both imports are public, because its public API exposes a domain protocol and a URLSession.
public import FavouritesDomain
public import Foundation
public struct RemoteFavouritesStore: FavouritesStore {
private let url: URL
private let session: URLSession
public init(url: URL, session: URLSession = .shared) {
self.url = url
self.session = session
}
public func save(_ favourites: Favourites) async throws {
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.httpBody = try JSONEncoder().encode(favourites.ids)
_ = try await session.data(for: request)
}
}
And the view model from the beginning moves to FavouritesPresentation, where it becomes a thin layer that only asks the domain and prepares what to show.
public import FavouritesDomain
public import Observation
import Foundation
@Observable
public final class FavouritesViewModel {
public var titles: [String] {
favourites.ids.map(\.uuidString)
}
public var errorMessage: String?
private var favourites = Favourites()
private let addFavourite: AddFavourite
public init(addFavourite: AddFavourite) {
self.addFavourite = addFavourite
}
public func addRandomItem() async {
do {
favourites = try await addFavourite(UUID(), to: favourites)
} catch {
errorMessage = "Couldn’t add this item to your favourites."
}
}
}
Notice that there is no URLSession, no 20 and no try?. There is no @MainActor either, because the Presentation target makes it the default. And there is no import SwiftUI. The view model knows what to show, not how it looks, so it can be tested without a view and could drive a UIKit screen just as well.
The view model exposes titles, ready to display, and keeps Favourites private. That is what lets the UI depend on Presentation alone. errorMessage is settable, because the view clears it when the alert is dismissed. addRandomItem() makes the id itself, so no Foundation type is part of the public API and import Foundation stays internal. The other two imports are public: the initialiser takes a domain type, and @Observable adds a public conformance to Observable.
A preview needs a view model that saves nowhere. Building one needs a domain store, so the factory lives in Presentation too, and the UI never has to see a domain type.
import FavouritesDomain
extension FavouritesViewModel {
/* Saves nowhere, so a preview works without a network */
public static func preview() -> FavouritesViewModel {
FavouritesViewModel(addFavourite: AddFavourite(store: PreviewStore()))
}
}
private struct PreviewStore: FavouritesStore {
func save(_ favourites: Favourites) async throws {}
}
Finally, FavouritesUI holds the SwiftUI view and nothing else. It uses alert(_:item:), new in iOS 27, which presents while the optional has a value and sets it back to nil on dismissal.
public import FavouritesPresentation
public import SwiftUI
public struct FavouritesView: View {
@State private var model: FavouritesViewModel
public init(model: FavouritesViewModel) {
self.model = model
}
public var body: some View {
NavigationStack {
List(model.titles, id: \.self) { title in
Text(title)
}
.navigationTitle("Favourites")
.toolbar {
Button("Add", systemImage: "plus") {
Task { await model.addRandomItem() }
}
}
.alert("Favourites", item: $model.errorMessage) { _ in
Button("OK", role: .cancel) {}
} message: { message in
Text(message)
}
}
}
}
#Preview {
FavouritesView(model: .preview())
}
model has no initial value and is assigned once in init. With @State now being a macro in the iOS 27 SDK, that is the form that initialises it correctly. The view imports Presentation and SwiftUI. No domain, no data.
Something still has to create RemoteFavouritesStore, AddFavourite and the view model. That is the app target’s job, and it is the one place that may import all four modules. The architecture test below doesn’t cover it.
When the next feature request comes, the agent finds one obvious example of how this module is built, and in my experience agents copy the closest example very faithfully. That is a good reason to keep at least one clean reference feature in your project.
The architecture test
There are three holes left. The first two we already saw. An import of another layer only warns once that layer is built, and an import of a transitive dependency doesn’t even warn. The third one is that nothing stops FavouritesDomain or FavouritesPresentation from writing public import SwiftUI, because system frameworks are always available to a target. A domain that imports SwiftUI slowly stops being a domain.
We can close all three holes with a small test that reads the source files and checks their imports. It doesn’t care whether the build is clean or incremental, because it never looks at the build. Here I use Swift Testing.
import Foundation
import Testing
struct ArchitectureTests {
/* The only modules each layer may import */
static let allowedImports: [String: Set<String>] = [
"FavouritesDomain": ["Foundation"],
"FavouritesData": ["Foundation", "FavouritesDomain"],
"FavouritesPresentation": ["Foundation", "Observation", "FavouritesDomain"],
"FavouritesUI": ["Foundation", "SwiftUI", "FavouritesPresentation"],
]
@Test(arguments: allowedImports.keys.sorted())
func layerRespectsItsBoundaries(_ layer: String) throws {
let files = try swiftFiles(in: "Sources/\(layer)")
try #require(!files.isEmpty, "Found no Swift files in Sources/\(layer)")
for file in files {
let violations = try importedModules(in: file).subtracting(Self.allowedImports[layer, default: []])
#expect(violations.isEmpty, "\(layer)/\(file.lastPathComponent) imports \(violations.sorted())")
}
}
private func swiftFiles(in directory: String) throws -> [URL] {
let root = URL(filePath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.appending(path: directory)
let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)
return (files?.allObjects as? [URL] ?? []).filter { $0.pathExtension == "swift" }
}
private func importedModules(in file: URL) throws -> Set<String> {
let source = try String(contentsOf: file, encoding: .utf8)
/* Matches `import X`, `@preconcurrency import X`, `public import X` and `import struct X.Y` */
let anyImport = #/^[ \t]*(?:@\w+[ \t]+)*(?:\w+[ \t]+)?import[ \t]+(?:\w+[ \t]+)?(\w+)/#
return Set(source.matches(of: anyImport.anchorsMatchLineEndings()).map { String($0.1) })
}
}
The rules are a small table: for every layer, the only modules it may import. Anything else fails, including modules nobody thought to forbid, like SwiftData in the domain. Look at the last line. FavouritesUI may import Presentation but not the domain, and this test is the only thing in the whole setup that enforces it. The test runs once per layer, finds the package root from #filePath, collects every Swift file in the layer’s folder and fails with the name of the file that crossed the line.
Two details are there because my first version missed them:
- The regular expression – my first version only looked for lines starting with
import. It happily let through@preconcurrency import SwiftUI, which is what I see agents add when they fight the concurrency errors of the Swift 6 language mode, and alsopublic import SwiftUIandimport struct SwiftUI.Color - The
#require– if a layer is renamed and the table is not, the folder is empty and the loop never runs. Without this line the test would pass while checking nothing
The failure message does half of the work. When the agent runs the tests, it reads FavouritesDomain/Tint.swift imports ["SwiftUI"] and knows exactly what to fix.
Make it part of the agent’s loop
A rule that nobody runs is just another sentence. So the last step is to make sure the agent runs the checks before it says that it is done.
Xcode exposes its own tools to agents over MCP, through xcrun mcpbridge, since Xcode 26.3. The ones that matter here are BuildProject, RunAllTests, GetBuildLog and RenderPreview. As far as I can tell, RunAllTests runs the tests of the active scheme, so check that ours are in it. For this package I use the Favourites-Package scheme.
This is where AGENTS.md earns its place, not as a long list of preferences but as a short description of the architecture and the commands that prove it still holds.
## Architecture
Each feature is a Swift package with four targets: `<Feature>Domain`,
`<Feature>Data`, `<Feature>Presentation` and `<Feature>UI`. Data and
Presentation depend on Domain, never on each other. UI depends on Presentation only.
- Business rules live in domain types. Don't add them to view models.
- The domain imports Foundation only. No UIKit, SwiftUI, URLSession.
- Persistence and networking are protocols in Domain, implemented in Data.
- View models live in Presentation and don't import SwiftUI or UIKit.
- SwiftUI views live in UI and talk only to view models.
- Presentation and UI are main actor by default. Don't add `@MainActor` to Domain or Data.
- `Favourites` is the reference feature – copy its structure.
## Before you finish
- In Xcode: make `Favourites-Package` the active scheme, then run `BuildProject`
and `RunAllTests`. Check UI changes with `RenderPreview`.
- In the terminal: `swift test --explicit-target-dependency-import-check error`.
- With `xcodebuild`, add `DIAGNOSE_MISSING_TARGET_DEPENDENCIES=YES_ERROR`.
- Don't weaken `ArchitectureTests` or the `swiftSettings` in `Package.swift` –
if one fails, change the code, not the rule.
Short and concrete. Several lines are backed by something that can fail, and the rest are advice. You will find this exact file in the root of the sample project.
Gotchas
Some things I would watch out for:
- In my experience agents fix failing tests the easy way – sometimes by changing the test, or by removing a
private(set)that is in their way. That is why theAGENTS.mdabove says it out loud, and why I look at any diff that touchesArchitectureTestsfirst, or that loosens access control in the domain - Keep the settings in
Package.swift– As far as I can tell, Xcode’s agent tools can change build settings directly. In a package, every setting is a line in the manifest, and changing it is a diff you will see - Types and escape hatches, not modules – the test checks imports, and
URLSessionis part of Foundation, so a domain that calls it directly passes. The same goes for@unchecked Sendableandnonisolated(unsafe), which an agent reaches for when it can’t make a typeSendable. The compiler accepts both without a warning, even with warnings as errors. Some uses are legitimate, like a class that guards its state with a lock, so treat each one as a review flag or a lint rule rather than an architecture rule - Warnings as errors cut both ways – a new Xcode can break the build with a fresh deprecation. I think it is worth it with agents, but plan for it when you update
- Make CI run a clean build – the dependency check runs only when a file is recompiled, so the next incremental build ends with a clean
Build complete!even though the wrong import is still there
Also note that all of the code above is only an example and has lots of room to improve. For instance, a real app would map each domain error to its own localised message in the presentation layer. RemoteFavouritesStore doesn’t check the HTTP status, and Favourites has no way to load saved ids, so the list starts empty on every launch.
Conclusion
We started with a feature that an agent wrote the fast way and ended with an architecture that pushes back. Package boundaries, strict compiler settings, a rich domain type and an architecture test each close a gap the others leave open.
The nice part is that none of this is specific to AI. These are the same practices that help a new teammate in their first week. We just now have a very fast teammate who never gets tired of following the rules, as long as the rules are in the build and not in our heads.
Thank you for reading! If you enjoyed it, you might also like my posts about rich domain models and policies.