The architecture behind Ironbor
A codebase usually has its architecture settled long before you arrive in it, and the pattern debate rarely settles the underlying problem: the weight moves from the view controllers to the view models. For my own app I started from principles rather than a pattern — SOLID, composition, small modules — and ended up with a small unidirectional core and almost no dependencies.
An iOS codebase usually has its architecture settled long before you arrive in it — by the team, by the era it was started in, by what the app was when it began. MVC came first. MVVM came later, often adopted to escape the problems of the first. Both are reasonable choices, and both run into the same thing once a screen has lived a while.
MVC on Apple’s platforms has a nickname old enough that people forget it was ever a joke: Massive View Controller. Networking, formatting, navigation, data-source glue, and screen state all drift into the one object the system hands you, and it grows until nobody wants to open the file. The usual cure is MVVM. Move that work into a view model, keep the controller thin. It helps, for a while. Then the view model starts collecting the same responsibilities the controller used to hold, and you have a massive view model instead.
This is not a fringe complaint. “Massive View Controller” has been community shorthand since around 2013, and the complaint that MVVM relocates bloat rather than removing it is just as well documented. Neither pattern is wrong, exactly. But neither one, on its own, prevents a single object from collecting everything. The weight doesn’t leave when you switch. It changes address.
A codebase whose architecture was mine to choose
Ironbor is a gym workout tracker, and unlike the codebases I work in for a living, its architecture was entirely mine to choose. In a shared codebase the conventions already in place are usually worth more than my preferences, and working with them is the right call. Here there was nothing to work with. That kind of freedom is easy to waste, so I tried to spend it on a few things I actually believe rather than on novelty.
I like the SOLID principles, and I prefer composition to inheritance. I think modules should be small, because large files are hard to read and harder to change without a knot in your stomach. And I wanted as few external dependencies as I could manage, because every dependency is a decision someone else gets to make on your behalf later.
I did not choose an architecture from a list. I started from those preferences and let the structure follow from them.
How it works
A screen in Ironbor starts from a piece of state that is a plain value type. Here is the whole of the exercise library’s state:
struct ExerciseLibraryState: ViewState {
var exercises: [Exercise] = []
var searchScope = ExerciseSearchScope()
}
ViewState is just Equatable. That is deliberate: the state is a value, it can be compared, and there is exactly one of it per screen.
The user does something, and the view sends a signal. A signal is the only way to ask the app to do anything. A small generic object holds the current state and a list of use cases, and hands each incoming signal to them in turn:
protocol UseCase<State> {
associatedtype State: ViewState
func handle(_ signal: any ViewSignal, currentState: State) async throws -> UseCaseResult<State>
}
A use case is a reducer by another name. It looks at the signal, does whatever work the signal asks for, and returns a new state, or some follow-up effects, or both. The exercise search is one file:
struct FetchExercisesUseCase: UseCase {
@Dependency(\.exerciseReader) private var exerciseReader
func handle(_ signal: any ViewSignal, currentState _: ExerciseLibraryState)
async throws -> UseCaseResult<ExerciseLibraryState> {
guard let searchScope = signal as? ExerciseSearchScope else { return .notHandled }
let query = ExerciseQuery(
searchText: searchScope.query,
muscleGroups: Set(searchScope.muscleGroups),
equipmentTypes: Set(searchScope.equipment),
customOnly: false
)
let exercises = try exerciseReader.fetch(query)
return .state(ExerciseLibraryState(exercises: exercises, searchScope: searchScope))
}
}
The object that runs this loop is StoreViewModel, and it is the only class in the whole pattern — everything else is a protocol or a struct. The real file is about a hundred lines including logging, and nothing about it is clever — that is the point. Here is its heart:
@MainActor
@Observable
final class StoreViewModel<State: ViewState> {
@ObservationIgnored
@Dependency(\.logger) private var logger
var state: State
private let useCases: [any UseCase<State>]
func send(_ signal: any ViewSignal) {
Task { await self.processSignalChain(signal) }
}
private func processSignalChain(_ signal: any ViewSignal) async {
var pending: [any ViewSignal] = [signal]
while let current = pending.first {
pending.removeFirst()
let outcome = await self.dispatchOnce(current)
pending.append(contentsOf: outcome.effects)
}
}
private func dispatchOnce(_ signal: any ViewSignal) async -> UseCaseResult<State> {
for useCase in self.useCases {
do {
let result = try await useCase.handle(signal, currentState: self.state)
guard result.handled else { continue }
if let newState = result.state {
self.state = newState
}
return result
} catch {
self.logger.error("UseCase failed", error: error)
return .notHandled
}
}
return .notHandled
}
}
A signal goes into a queue. Each one is offered to the use cases in order, and the first that handles it wins. If it returns a new state, the store applies it; if it returns effects — more signals — they join the queue and flow through the same list until it is empty. The full file also refuses to dispatch the same signal type twice in one chain, so an accidental cycle stops itself and leaves a line in the log instead of spinning. The view observes state and redraws, and the loop is closed.
On the view side, the wiring is one property. Here is the exercise library screen, complete:
struct ExerciseLibraryView: View {
@State private var viewModel = StoreViewModel<ExerciseLibraryState>(
initialState: .empty,
initialSignal: ExerciseSearchScope(),
useCase: .fetchExercises
)
var body: some View {
NavigationStack {
ExerciseListContent(viewModel: self.viewModel)
.navigationTitle(.exercises)
}
}
}
The view owns its store with @State and hands it three things: an initial state, an initial signal, and the use cases that define what this screen can do. The initial signal makes the first load an ordinary dispatch rather than a special case. Because the store is @Observable, whatever reads state redraws when it changes, and user actions come back in through send. That is the entire relationship between a view and its logic. The view never talks to a repository and never runs a query. Purely presentational state — which sheet is up, a pending confirmation — binds straight to state; anything that touches data goes through send.
A feature is a state, a handful of signals, and a handful of use cases. The files stay small — across the feature layer the average is around fifty lines. When a use case needs something from the outside world, it asks for it by protocol. FetchExercisesUseCase depends on exerciseReader, never on a concrete database type. Those dependencies are wired once, at app startup, where the real SwiftData repositories are handed to a dependency container. Everywhere else, the code works against the interface.
Those repository protocols are split by responsibility rather than bundled into one. A template repository, for instance, is a reading protocol with a single fetch() and a separate writing protocol with insert and delete:
typealias WorkoutTemplateRepository = WorkoutTemplateReading & WorkoutTemplateWriting
@MainActor protocol WorkoutTemplateReading {
func fetch() throws -> [WorkoutTemplate]
}
@MainActor protocol WorkoutTemplateWriting: Saveable {
func insert(_ template: WorkoutTemplate)
func delete(_ template: WorkoutTemplate)
}
Most use cases only read. Splitting the protocol means a use case that lists templates depends on WorkoutTemplateReading and nothing else — it has no way to write, which is exactly what you want a boundary to enforce. It is the interface-segregation principle doing something concrete rather than sitting in a slide.
Small pieces that compose
Composition is the part I care about most, and it falls out of one decision: a use case that bundles other use cases is itself a use case. CompoundUseCase takes a list of them, runs each in turn on the same signal, threads the state through, and gathers their effects. Because it conforms to the same protocol, the store cannot tell a bundle from a single handler, so it drops in anywhere one does.
That earns its keep in two ways. Sometimes I use it to fan a single signal out to several independent loaders. The workout home screen loads on one LoadWorkouts signal, and behind it sits a bundle of four small use cases, each contributing its slice of the screen:
CompoundUseCase([.restoreActiveWorkout, .loadWorkoutTemplates, .weeklyProgress, .weeklyVolume])
Other times I use it to give a bundle a name. “Load the exercise detail” is not one operation — it is a personal-record lookup, set-performance setup, an input-mode decision, and a progress query — so LoadExerciseDetailUseCase is little more than those four composed together and published under one name, .loadExerciseDetail.
For a handler small enough that a whole struct would be noise, ClosureUseCase takes a typed closure and does the signal matching for you. In the exercise picker, confirming the selection just calls back to the parent view, so it lives inline in the use-case list instead of in a file of its own:
ClosureUseCase(on: ConfirmSelection.self) { _, state in
onAdd(state.draft)
return .notHandled
}
These are three tools, not three tiers. A struct gives a real piece of logic its own name and place. A compound composes several use cases into one, for behaviour that is genuinely several behaviours. A closure keeps a small handler inline, usually to bridge a signal out to something beyond the store, the way the picker hands its result back to the parent view.
The last touch is a little Swift sugar that makes the call sites read the way I want. A signal and its use case each get a static factory:
struct StartEmptyWorkout: ViewSignal {}
extension ViewSignal where Self == StartEmptyWorkout {
static var startEmptyWorkout: ViewSignal { StartEmptyWorkout() }
}
extension UseCase where Self == StartEmptyWorkoutUseCase {
static var startEmptyWorkout: Self { .init() }
}
so the same tidy spelling names the use case when the store is built:
@State private var viewModel = StoreViewModel(
initialState: .empty,
initialSignal: .loadWorkouts,
useCases: [.startEmptyWorkout, .startFromTemplate]
)
and the signal when the view asks for it:
Button(.startEmptyWorkout, systemImage: "figure.strengthtraining.traditional") {
viewModel.send(.startEmptyWorkout)
}
In the first snippet .startEmptyWorkout names the use case; in the second, the signal. That is why the store lists earlier read as .restoreActiveWorkout, .weeklyProgress, and the rest. The mutation inside the use case reads just as plainly: .updating(currentState) { $0.activeWorkout = workout }. None of this is essential. It is the difference between a pattern you tolerate and one you still enjoy reading months later.
Pros
The reason I care about any of this is what happens once the app grows and I want to change something without dreading it. So rather than list what the pattern promises, here is what I can point at in the repository.
Adding a feature barely disturbs existing code. When I added the Apple Health sync toggle, the work inside the Settings feature was a new use case with a hundred lines of its own tests, a new toggle row, and small changes to the state, the loader, and the existing HealthKit authorization use case. Outside the feature, two Swift files changed: the UserSettings model gained the flag, and FinishWorkoutUseCase gained eleven lines, because finishing a workout is the moment the data is written to Health. Beyond that, only the strings catalog. That is what a small blast radius means in practice.
Cross-cutting behaviour gets solved once. When the user switches between kilograms and pounds, every visible screen has to re-render its numbers. That is one generic use case, ApplyUnitsChangeUseCase. It works on any state that declares it carries units — a two-line protocol — and four screens opt in by adding one entry to their use-case list. No base class, no inheritance, no copy of the handler per screen. This is what I wanted composition for.
Replacing whole parts is not a hypothetical benefit; the app does it on every demo launch. Behind a launch argument, the composition root swaps the real StoreKit service for one that always answers “Pro subscriber”, swaps the first-launch seeder for one that fills the database with sample data, and keeps that database in memory. With that, the entire app — paywall included — runs against a different world. A network backend would go in through the same seams: new implementations of the repository protocols, with the use cases untouched.
The seams face the other way as well. Views only read state and send signals, so a screen’s SwiftUI can be rebuilt from scratch while its store, use cases, and tests do not move.
The dependency list stayed where I wanted it, too: SwiftData for persistence, swift-dependencies for injection, SDWebImage for exercise animations. That is the whole list of substantial third-party code.
If you know the unidirectional-data-flow family — Flux, Redux, Elm, what the Android community calls MVI, or The Composable Architecture on Apple platforms — you will recognise the shape of all this. I did not set out to implement any of them; the shape followed from the principles.
One piece of that world did earn its place: swift-dependencies, Point-Free’s injection library, published standalone so it can be used without the rest of the framework. Dependency injection is plumbing, not architecture, and it is hard to do well. So I took the plumbing and kept the architecture mine.
Cons
I would not trust this article if it only listed advantages, so here is the other side — found by re-reading my own code critically, not by imagining failure modes.
There is a fixed cost per screen and per dependency. A trivial screen still pays for a signal type, a use case, and a registration — real scaffolding for a button that flips one boolean. Every injected service pays a toll before it does anything useful: a key, a live default, an unimplemented test shim. The DI folder is sixteen files and roughly seven hundred and fifty lines that exist only to wire things together. And the store itself is mine to maintain when it misbehaves; there is no framework maintainer to escalate to and no Stack Overflow thread about my particular hundred lines. I consider all of that a fair price. It is still a price.
The value-semantics story is only partly true — for now. ViewState is a struct and Equatable, but inside those structs sit SwiftData models — the [Exercise] in the library state, the WorkoutExercise in the active-workout state — and those are classes. Equality over a class is identity, not content, so two “equal” states can disagree about what is actually on screen, and a change made to a model travels by reference rather than through the signal loop. The fix is planned, not hypothetical: plain DTOs in the states, with mapping at the repository boundary so the models stop where the protocols already are. I deferred it deliberately. Doing it before launch would have been exactly the refactoring spiral YAGNI warns against: churning working screens for purity while the app shipped nothing. It waits for one of the next updates.
Signals are matched at runtime, not by the compiler. The decoupling comes from any ViewSignal and a cast; the repository has nearly fifty guard ... else { return .notHandled } lines across roughly seventy use cases. A signal that nothing handles disappears silently instead of failing the build. Order matters too: the first handler wins, so each view’s use-case list is quietly also a priority list. Tests catch what the compiler cannot; an enum-based design would have caught it for free.
The concurrency story is deliberately simple, and the simplicity has edges. The target defaults to the main actor, so the store, the use cases, and the repositories all run there. That is the honest place to be while the data sits in SwiftData’s main context, and it is cheap at a workout tracker’s volumes. Heavy background work, though, would mean extending the pattern, not flipping a switch. send is also fire-and-forget: it starts an unstructured task, and nothing cancels a chain in flight when its screen goes away. Harmless while every load is local and fast; a networked future has to add cancellation first.
And the discipline is still mine to keep. Nothing physically prevents a use case from swelling into its own small massive view model. The structure makes the small choice the easy one; it does not make the large choice impossible. The weight can still find a new address if I let it.
Testing, and finding things when they break
The payoff I did not fully anticipate was testing. Because a use case is close to a pure function, a test is just input and output. You build a plain state value, call handle with a signal, and check the state that comes back. No view to host, no store to spin up, no mocks unless the use case actually reaches for a dependency.
@Test("Appends a new empty set with the next sortOrder and reprojects sorted sets")
@MainActor
func appendsNewSet() async throws {
let workoutExercise = WorkoutExercise(exercise: ExerciseFactory.benchPress(), sortOrder: 0)
workoutExercise.sets = [ExerciseSet(sortOrder: 0), ExerciseSet(sortOrder: 1)]
var state = ActiveWorkoutExerciseState.empty
state.workoutExercise = workoutExercise
let result = try await AddSetUseCase().handle(AddSet(), currentState: state)
let updated = try #require(result.state)
#expect(updated.sets.count == 3)
#expect(updated.sets.map(\.sortOrder) == [0, 1, 2])
}
The same file also checks that an unrelated signal comes back as notHandled, and that adding a set with nothing loaded does nothing. When a use case does reach for a dependency, the test provides exactly that one — everything else defaults to a shim that fails the test loudly if it is touched, so nothing sneaks in through a shared context. The app has a hundred and twenty-five of these test files, all on Swift Testing, and they run without ever launching a screen.
Debugging is a smaller, quieter benefit, and I will not oversell it. One-way flow does not fix bugs. What it does is narrow where you look. A wrong value on screen is almost always in one of three places: the state, the use case that produced it, or the view that drew it. You can usually tell which by inspecting the state, because there is only one of it and it is comparable. If a signal chain misbehaves, the store’s cycle guard leaves a note in the log. It is not magic. It is just that a system with one direction has fewer places for a mistake to hide.
Working with an AI agent
I designed and proved the architecture by hand first. Only once it had held up as the app grew did I automate anything on top of it. Today the flow is: tasks live in Notion; I hand one to Claude Code; it writes a plan; I approve or correct the plan; it implements and opens a pull request; I review, test, and merge.
That flow works because of the shape of the code. Most tasks reduce to “add a signal, add a use case, write its test” — a narrow unit of work with a hard boundary and an obvious way to check it, so an agent can take it on without needing to understand the whole app. The properties that make the codebase pleasant to work in by hand — small pieces, clear seams, fast tests — turned out to be the same properties that make it safe to delegate. The clean structure is the precondition, not the product.
I did not invent anything here. I followed a few principles I trusted, kept every piece small, and said no to most dependencies. What I got is an app I built in five months and can still change without dread — and that was the whole goal.