The most useful part of our model-audit package has no idea Django exists.
It is a field-diff engine. Two mappings go in, a typed changeset comes out: which fields changed, from what, to what. That's it. No model class, no ORM query, no import from the framework anywhere in that layer.
The Django side does the boring half — it knows how to get the previous state of a row and the new one, and it hands both to the core as plain data.
The reason this matters is not elegance. It's what your tests have to boot.
When change-tracking logic lives inside a model method or a signal handler, testing "did we detect this edit correctly" means a database, migrations, fixtures, and a save cycle. You end up testing the ORM to verify your own business rule. So people write three cases instead of thirty, and the interesting ones — a field set to null, a decimal that changed representation but not value, a JSON blob with reordered keys — never get written.
With the core separated, those are dictionary-in, changeset-out tests that run in milliseconds. And the same engine handles a dict from an API payload or an imported row, because it was never coupled to a table in the first place.
We keep it open at github.com/shipmindlabs/model-audit if you want to look at where that seam sits.
What's the last piece of logic you pulled out of a framework class to make it testable — and what finally forced the move?