When I started the new app, architecture was the one decision I did not expect to revisit. We had already spent years learning where state should live, where work should happen, and how views could stay out of both. The previous app had carried those decisions long enough for them to settle into the structure I later wrote about in the SCALES series. There were things I wanted to improve, but I expected that work to stay in the implementation. That was still my assumption when I began the first serious Swift 6 pass and discovered how much of the old target, still compiling in Swift 5 mode with minimal concurrency checking, had never been asked to explain its isolation. State moved through unstructured tasks, observable objects changed away from the main thread, and closures captured mutable owners because they always had. The fact that the application worked was not the same as the compiler having verified the model, and for the first time those assumptions were part of compilation.
Some of what surfaced looked like migration work at first. Messages needed to be Sendable because they crossed task boundaries, and handler closures needed the same treatment. I expected changes like that. I did not expect the annotations to lead so quickly into the shape of the system itself. Once a handler captured a mutable object, that object needed an isolation domain; once state was read and written from several tasks, someone had to own it. The previous architecture had never been forced to say who that was, and the audit made it difficult to dismiss the question as strict-concurrency fussiness. Handler registration happened asynchronously, leaving a window where startup messages could disappear before anyone was listening. One handler was registered twice and had been processing every matching message twice. The context cache used a barrier queue but could still miss a value immediately after writing it and append the same context again. Swift 6 had not introduced any of this. It had interrupted us while we were preparing to carry it forward.
The most direct answer was also the most familiar one: put the architecture on MainActor. That would give the mutable objects an isolation domain and line them up with the SwiftUI state they eventually affected. I understood why it was the default proposal, and for a while I tried to make myself comfortable with it. What kept bothering me was that AppContext does not present anything. Its reducers hash payloads, serialize records, deduplicate results, sequence service calls, and decide what the application state should become after those calls finish. Looking back, the previous architecture had never really depended on the main thread; it depended on having one serialized owner for architectural state. We had maintained that mostly through shape and convention, then weakened it every time an unstructured task reached across the boundary. Moving everything to MainActor would make the code compile without preserving that distinction, so the new spine ended up with its own global actor. AppContext, state, reducers, and routing policy live there, while ViewContexts and presentation remain on MainActor. I initially saw this as a new architecture decision because the old app had no equivalent annotation. After working through it, I am not sure it was new at all. It gave the compiler a way to enforce a rule the old architecture had been relying on without being able to state explicitly.
Observation took longer because I had confused an old workaround with part of the design. The previous app used @Published and a small Combine bridge to carry selected state into ViewContexts. It sat directly on the boundary between the application spine and the UI, so in my memory it had become one of the things that made the boundary work. My first replacement was more elaborate: create Sendable snapshots on the application actor, pass those snapshots across, and let ViewContexts interpret them on the main actor. It was safe on paper, but it introduced another representation of state and another collection of types whose only job was to cross a seam. The more I worked through it, the more it felt like I was adding architecture to replace a convenience Combine used to provide. Then I went back to the old bridge and read the comment at the top. It described itself as a stopgap for a per-property observation primitive Swift did not have when we wrote it. The design had never required Combine specifically; it was simply the tool available at the time, and after enough years I had stopped remembering the difference.
The newer observation APIs let a sequence be created where state is isolated and consumed where its result belongs. A ViewContext can subscribe to the few properties it needs without receiving AppState, AppContext, or a snapshot of everything surrounding it. The subscriptions belong to the ViewContext, which owns their lifetime and cancels them when it is torn down. The view may cause that work to begin, but the work itself does not belong to the view’s task. This seemed like a clean replacement until the spike wrote to one property 101 times and the observer received one value. Observation had done what it promised by converging on the latest state, which is usually exactly what a screen needs, but anything relying on the path between those values could not use observation at all. Authentication transitions, navigation intent, and other events that cannot be skipped still had to travel through the message channel. We had already drawn that line in the previous app. I had thought of it mostly as a preference about keeping responsibilities clear; coalescing made the consequence of getting it wrong much less theoretical.
The part I had not anticipated at all appeared during logout. The app holds sensitive user data, so replacing the visible screen is not enough; the old session has to stop and its state has to leave memory. The previous app handled this by rebuilding the entire spine. State, services, routing, and contexts were replaced together, and the Combine subscriptions disappeared with them. I had always attributed that behavior to the ownership shape of the architecture, but Combine had been doing more of the work than I realized. When the object containing an @Published property deallocated, its publisher completed, which ended the loops consuming it. The newer observation sequences do not complete merely because the object being observed has gone away. A strong capture can keep the old graph alive, while a weak one may leave the loop suspended with nothing useful left to observe. Rebuilding a few root objects no longer proved that teardown had happened.
For a while I kept reaching for ways to recover the old behavior. Weak references looked promising because they avoided the obvious retain cycle. Cleanup in deinit looked promising because it put cancellation beside ownership. Both helped, but neither explained what was supposed to end the lifetime in the first place. Weakness does not stop a task, and a deinitializer cannot rescue an object that its own work is still retaining. AppContext now owns one root task for the session. Bootstrap happens in order beneath it, followed by the long-running watchers, so logout has something concrete to cancel. Each ViewContext ends its subscriptions during its own teardown. Navigation is reset, those contexts are released, and only then is the session rebuilt. There is still a deinitialization backstop, but it is no longer the primary plan. This is more explicit than the old design, although I hesitate to call it more complicated. The work existed before; Combine and object deallocation had simply made its lifetime difficult to see.
The same lifetime question had been hiding in navigation. The old router kept a type-keyed cache of ViewContexts and tried to decide when each one should be reused or evicted. The cache accumulated synchronization and special cases because it was managing lifetimes indirectly. In the new design, a context is created when navigation is handled and retained by the stack entry that needs it. Remove the entry and the context goes with it. The route-to-view relationship is still supplied at the composition edge, and the architecture package still cannot import SwiftUI, but the lifetime no longer depends on a cache guessing what the navigation stack means.
Somewhere during that work I noticed that the list of mechanisms we were replacing had become longer than the list we were preserving. The old isolation model and Combine bridge were gone, task lifetime worked differently, logout had become explicit, and context caching had disappeared. Even the message channel was undergoing its own Swift 6 rewrite rather than arriving intact. Calling this the same architecture started to feel suspicious. Maybe we were preserving familiar names and telling ourselves the design had survived because AppContext was still called AppContext and the views still had ViewContexts. It is possible to keep the vocabulary of an architecture long after its original boundaries have blurred, and I did not want the name itself to become the evidence.
What kept pulling me back was tracing responsibility rather than types. Services still returned their work to AppContext, which decided how state changed afterward. Views still received prepared data and interaction points without learning about storage or the services behind them. Navigation still passed through orchestration, and state still had one owner, only now the compiler knew who it was. The diagram would have looked familiar before the rewrite and after it. I trust that less than I used to. A boundary maintained by convention and one maintained by the compiler can produce the same boxes and arrows, yet behave very differently once tasks begin crossing them.
The previous architecture had several responsibilities in the right place while relying on mechanisms that could not fully enforce what the diagram claimed. In a few cases, the rewrite exposed that those mechanisms had been weakening the thing they appeared to implement. That does not make implementation incidental. Combine’s completion behavior had shaped logout whether we intended it to or not, just as the cache had shaped context ownership and unstructured tasks had shaped sequencing. Maybe that is why the work felt less like porting than I expected. The machinery was replaceable, but it still decided whether the boundaries were real.
That uncertainty was why, before moving any of it into production, I built throwaway probes for the seams I was least willing to trust. One tested whether the application actor could create and hold a context that eventually crossed to the main actor. Another kept observation loops running while contexts and state were released, so we could see whether the capture graph actually collapsed. A small simulator app pushed and swiped through real navigation while the architecture reconciled both sides. The message path was flooded immediately after startup to verify that attaching handlers first was a guarantee rather than an intention.
The probes changed the design. One protocol had to become Sendable before contexts could cross the boundary. Observation coalescing changed where transitions were allowed to travel. Capture rules became stricter after seemingly harmless closure shapes retained more than expected. Once those answers were recorded, the probe code was deleted; none of it had been written to become part of the app. I still say the new app reuses the previous architecture, although I no longer mean that the old implementation carried over. Each mechanism that stopped fitting forced us to argue about its responsibility again, this time under constraints the old code had never faced. Most of those responsibilities stayed where they were even as the code enforcing them changed. That is a less visible kind of reuse than I expected when I began, and probably why I only noticed it during the rewrite.