Deterministic Serialization: Making Architecture Models Git-Friendly
Commit a small rename to an architecture model and open the diff. If the diff is one line, the format is doing its job. If it's four hundred lines of reshuffled JSON with a two-character semantic change buried somewhere inside, the format is fighting you — and everyone who has to review that pull request pays for it, every time, whether or not anything meaningful actually changed.
This is not a hypothetical problem for architecture models specifically, but it bites harder there than almost anywhere else in software. A model file isn't generated once and left alone; it's edited by several architects over months, reviewed the way code is reviewed, and expected to hold a legible history — who changed what, and when, and why. If the serialization format can't produce a stable, predictable diff for a stable, predictable change, none of that works. You get a repository that technically has version history and practically has none, because nobody can read it.
What "non-deterministic" means in practice
Determinism, in this context, has a narrow and testable meaning: serializing the same model twice, with no semantic change in between, produces byte-identical output. Save without changing anything, and the file on disk doesn't move. That sounds like it should be the default behavior of any serializer. It usually isn't, for four recurring reasons.
Object key and collection ordering
Most general-purpose serializers walk whatever in-memory structure they're given — a hash map, a dictionary, a set — and write out keys in whatever order that structure happens to iterate in. In many languages that order is insertion order and is stable within a single process, but it is rarely stable across a save-edit-save cycle, across different runtime versions, or across two different people's machines. Add a property to an object, and depending on how the in-memory representation rehashes, three unrelated properties earlier in the object can silently swap positions in the output. Nothing about those three properties changed. The diff says otherwise.
Floating timestamps and regenerated identifiers
A worse variant: some tools stamp every save with a fresh "last modified" timestamp on every object touched by the save operation — not just the one the user actually edited — because the save routine walks the whole tree and it was simpler to write a timestamp on everything than to track precisely what changed. Worse still, some ID schemes regenerate identifiers on export even when nothing was added or removed, because the export step treats IDs as a serialization detail rather than as part of the model's actual identity. Both failure modes turn a zero-change save into a diff that touches every object in the file.
Whitespace, formatting, and float precision
Smaller but just as corrosive: a formatter that isn't pinned to one style will happily alternate between two-space and four-space indentation depending on which editor last touched the file, or between trailing commas and none. A coordinate stored as a floating-point number can print as 120 in one save and 120.00000000001 in the next, because two different code paths computed a layout position with slightly different floating-point rounding. None of this is a semantic change. All of it shows up in the diff as if it were.
Non-deterministic ordering of collections that have no natural order
A relationship list, a tag set, a list of view members — anything backed by a set or built by iterating a map — has no guaranteed order unless the serializer imposes one. Two runs of the exact same export, from the exact same model, can emit that list in two different orders purely because the underlying data structure doesn't promise an order and nobody told it to sort before writing.
Why this bites architecture models specifically
Non-deterministic output is an annoyance in most file formats. In an architecture model kept in Git and reviewed by more than one person, it's structural. Three things make architecture models an unusually bad place for a noisy serializer.
First, architecture models are reviewed the way code is reviewed — as pull requests, by a second architect, often under time pressure, often for a change they didn't make and have to understand cold. A reviewer's entire strategy for a PR is reading the diff and reasoning about what changed. That strategy only works if the diff reflects the change. A twenty-line semantic edit that produces a two-hundred-line diff isn't twenty times harder to review; it's often abandoned, rubber-stamped, or reviewed by skimming rather than reading, because nobody can hold two hundred lines of reshuffled structure in their head to find the twenty that matter.
Second, architecture models get merged, not just reviewed. Two architects working on separate branches — one renaming a service, one adding a new relationship elsewhere in the same file — expect Git's merge algorithm to combine their changes automatically, the way it does for source code. Git's merge is line-based: it looks at what changed relative to a common ancestor and tries to apply both sets of line changes cleanly. That only works when each person's edit maps to a small, localized set of lines. When the serializer reorders unrelated objects on every save, two architects who touched completely different parts of the model can still produce a merge conflict, because the file each of them saved has drifted out of line-for-line alignment with the common ancestor in places neither of them intended to touch.
Third, architecture models are the kind of artifact people go back through history for. "When did this dependency get added" and "who approved removing this rule" are real questions an architect asks against `git blame` or `git log -p`, sometimes months later, sometimes during an audit. A noisy serializer doesn't just make today's diff hard to read — it makes every historical diff in the file's entire lifetime equally hard to read, because the noise compounds. A file that has been reformatted by ordering churn twenty times over two years has a history that's mostly archaeology.
What "stable ordering" actually buys you
Stable ordering means the serializer imposes an explicit, deterministic rule for where every object goes in the output — not "whatever order the in-memory map iterates in," but a rule like "elements sorted by stable ID, relationships sorted by (source ID, target ID, type), properties within an object sorted alphabetically by key." The rule doesn't have to be clever. It has to be fixed, and it has to be the same rule every time, on every machine, in every language runtime the tool ever ships in.
The payoff shows up immediately once you look at an actual diff. Here's a rename under a naive, unordered serializer — a hash map iterating in whatever order it iterates, with a full re-stamp of "modified" timestamps on every touched node during the save pass:
- "elements": {
- "e2": { "name": "Customer API", "type": "Application", "modified": "2026-08-19T10:02:11Z" },
- "e7": { "name": "Billing Service", "type": "Application", "modified": "2026-08-19T10:02:11Z" },
- "e1": { "name": "Identity Service", "type": "Application", "modified": "2026-08-19T10:02:11Z" }
- }
+ "elements": {
+ "e1": { "name": "Identity Service", "type": "Application", "modified": "2026-08-26T14:41:03Z" },
+ "e7": { "name": "Billing Service", "type": "Application", "modified": "2026-08-26T14:41:03Z" },
+ "e2": { "name": "Customer Service", "type": "Application", "modified": "2026-08-26T14:41:03Z" }
+ }
Three objects changed position, three timestamps were rewritten for no reason, and the one actual edit — e2 renamed from "Customer API" to "Customer Service" — is in there somewhere, indistinguishable at a glance from noise. A reviewer scanning this has to read every line to find the one that matters, and a second architect who touched e7 in a parallel branch is now looking at a merge conflict on a line that has nothing to do with their change.
Here's the same rename under a serializer with stable ordering — elements sorted by ID, and a "modified" timestamp that only updates on the object actually touched:
"elements": {
"e1": { "name": "Identity Service", "type": "Application", "modified": "2026-08-19T10:02:11Z" },
- "e2": { "name": "Customer API", "type": "Application", "modified": "2026-08-19T10:02:11Z" },
+ "e2": { "name": "Customer Service", "type": "Application", "modified": "2026-08-26T14:41:03Z" },
"e7": { "name": "Billing Service", "type": "Application", "modified": "2026-08-19T10:02:11Z" }
}
Two lines changed. That's the whole edit: a name, and the timestamp on the one object that actually moved. A reviewer reads it in two seconds. A second architect editing e7 in parallel never touches these lines at all, so Git merges both changes automatically without asking anyone to resolve anything.
Stable identity is the other half of the story
Ordering alone isn't enough if the thing being ordered doesn't have a durable key to sort by. This is where deterministic serialization connects to a decision that has to be made earlier, at the model layer: every element needs a stable, immutable ID that's independent of its display name. If "Customer API" is identified in the file by its own name rather than by a separate machine ID, then renaming it to "Customer Service" doesn't look like a rename in the diff — it looks like the deletion of one object and the creation of an unrelated one, because the sort key itself changed. A diff reviewer sees -"Customer API": {...} and +"Customer Service": {...} in two different places in the file and has to reconstruct, by reading the surrounding properties, that these were actually the same object. A merge tool doesn't even get that chance — it just sees an object removed and a different object added, and any relationship pointing at the old name is now dangling.
With a stable ID as the sort and reference key — an element referenced everywhere as e2, with "Customer Service" carried purely as a name property — the object's position in the file doesn't move when it's renamed, its relationships don't need to be rewritten, and the diff is exactly the one line that changed. This is the same mechanism that makes AI-proposed patches, round-trip exchange with other tools, and semantic diffing all work reliably; deterministic serialization is really just what that mechanism looks like once it hits the page.
Sorting rules that don't fight the model
Once IDs are stable, the actual sort order is a design choice with real consequences for diff readability, not just an arbitrary pick. A few options, and their tradeoffs:
- Sort by ID. Simple and completely stable, but IDs are usually opaque strings, so a reviewer scanning the raw file sees elements in an order that has no relationship to anything they recognize. Fine for a machine-consumed export; less pleasant for a human skimming the file directly.
- Sort by creation order, recorded once and frozen. Reads naturally — newest elements at the bottom, roughly matching how the model grew — and stays stable because the order is stored, not recomputed. The cost is one extra field per object to track it.
- Sort by a natural key, like element name, recomputed on every save. Readable, but reintroduces the exact problem stable IDs were meant to solve: renaming an element changes its sort position, so a simple rename produces a reordering diff again, just one level removed from where it started.
There's no universally correct answer among these — it depends on whether the file is meant to be read raw by humans or mostly consumed through tooling — but whichever rule gets picked has to be fixed and applied uniformly, including inside nested collections like a view's member list or an element's tag set. A serializer that sorts elements correctly but leaves relationship lists in insertion order has only solved half the problem.
The other sources of noise, and how to close them off
Ordering is the most visible source of diff noise, but a genuinely deterministic serializer has to close off the other three as well.
Timestamps that only move when the thing they describe actually changed. A "last modified" field belongs on the object that was edited, set at the moment of that edit — not recomputed at export time by walking the whole tree and stamping everything with "now." This sounds obvious once stated and is nonetheless one of the most common causes of whole-file diff noise, because it's the easiest thing to get wrong: a save routine that touches every node in memory has a timestamp field sitting right there, and it's a natural, incorrect shortcut to just write the current time into it on the way past.
IDs generated once, never regenerated on export. An identifier assigned at creation time has to survive every subsequent save, export, and re-import untouched. A serializer that assigns a fresh ID on every export — because, say, IDs are computed as a hash of an object's current properties rather than stored as an independent field — will silently break every relationship that references the old ID, and will do it invisibly, since the object still looks the same to a human reading the file.
A pinned formatter, not an ambient one. Indentation width, key quoting style, trailing newline, line-ending convention — none of these are semantic, and all of them need to be fixed by the tool rather than left to whatever the editor or platform defaults to. This matters more on a team that spans Windows and macOS/Linux than it might seem: a serializer that emits \r\n on one machine and \n on another turns every single commit into a whole-file diff regardless of what actually changed, and it's the kind of bug that's easy to miss locally and catastrophic the first time two architects on different platforms both commit to the same file.
Numbers printed the same way every time. A canvas position, a diagram coordinate, a computed layout value — anything that passes through floating-point arithmetic needs to be rounded to a fixed precision before it's written out, and formatted with a consistent number of decimal places. Otherwise two saves that are visually and semantically identical can differ at the fourteenth decimal place of a coordinate nobody was even looking at, and that shows up as a changed line in the diff exactly like a real edit would.
What this looks like when it's working
The honest way to evaluate a model file format for this property is the same test stated earlier, made concrete: open the model, change nothing, save, diff. Then make one small, specific change — rename one element, add one relationship, retag one service — and diff again. The second diff should be proportional to the edit: a handful of lines for a handful of changed facts, not a percentage of the file that scales with file size regardless of edit size.
This is the design target Mooodels holds its own serializer to, and it's worth being specific about why it's treated as a hard requirement rather than a nice-to-have: a model kept in Git, reviewed through pull requests, and edited by more than one architect is exactly the scenario where this property either holds or the workflow quietly stops working. Elements and relationships serialize in a fixed, predictable order rather than however an in-memory structure happens to iterate; a rename is a one-line change because the element's position and its references are keyed on a stable ID, not on its display name; timestamps update only on the object actually touched; and formatting — indentation, line endings, number precision — is fixed by the serializer rather than left to whatever produced the last save. None of this is visible in a demo. It's only visible six months in, in the pull request queue, when the tenth architect opens a diff and can actually tell what changed without reading the whole file.
Honest limits of what determinism buys you
It's worth being direct about what this does and doesn't solve, because "deterministic" is sometimes oversold as a cure for merge conflicts generally. It isn't. If two architects genuinely edit the same fact — both rename the same element to different names, or one deletes an element the other just added a relationship to — that's a real conflict, and no amount of stable ordering makes it go away. What deterministic serialization eliminates is the false conflict: the one that exists purely because the file format scrambled unrelated content on save. A clean, line-based diff still leaves you with the conflicts that are actually about disagreement over the model, and that's the correct outcome — those conflicts should surface, be looked at by a human, and get resolved deliberately. Line-based diffing also has a ceiling of its own: it tells you which lines changed, not whether the change is semantically meaningful (a relationship type change reads the same, diff-wise, as a typo fix), which is why a stable, low-noise diff is the foundation for tooling like semantic diff and automated rule-checking, not a replacement for it.
There's also a real performance-versus-readability tradeoff buried in the sorting choice, worth naming rather than glossing over. Re-sorting a large collection on every save has a computational cost, and for a model with many thousands of elements that cost is not zero, even if it's small relative to everything else happening in a save. In practice this rarely matters — sorting a few thousand records is fast on any modern machine, and it happens once per save, not on every keystroke — but it's the kind of thing worth measuring rather than assuming away if a model ever grows large enough for it to show up in a profile.
A short checklist
For anyone evaluating whether a model format — any format, not just this one — will hold up under real Git use with more than one contributor, a few quick checks surface the answer fast:
- Save the same model twice with no edit in between. Is the output byte-identical, or does something move?
- Rename one element. Is the diff proportional to a rename, or does it touch objects that weren't renamed?
- Have two people edit unrelated parts of the same model on separate branches. Does the merge go through cleanly, or does it conflict on lines neither of them touched?
- Check whether "last modified" timestamps move on objects nobody edited.
- Export, then re-import without changes, then diff. Does anything reorder, or do any IDs change?
A format that passes all five will make Git history genuinely useful for a model — reviewable diffs, clean merges, a blame log that means something a year later. A format that fails even one of them will technically live in Git without getting much benefit from being there, and the team will eventually stop trusting the history enough to actually use it, which is the same as not having it.
None of this is exotic engineering. It's mostly discipline: pick an explicit sort key, key it on something stable, stamp timestamps only where something actually changed, and fix the formatter instead of trusting whatever wrote the file last. The payoff isn't a feature anyone demos. It's a pull request, eight months from now, that a second architect can actually read.
See the model this article describes, working in a real editor.
Try the live demo