Isar & The Migration Gap Crash

The Case for Global Nullability in Mobile Persistence Layers

Back to all articles

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:

  1. The Index Lookup: Isar attempts to read the email index for a record created in v1.0.
  2. The Void: Because the field didn’t exist in v1.0, the disk returns nothing.
  3. The Moment of Failure: The internal reader retrieves a null.
  4. The Illegal Assignment: Because your model defines the field as a non-nullable String, the generated code attempts to assign that null to 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:

  1. Crash Immunity: Your app will always open, regardless of how old the user’s data is.
  2. Honest Types: null correctly signifies "this data is missing," allowing you to use Dart's standard null-aware operators (??, ?.).
  3. 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

  1. saropa_lints, https://pub.dev/packages/saropa_lints
  2. Isar Schema & Migration Behavior, https://isar.dev/schema.html#schema-changes
  3. [Dart] Sound Null Safety (Runtime Null Checks), https://dart.dev/null-safety/understanding-null-safety#runtime-checks
  4. Local-first software: You own the data, in spite of the cloud, Ink & Switch (Research Lab), https://www.inkandswitch.com/local-first/

Final Word 🪅

Illustration from article
saropa.com
Share this article

Your feedback is essential to us, and we genuinely value your support. When we learn of a mistake, we acknowledge it with a correction. If you spot an error, please let us know at blog@saropa.com and learn more at saropa.com.

Originally published by Saropa on Medium on January 29, 2026. Copyright © 2026