Skip to main content

Rust Made Object Mutation Hard—So We Designed It Properly

· 9 min read
Philip Z
Architect

Object mutation feels simple in many languages. Load an object, call a setter, and save it. If the object has children, mutate those too. Somewhere underneath, an ORM compares snapshots, observes setters, or walks an identity map and turns the differences into database commands.

Then we implemented the same programming model in Rust.

Rust did not let us casually hide shared mutable state behind an object graph. Ownership and borrowing forced us to answer questions that our other runtimes had allowed us to postpone.

  • Who owns the pending changes?
  • What is the mutation boundary when a parent and its children change together?
  • How does a save operation discover a change made through another reference?
  • Where is the original version kept for optimistic locking?
  • What happens to pending changes when validation or persistence fails?

Our first reaction was that Rust made an otherwise ordinary API unnecessarily difficult. The more useful conclusion was the opposite: the ordinary API had been relying on behavior that was never clearly designed.

The answer became a mutation ledger shared by an object graph. It began as a way to make Rust mutation practical. It ended up becoming the mutation model for all seven TeaQL runtimes.

The Hidden Contract Behind a Setter

Consider a loaded order with two lines. Business code changes the shipping address, changes the quantity of one line twice, and removes the other line. The final save needs more than the current memory representation.

It needs to know that:

  • the order and both lines belong to one mutation unit;
  • the quantity has one final pending value, not two database updates;
  • the removed line is a deletion, not an object that happened to disappear;
  • each existing entity must be updated against the version originally loaded;
  • validation and automatic fixes must run before any database call;
  • a failed save must not make the in-memory graph look committed.

A runtime can infer some of this from snapshots. It can place interceptors in setters or proxies around entities. It can keep a session-level identity map. But each mechanism creates an implicit contract, and those contracts are easy to implement differently in different languages.

Rust exposed that ambiguity early. We could not depend on a web of invisible aliases and later ask the runtime to reconstruct what had happened. We needed a single explicit owner for mutation state.

One Root, One Pending Ledger

Every mutable entity in a working graph now owns or references the same EntityRoot. This is a technical root for pending mutation state, not a domain aggregate root and not a tenant or authorization context.

The language-neutral model is deliberately small:

EntityKey = (entityType, entityId)

EntityRoot
changes: Map<EntityKey, Map<FieldName, NewValue>>
originalVersions: Map<EntityKey, Version>
newKeys: Set<EntityKey>
deletedKeys: Set<EntityKey>

Generated update methods write into this root. If the same field is updated more than once before save, the entry is overwritten with the final value. The ledger records the state we intend to persist, so repeated writes are last-write-wins.

For example, the conceptual operation:

line.update_quantity(2);
line.update_quantity(3);

leaves one pending change for quantity = 3. It does not require two mutation commands and does not lose the fact that the field was deliberately changed.

When a parent and its children are loaded or assembled together, they adopt the same root. A change made through a child therefore belongs to the same ledger consumed when the graph is saved. The save receiver is merely an entry point; it is not the limit of mutation discovery.

This was the key move for Rust. Instead of trying to make every entity secretly observe every other entity, we made mutation state an explicit shared resource with a precise lifecycle.

Why the Ledger Stores New Values, but Not Old Values

An audit record often needs both old and new values. It is tempting to copy both into the mutation ledger, but that would duplicate state and introduce another consistency problem.

A loaded entity already retains its original field state. The root records the final intended values and the versions seen during hydration. When audit evidence requires an old/new pair, it can combine the loaded state with the ledger.

This separation also makes the purpose of the root clearer. It is a record of pending intent, not a second complete copy of the entity graph.

There are limits to that choice. If an application discards the original loaded state, it cannot later reconstruct old values from the root alone. That is intentional: a lightweight in-memory mutation ledger should not pretend to be a durable history system.

Checker and Fix Are Part of the Same Mutation

TeaQL applies two kinds of pre-persistence behavior. A checker rejects invalid state with a domain-facing error before the database sees it. A fix supplies context-derived values such as creation time, update time, or a required root reference.

Both operate on the pending mutation, not beside it.

That means a fix does not silently alter an entity through a special path. Its change is written to the same ledger as an application update. The eventual provider command, audit evidence, and diagnostic view all see the same final intent.

The ordering is important:

business updates
|
v
shared mutation ledger
|
v
checker and context-driven fix
|
v
audited save / provider commands
|
+--> success: adopt authoritative versions and clear committed entries
|
`--> failure: retain pending state

Database constraints remain a final safety boundary, but they should not be the first place an application learns that a required business field is missing. The ledger gives checker and fix logic a complete graph-wide view before the provider is called.

Failure Semantics Matter as Much as the Happy Path

Dirty tracking designs often focus on detecting a change and generating an update. The difficult part is deciding what the in-memory state means after something goes wrong.

If checking rejects the mutation, no provider call should occur and the pending ledger should remain available for correction. If the provider fails, the runtime must not clear the ledger or claim that the new version was committed. On success, committed entries are cleared and authoritative state—especially generated identifiers and versions—is adopted.

These rules make failure observable and retry behavior understandable. They also create useful boundaries for future diagnostics. A system can distinguish:

  • what business code intended to change;
  • what checker or fix logic added;
  • what command was submitted;
  • whether the command committed or failed.

That is already valuable without turning the design into a permanent event log.

This Is Not Event Sourcing

The word “ledger” can suggest event sourcing, but this design has a different job.

An event-sourced system normally persists an ordered sequence of domain events as its source of truth. Our mutation ledger is transient pending state. It may collapse multiple assignments to the same field into one final value, and it is cleared after a successful save. Durable audit records can be produced from it, but durability and replay are not properties of the ledger itself.

It also differs from conventional ORM dirty checking. Snapshot comparison asks, “What is different now?” The ledger records, “What does this graph intend to change?” The difference becomes important for deletions, optimistic versions, context-driven fixes, failure handling, and cross-entity operations.

Rust Was the Design Review

There is a recurring lesson in cross-language framework work: a feature that is easy to hide in one language may still be poorly specified.

Garbage collection, reference semantics, dynamic interception, or framework proxies can make a convenient API possible without forcing its ownership model into the open. Rust removes many of those escape routes. That can feel like friction, but the friction is information. It tells us where the architecture depends on an unnamed owner, an implicit lifetime, or an ambiguous side effect.

In this case, Rust forced us to identify the real unit of mutation. It was not one entity and it was not whichever object happened to receive save. It was the set of pending changes attached to a shared object graph.

Once that was explicit, the design became easier to explain, test, and port. The surprising benefit was not merely that the Rust runtime worked. The other runtimes acquired a stronger contract too.

From One Rust Problem to Seven Conforming Runtimes

TeaQL now applies the same mutation-ledger semantics in Rust, Java, Python, Go, .NET, Swift, and TypeScript. The APIs remain idiomatic to each language, but the observable behavior is shared: graph-wide roots, final field values, original versions, new/delete classification, checker-before-provider behavior, and clear success and failure transitions.

We retain executable cross-language evidence in the TeaQL conformance repository, rather than treating matching documentation as proof. The current fixture constructs or loads a parent with children, mutates both entity types, updates one field twice, marks a child for deletion, and saves through the ordinary audited path. It checks the ledger before save, the provider commands during save, and ledger state after rejection or success.

The implementation began in TeaQL Rust. The other six implementations—Java, Python, Go, .NET, Swift, and TypeScript—then adopted the same design.

What started as a solution to Rust's mutation constraints became one of the clearest pieces of our cross-language runtime architecture. Rust did not merely make us work harder. It made the hidden contract visible—and once visible, the contract could finally become consistent.