Claude and a two-week architecture detour

I’m building a macOS app with a cloud backend. Two weeks of careful architecture and modeling with Claude later, the architecture turned out to be structurally wrong, and I had to unwind it. This is the story of that detour, and why I’d take the same approach again.

The first architectural decision was the backend. I worked through it with Claude — laying out the candidates, weighing tradeoffs, pressure-testing each option against what the app needs. Mainstream candidates were obvious: AWS, Azure, GCP. The tempting alternative for an app that’s going to live entirely inside the Apple ecosystem is CloudKit. Native APNs. iCloud authentication. Per-user storage that doesn’t count against my bill. Built-in sync. For an Apple-only app, the math was clear.

Decision locked, decision-log.md updated.

Next conversation with Claude: how the app should talk to CloudKit. SwiftData or direct CKContainer access. We went deep on this. SwiftData has a lot to recommend it — Swift-native paradigms, less boilerplate, full SwiftUI integration, and the headline feature: an auto-managed CloudKit mirror that just syncs in the background. The tradeoff is eventual consistency, which is well-understood and manageable. The whole cloud is built on it.

Decision locked, decision-log.md updated.

On to development.

Two weeks of work with Claude. Schema design sessions. Every model written out and reviewed together. Code walkthroughs. Test cases. Reconciling SwiftData state against what showed up in the CloudKit dashboard. A rudimentary UI to exercise everything end-to-end. It came together cleanly, and I was proud of it — probably the most productive two weeks of my career on this kind of code. The networking and security plumbing alone would have taken months of careful reading without Claude in the loop. Some of it I probably couldn’t have written at all on a reasonable timeline.

Then I ran the app on a second Mac.

The app maintains shared CloudKit data across multiple Macs, and eventually iOS. I expected to see my data on the second Mac, edit something, watch it propagate. That’s not what happened.

One of my models is a true singleton — there should be exactly one row of it, ever. SwiftData can’t enforce that. The second Mac, not having seen the mirror’s sync yet, helpfully created its own row. The next sync inserted a second row alongside the first. Both apps crashed. I went back to Claude. Options? Could we add a unique constraint? No — SwiftData doesn’t support them. Force a sync first, wait, then upsert? Yes, technically. It blocks the app, doesn’t fully close the race, and is a bandaid by any honest description. I could live with dedup logic instead, but it’s not the kind of code I want underneath a paying customer.

A second model is one row per Mac — not a true singleton, but close. The same race shows up on OS reinstall and a few other edge cases. Platform-id workarounds plus dedup get me ninety percent of the way. That last ten percent would haunt me.

Fine — Claude and I worked through moving these singletons out of SwiftData and managing them directly in CloudKit. Can’t do it in the default zone; SwiftData owns that. So: create a new zone. Solved. Except now I have parent-child relationships that cross zones, and CloudKit won’t enforce them. Cascade delete is gone. When a Mac record gets deleted, everything underneath it is orphaned. I’d have to write that bookkeeping myself, carefully, forever.

At this point something smelled wrong. When you’re piling workaround on workaround to make a framework do something it doesn’t want to do, you’re either using the wrong framework or you have the wrong architecture. Probably both. Two layers of workaround is a warning. Three is a verdict.

So I asked Claude to stop helping me work around the symptoms. Go look instead — across Apple’s developer documentation, WWDC sessions, forums, anything reachable — for whether SwiftData and CloudKit’s full feature set were ever meant to be used together this way.

It came back with a load-bearing finding we’d both missed.

SwiftData’s CloudKit mirror only works with the Private Database — not the Shared Database, not the Public Database. This is confirmed in WWDC23 lab notes and stated plainly by an Apple Worldwide Developer Relations engineer (Ziqiao Chen) on the developer forums. The implication: the moment a user accepts a “share my data with another user” invitation, that data lives in the Shared Database, and SwiftData’s @Query path simply cannot see it. It’s not a polish problem. It would break the v1.0 sharing feature the day the first paying customer tried to use it.

We’d been laying down architecture that broke my product roadmap.

What Apple actually recommends, post-WWDC23, is CKSyncEngine — a new API explicitly designed for the “bring your own persistence layer plus CloudKit sync” pattern. Ziqiao Chen confirms on the forums that the local cache layer can be whatever you want, including SwiftData. The official pattern is SwiftData for local persistence, CKSyncEngine for sync — not SwiftData’s built-in mirror.

That’s the pattern. We’d been using the wrong half of the framework.

Two weeks of work, gone. Not really — this is exactly the moment you want to find out you’re on the wrong path, before there are users on it. But it’s the kind of moment worth being honest about, because the lessons aren’t really technical. They’re about how to work with AI on serious software.

When the framework fights back, ask if you’re using the right framework. I should have caught the smell sooner. The architectural reversal happened the moment I stopped asking Claude to fix individual symptoms and asked it to investigate the premise instead. That’s not an AI lesson. That’s a thirty-year engineering lesson that I let slip because the velocity felt good. Pair-programming with AI doesn’t suspend the discipline; if anything, it raises the cost of forgetting it, because you can ship a lot of wrong code very fast.

Velocity is still a net win, even with the rework. Two weeks of building plus a rewrite is still less than the months this would have taken without Claude — and parts of it I probably couldn’t have done at all in a reasonable window. The rework cost is real, but it’s not the right comparison. The right comparison is what would have happened without the AI in the loop at all.

I don’t blame Claude, and I don’t buy the “AI slop” framing. Claude evaluated SwiftData against what I told it the app does today, and the answer was reasonable for that scope. It underweighted the v1.0 sharing requirement because I didn’t push it to the front of the conversation. I’d have made the same call sitting at a whiteboard with a senior engineer who hadn’t read the full spec. AI makes mistakes. So do humans (myself included, in plenty of past lives). The discipline to catch them is the engineering, not the tool. I’ve seen people produce slop with their own hands; I’ve seen people ship excellent work with AI in the loop. The tool isn’t the variable.

The way to work with AI on serious software is the way you’d work with a team of capable engineers at your disposal: trust, verify, document everything, log decisions, walk through code yourself, refactor what you don’t fully understand, and keep the instinct for when something smells wrong. The instinct is the part you can’t outsource.

So: back to re-architecture, with Claude. SwiftData stays — for local persistence. CKSyncEngine takes over the sync layer. The singletons stop being a fight against the framework. Cascade delete comes back. The Shared Database path opens up, which means the v1.0 sharing feature actually works the day it ships.

No sprint plan to update. No quarterly roadmap to recast. No weekly status report with a sad amber dot next to “Architecture.” No recovery plan to socialize, no stakeholders to manage, no engineering executives up the chain wondering what the team has been doing for two weeks. Just me, Claude, the laptop, and two weeks of work to redo. Properly, this time.

Joys of being a solo founder.

Decision locked, decision-log.md updated.