Early builds of Crumb Count had a bug that only reproduced on roughly one in twenty scan uploads: a nutrition payload would round-trip through our Kotlin ingestion service, get cached locally, and come back out of Dart with a null field that had definitely been populated on the way in. No crash, no exception in either runtime’s logs — just a silently dropped value that showed up three screens later as a blank macro count. We spent the better part of a week chasing it before we found the actual cause: a shared Kotlin data class, exposed to Dart through a code-generation bridge, that encoded an optional enum as a bare int on one platform and as a nullable wrapper on the other. The generator hadn’t done anything wrong. Two type systems had simply disagreed about what “optional” means, and the bridge papered over the disagreement instead of surfacing it.

That bug is why we don’t share model definitions across Dart and native code anymore. Not “share carefully” — don’t share at all. Every platform boundary in our stack defines its own model, its own (de)serialization, and its own validation, and the two copies are kept in sync by contract tests, not by a shared source file.

The failure mode that shared models can’t see

The pitch for a shared models folder is obvious: define the shape once, generate bindings for both sides, never let Dart and Kotlin drift out of sync. It works fine for the happy path. It fails specifically at the edges each language handles differently — nullability, enum exhaustiveness, integer width, and date/time representation.

  • Nullability semantics. Kotlin’s null-safety and Dart’s sound null-safety look similar on the surface but resolve differently for generic types and platform interop boundaries, especially anything crossing a MethodChannel or Pigeon-generated interface. A field that’s “never null after construction” in Kotlin can arrive genuinely null in Dart if the object was built through a deserialization path the type system doesn’t fully see.
  • Enum exhaustiveness across versions. Add a case to a shared enum on the native side and ship it before the Dart side updates, and Dart’s exhaustive switch either throws or silently falls into a default branch you forgot was there — depending on how the generated bridge handled the new ordinal.
  • Numeric width and JSON round-tripping. Kotlin’s Long and Dart’s int both claim 64-bit, but JSON serialization on one side and platform channel marshalling on the other don’t always agree, particularly once a value has passed through a Firestore or REST layer that JSON-encodes it in between.

A single shared definition can’t express “this field behaves like X here and Y there” — that difference is exactly the information a merged model erases. The bug we hit in Crumb Count wasn’t a coding mistake in the generator; it was a category of bug that a shared-model approach is structurally unable to represent, let alone catch at compile time.

What we do instead

Each platform gets its own model, written idiomatically for that language, with its own construction and validation logic. Dart models use freezed or plain immutable classes with explicit fromJson/toJson; Kotlin models are plain data classes with kotlinx.serialization or Moshi, written the way a Kotlin developer would write them if Dart didn’t exist. Nobody generates one from the other.

The two copies stay honest via two mechanisms, not one:

  1. A wire-format contract, not a code contract. We define the JSON/protobuf shape as the actual interface — field names, types, required-vs-optional — documented once, independent of either language’s type system. Both models serialize to and deserialize from that shape, and that shape is what’s versioned.
  2. Golden-file round-trip tests on both sides. The same fixture payloads — including deliberately malformed ones: missing optional fields, unexpected enum ordinals, boundary integer values — get fed through both the Dart and Kotlin deserializers in CI. If Kotlin accepts a payload that Dart rejects, or the two disagree on what a missing field defaults to, the test fails before it ships, not three screens deep in production telemetry.

The cost is real and worth naming plainly: more files, more places to update a field name, and a genuine risk of drift if the contract tests are weak or skipped. We accept that cost because the alternative — the illusion of a single source of truth hiding a real platform-level disagreement — is the more expensive failure. Drift caught by a failing test in CI is a five-minute fix. Drift discovered by a user reporting a blank macro count is a support ticket, a debugging session, and a hotfix release.

Where this generalizes

The same logic applies anywhere two runtimes need to agree on a shape without sharing a type system: a backend admin console talking to a mobile client, an AI feature that parses model output into a typed object on the server before it ever reaches the app, or a legacy codebase where “the API and the app used to be one repo” turns out to be doing a lot of quiet, undocumented work. In code audits, this exact pattern — a shared model or shared generator masking a genuine cross-platform type disagreement — is one of the first things we check for, because it tends to explain the “impossible” intermittent bugs a team has already spent weeks on.

This is a small, unglamorous decision, and it’s the kind of thing that only becomes visible after it’s caused a production bug. It’s also representative of how we approach architecture at orithLabs generally: we’d rather write the boring, duplicated version that fails loudly in a test than the elegant, shared version that fails quietly in someone’s hands. If you’re seeing intermittent, hard-to-reproduce data bugs at a platform boundary in your own app, it’s worth checking whether a shared model is the reason.