We wanted one test that proved one thing. When a status field goes from pending to settled, the changeset says exactly that.
Writing it took a Django test database, a migration run, two saved instances, and a fixture nobody wanted to own.
Then a payments integration asked for the same diff over something that was never a model, a webhook payload compared against the state we had stored for that transaction. Same question, same output shape. Our diff logic could not answer it, since it only knew how to walk a model instance.
So we cut it apart. The diff core in our open-source project model-audit now takes two plain mappings and returns a typed changeset: added, removed, changed, with old and new per field. There is no Django import anywhere in it. The framework layer's whole job is to produce those two mappings from model state and hand them over.
What that cost us, honestly: the boundary got harder. A deferred field is not the same as a field that is absent. A foreign key has to arrive as an id, not a lazy object. A None value and a missing key mean different things, and now something has to decide which one it is. All of that used to sit implicit inside the ORM walk. It is now an explicit decision at the edge, written down once, where someone can argue with it.
What we got back is the part we would defend in review: the diff tests are dictionaries in, assertions out. No database, no migrations, no app registry. And when another service needs the same changeset over an API response or a parsed file, it imports the core and skips the framework entirely.
The rule we kept: the framework layer feeds data in, it stays out of the comparison.
You probably draw that line somewhere else in your own code, and the thing worth naming is the last piece you would still let the framework reach into.