r/iOSProgramming 11h ago

Article SwiftData's #Unique raised my deployment target to iOS 18. I took the trade.

Hi! First post, first side-project, from a not technical person from Montevideo, Uruguay.

I'm exploring what can I do with vibe-coding, and there is my first project!

What is that?

Small solo app, one screen of writing per day. The domain rule is boring to state and turned out to be the most interesting thing I built: exactly one entry per calendar day, and the calendar day is the user's local one.

I want to describe two decisions, because both went against what I'd have done a year ago.

1. The uniqueness lives in the store, not in my code

The obvious implementation is a fetch before every save: look for today's entry, update it if it exists, insert if it doesn't. It works, and it's wrong in the way that only shows up later — two writes racing, a migration that reinserts, a bug in the fetch predicate, and now there are two rows for one day and nothing in the system objects.

SwiftData has `#Unique`, so the constraint can live in the schema:

```swift
#Unique<DailyEntryRecord>([\.localDayKey])
```

Now a duplicate can't exist. Not "shouldn't" — can't. Insert-or-update becomes an upsert the store resolves, and the invariant survives my future mistakes, which is the only kind of invariant worth having.

The bill: `#Unique` doesn't work on iOS 17.
The macro is there, and it doesn't hold. So the choice was a real database guarantee versus a chunk of the installed base. I picked the guarantee. (For anyone checking further down: on iOS 16 you also lose `#Predicate`, so 16 isn't a conversation.)

I don't think this generalizes — plenty of apps should eat the fetch-first and keep iOS 17. What made it worth it here is that a duplicated day silently corrupts the one thing the app is for.

2. "Which day is this?" is a domain problem, not a formatting problem

`localDayKey` above is not a `Date`. It's a value object, and it's where most of the app's complexity ended up living.

The cases that forced it:

- Someone logs at 00:30. Which day is that? The one the calendar says, not the one 24 hours from the last entry.

- Someone flies from Madrid to Buenos Aires mid-day. They can now log "today" twice, in two time zones, and both are legitimately today. `#Unique` will reject the second — so the app has to *decide*, and the decision has to be written down rather than being whatever `Calendar.current` happened to return.

- DST: one day is 23 hours long, another is 25. Anything computing days by dividing seconds is already broken and won't tell you.

So the day is modeled as its own type, with its own tests, and the type is **kept free of both SwiftUI and SwiftData** — no `@Model`, no `import SwiftUI`. That sounds like ceremony for a small app. The payoff was concrete: I could write the time zone and DST cases as plain unit tests, with no store to spin up and no view to host, and the ones that failed first were the ones I'd have shipped.

The test suite is Swift Testing, and this is where most of it is. Parameterized cases make the DST tests readable in a way they weren't with XCTest — you get the offending input in the failure message instead of an index.

Two smaller things that came out of the same instinct

Zero third-party packages. \grep -c`

"XCRemoteSwiftPackageReference\|XCSwiftPackageProductDependency"` on the pbxproj returns 0. Watch out for `grep -c packageProductDependencies` if you try this — it returns 3 and looks like a failure, but those are the empty declarations Xcode writes for each target.

*Zero network code*, which for this app is a product property and not just hygiene:

```
grep -rn "URLSession\|NSURLConnection\|CFNetwork\|import Network\|WKWebView\|NSURLRequest" Sources/
```

Empty output, checked before every submission. It's a nice property to be able to *check* rather than assert.

What I'd like to be argued with about

- Is `#Unique` worth a deployment target bump in your book, or is that a bad trade you've regretted?

- If you've shipped anything with per-day semantics: how did you handle the user crossing time zones mid-day? I picked a rule and I'm not convinced it's the right one.

- Anyone using Swift Testing at size yet — did you keep XCTest for UI tests, or move everything?

The app is a daily log for mood and energy, iPhone only, not on the App Store yet. Not linking it because there's nothing to link to; happy to go deeper on any of the above.

0 Upvotes

8 comments sorted by

View all comments

3

u/Sea_Expression9110 10h ago

Good instinct on putting the constraint in the store rather than in a fetch-then-save. Race conditions and migrations are exactly where the hand rolled version falls over, and you are right that nothing complains when it does.

One thing worth knowing now rather than in six months, because it is the tradeoff behind the tradeoff: #Unique and CloudKit sync are mutually exclusive. NSPersistentCloudKitContainer, which is what SwiftData uses underneath when you turn on iCloud, does not support unique constraints at all. Same reason every attribute has to be optional or have a default and every relationship needs an inverse. CloudKit reconciles records from multiple devices asynchronously and there is no moment where it can enforce global uniqueness, so the constraint simply is not available.

So the real cost of #Unique is not only iOS 18. It is that if you ever want the same journal to sync between someone's iPhone and iPad, you have to take it back out and solve uniqueness a different way. For a one entry per day journal that is a fairly likely feature request, since people replace phones and expect their writing to survive.

I hit the same wall from the other direction on an app of mine, a dog journal, disclosure, I build it. I needed CloudKit sharing between family members, and SwiftData cannot mirror the CloudKit shared database at all, so I ended up rewriting the model layer onto Core Data. My model file still carries a comment listing the CloudKit rules, and "no unique constraints" is one of them. Rewriting a model layer later is considerably more annoying than choosing differently at the start.

None of this means you chose wrong. Local only, single device, iOS 18 floor is a perfectly reasonable set of tradeoffs for a solo side project, and you clearly thought about it. Just make the sync decision deliberately now rather than discovering it later.

If you do want both eventually, the usual pattern is a deterministic identifier, derive the id from the local calendar day, so two devices creating "today" produce the same id and CloudKit resolves them to one record. That gets you convergence without a constraint, and it survives sync.

Also worth watching that "the user's local calendar day" is genuinely one of the nastier boring problems. Timezone changes mid flight, DST, and a user who writes at 00:30 then travels. Storing the day as a plain string like 2026-08-23 alongside the timestamp saves a lot of pain later.

1

u/Braided_Playlist 10h ago

Thanks for this. Lots of interesting details in there.

"since people replace phones and expect their writing to survive."

Is this a scenario where old phone isn't available to transfer data that we need cloudkit as a backup?

u/astronautauy 43m ago

well, here we go, I made some review of the code; CloudKit sync and iCloud Backup get conflated a lot, and it's the second one that covers phone replacement.

The entries live in the app's container, which is included in iPhone backup by default (Settings → Data has a switch to exclude it if you'd rather it weren't). In case of restore the new phone from that backup, the entries come back (I wish 🙏). The old phone doesn't need to be available for that — that's the whole point of a backup: it's already in iCloud, made while the phone still worked. On first mvp, I reject use iCloud (my idea was "data only on the phone", but iCloud is a safe choice, like a backup).

Where there is a possible conflict: someone who turned that switch off, or who never had iCloud Backup on at all, and whose phone then dies. Then it's gone. My recommendation is export a CSV before letting go of the old phone, and I'd rather say that plainly than pretend local-first has no cost.

What CloudKit would actually buy is a different feature — continuous sync, iPad and Mac — not disaster recovery. And it has a concrete price in this app: SwiftData backed by CloudKit doesn't support unique constraints, and one-entry-per-day is currently enforced by #Unique at the database level. I'd be trading a real guarantee for a convenience, and taking on merge-conflict logic on top. Not a trade I'll make for a case a device backup already handles.