In a server-side environment, a database migration is a controlled event. You stop the world, run the script, verify the data, and deploy the code. You own the timeline.
In a local-first mobile environment, you own nothing.
When you deploy a Flutter app using a local database like Isar, you are shipping a schema into a “Black Box” environment where you have zero visibility. Consider the reality of a distributed user base:
- Unpredictable Upgrade Paths: Users might jump from version 1.0 directly to 3.5, skipping every migration logic you wrote in between.
- Offline States: Users might open the app while offline, preventing any remote “repair scripts” or API fetches from running to backfill data.
- Legacy Decay: A user might have a corrupted record from two years ago sitting in a binary index you no longer remember creating.
This creates a state where the schema defined in your fresh code conflicts with the stale reality of the user’s disk. The most common casualty of this conflict is the Isar database migration, specifically when dealing with required fields.
Recent engineering audits have surfaced a critical mechanical vulnerability in how we handle these schema evolutions. The finding is simple: in a local-first persistence layer, “Required” is a lie, and the Constructor is not your friend.
To build truly resilient apps, we must adopt a counter-intuitive architectural standard: Global Database Nullability.
The Constructor Lie
As Dart developers, we are trained to trust the constructor. We define a class, mark fields as required, and rely on the compiler to ensure we never instantiate an invalid object.
class UserProfile {
final String username;
final String email; // Added in v2.0
UserProfile({required this.username, required this.email});
}
We assume that because email is required, UserProfile can never exist without it. This is true for the memory you control. It is false for the database you don't.
The Mechanics of Hydration
When Isar retrieves a record from the disk, it does not politely ask your constructor for permission. It performs Hydration. The generated code allocates memory for the object and then populates the fields via direct assignment.
Here is the “Sequence of Death” that occurs during a migration:
- The Index Lookup: Isar attempts to read the
emailindex for a record created in v1.0. - The Void: Because the field didn’t exist in v1.0, the disk returns nothing.
- The Moment of Failure: The internal reader retrieves a
null. - The Illegal Assignment: Because your model defines the field as a non-nullable
String, the generated code attempts to assign thatnullto the variable.
This triggers an immediate TypeError. The object is never created, the query fails, and the application crashes before your UI can even attempt to handle it.
The Failure of “Magic Values”
When developers encounter this crash, the instinct is often to patch the hole with fake data. We assign default values to satisfy the compiler.
// The "Patching" Approach
@collection
class UserProfile {
Id id = Isar.autoIncrement;
String username;
// "Fixed" with a default value
String email = "PENDING_EMAIL";
}
This technique, often called using “Sentinel Values” or “Magic Strings,” prevents the crash, but it corrupts the architecture in three distinct ways:
- Logic Pollution: Every piece of business logic must now become “magic-aware.” You end up writing code like
if (user.email != "PENDING_EMAIL")across your entire project. - The Type Lie: You have told the compiler that data exists when it does not. This effectively disables Dart’s null-safety features, which are designed specifically to help you handle missing information.
- API Integrity: If you accidentally send “PENDING_EMAIL” to a backend API, you transform a local database issue into a server-side data corruption issue.
The “Late” Trap
Another common attempt to bypass the migration crash is the late keyword:
late String email;
This is an architectural gamble. The late keyword is a promise to the compiler that you will initialize the data before it is used. In a migration scenario, you break that promise immediately. The moment Isar loads a legacy record, the field is uninitialized. Accessing it triggers a LateInitializationError, which is just as fatal as the original TypeError but harder to debug.
“Without clean data, or clean enough data, your data science is worthless.” — Michael Stonebraker, MIT
The Solution: Global Nullability
The only way to guarantee 100% uptime across all possible upgrade paths — without resorting to magic values — is to separate your Persistence Model from your Domain Model.
We must accept a hard truth: At the database level, everything is optional.
Step 1: The Honest Database Model
In your Isar collection, you mark fields as nullable, even if your business logic says they are required.
@collection
class UserProfileDB {
Id id = Isar.autoIncrement;
String? username;
String? email; // Nullable, reflecting the reality of v1.0 disks
}
This satisfies the database engine. If Isar reads a legacy record with no email, it assigns null. No crash. No TypeError. The application opens successfully.
Step 2: The Repair Bridge
The strictness belongs in your Domain Model and the mapper that connects them. This is where you explicitly handle the “Migration Gap.”
// The Mapper (The Repair Logic)
extension UserProfileMapper on UserProfileDB {
UserProfile toDomain() {
// 1. Detect the legacy state
final bool isLegacy = email == null;
if (isLegacy) {
// 2. Trigger repair logic (e.g. background fetch)
}
return UserProfile(
username: username ?? 'Unknown User',
// 3. Provide a safe fallback for the UI
email: email ?? '',
);
}
}
Architectural Resilience
This approach shifts the responsibility of data integrity from the implicit hydration process to the explicit mapping logic. By adopting Global Nullability, you gain:
- Crash Immunity: Your app will always open, regardless of how old the user’s data is.
- Honest Types:
nullcorrectly signifies "this data is missing," allowing you to use Dart's standard null-aware operators (??,?.). - Separation of Concerns: Your UI deals with strict, clean objects, while your persistence layer deals with the messy reality of the disk.
A linting package like saropa_lints now enforces this pattern by flagging non-nullable fields in Isar collections. They are not asking you to lower your standards; they are asking you to acknowledge the physical reality of the device.
Conclusion
The presence of hundreds of migration errors is a roadmap of technical risk. Because the Dart Constructor is bypassed during hydration, the only way to prevent fatal runtime TypeErrors is to adopt Global Database Nullability.
By making the Persistence Layer nullable and the Domain Layer strict, we ensure that no user, regardless of their version history, will ever experience a crash due to schema evolution.
“Data is a precious thing and will last longer than the systems themselves.” — Tim Berners-Lee
References
- saropa_lints, https://pub.dev/packages/saropa_lints
- Isar Schema & Migration Behavior, https://isar.dev/schema.html#schema-changes
- [Dart] Sound Null Safety (Runtime Null Checks), https://dart.dev/null-safety/understanding-null-safety#runtime-checks
- Local-first software: You own the data, in spite of the cloud, Ink & Switch (Research Lab), https://www.inkandswitch.com/local-first/
Final Word 🪅
