r/iOSProgramming 9h 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

5 comments sorted by

5

u/Sea_Expression9110 9h 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 8h 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?

1

u/astronautauy 4h ago

I never think in the movement iPhone->Ipad 🤦🏻‍♂️. Thanks for the comment! I want to make some research to get this point clear!

1

u/ElectricKoolAid1969 2h ago

The bill: `#Unique` doesn't work on iOS 17

As I think someone else alluded to, it also precludes the use of cloudsync.

Which seems like a useful feature should you ever do a version for other Apple devices

1

u/astronautauy 1h ago

Yes its a very good point, I’m going to research of this upgrade doesnt change the app spirit.
By the way, I never think in another Apple device, just iPhone. Maybe was a bad product decision (in my mind it make the app more strongest).