skip to content

A mixin's method stops running after a class's bases are reordered in a clinical-lab result loader. How do you diagnose it?

level: seniorimportance: should knowfreq 40%

answer

  1. Nothing raised, behaviour just went missing
  2. Suspect resolution order before the method body
  3. Ask which class actually supplies it
  4. Print __mro__ and the method's __qualname__
  5. Guard with a behaviour test, not the convention

basics

~20 s

Print the class's __mro__ and the method's __qualname__ to see which class actually supplies it. After the reorder the concrete base precedes the mixin and wins lookup, so the override never runs and nothing raises — the behaviour just reverts.

solid answer

~40 s

Confirm it at the class, not in the call path: `[c.__name__ for c in ResultLoader.__mro__]` shows the search order and `ResultLoader.fetch.__qualname__` names the class supplying the implementation. If that prints `Loader.fetch` rather than `CacheMixin.fetch`, the mixin was pushed behind the concrete base and is shadowed — results stay correct, only the caching disappears, which is why a lab loader surfaced it as latency and upstream load at a 1,200-request-per-minute peak instead of as an error. Fix the order so mixins precede the base, then check the second half: the mixin must call `super()` or it replaces the base instead of wrapping it. Prevent the recurrence with a behaviour-level test that asserts the second fetch hits the cache; asserting on the order alone tests the convention, not the outcome.

code

python · 20 lines
python
class Loader:
    def fetch(self, sample_id):
        return {"id": sample_id, "from_cache": False}


class CacheMixin:
    _cache = {}

    def fetch(self, sample_id):
        if sample_id not in self._cache:
            self._cache[sample_id] = super().fetch(sample_id)
        return self._cache[sample_id]


class ResultLoader(Loader, CacheMixin):
    pass


print([c.__name__ for c in ResultLoader.__mro__])
print(ResultLoader.fetch.__qualname__)

go deeper

for a junior

Take away the mechanics: bases are searched left to right, so a mixin behind the concrete base is shadowed, and you can see that by printing the class's resolution order rather than by reading the code.

for a middle

Be able to run the diagnosis end to end — order, then owning class, then whether the winner delegates — and to explain why nothing raised when the override was lost.

for a senior

An interviewer expects the production instincts: recognising a silent behavioural revert, guarding it with a behaviour-level test rather than the convention, and adding a metric so a degraded-but-correct path cannot hide.

for a principal

Own the systemic point: layering that lives in a bases tuple has no call site and can be lost by a formatting change, so decide where that fragility is acceptable and where the layer should be an explicit collaborator instead.

## Start from the symptom class, because it tells you where to look A mixin that stops running produces a *silent behavioural revert*, not an exception. Both the mixin and the concrete base define a method with the same name and a compatible signature, so once the base is found first, every call succeeds and returns a correct-looking result. In a result loader that means the data stays right and only the caching layer vanishes, which shows up as: - latency, - extra load on the upstream analyser system - and a cache-hit ratio flat at zero — at a 1,200-request-per-minute peak that is an operational incident with a completely clean error log. When a capability disappears without a traceback, suspect resolution order before suspecting the method body. ## Diagnose at the class object, not by adding prints inside the method Two expressions settle it. - `[c.__name__ for c in ResultLoader.__mro__]` prints the exact left-to-right search order; if the concrete `Loader` appears before `CacheMixin`, the mixin cannot win any name they share. - `ResultLoader.fetch.__qualname__` prints the owning class of the implementation actually found, so `Loader.fetch` is the confession. Adding a print statement inside the mixin's method proves the same thing more slowly, and in a running service it proves it only for the paths you happened to exercise. ## Then confirm the cause rather than assuming it A shadowed mixin is the common explanation, but two neighbours look identical from the outside. 1. First, the winning implementation may be reached but may not delegate: if the mixin is first and simply lacks a `super()` call, the base's work stops instead of the mixin's. 2. Second, someone may have added a *new* base rather than reordered the existing ones, which pushes the mixin rightwards as a side effect of a change that looked purely additive. Comparing the printed order against the previous release, or against the same expression on a known-good deployment, distinguishes them immediately. ## How the reorder happens in the first place Almost never on purpose. - A bases tuple gets alphabetised during a tidy-up, - a merge resolves two branches that each added a base, - an automatic formatter or a refactor rewrites the class statement, - or someone moves the concrete base first because that is how single inheritance reads. None of those changes look behavioural in review — the diff shows two identifiers swapped — which is exactly why reviewers wave them through. ## Fix, then make the fix stick 1. Restore *mixins before the concrete base*, and verify the mixin's own delegation while you are there. 2. Then write the guard as a behaviour test: call `fetch` twice and assert the second call did not reach the upstream source, or assert the recorded hit count. That test fails for every way the caching can be lost, including a future refactor that removes the mixin entirely. A test that only asserts `CacheMixin` precedes `Loader` in `__mro__` is weaker: it pins the convention rather than the outcome, and it passes happily against a mixin whose `super()` call was deleted. Pin the order too if you like, but never instead. ## Make it observable so it cannot hide for a week The reason this cost real latency is that a cache silently at zero hit rate looks exactly like a cache that is merely cold. A counter for hits and misses, exported and alerted on, turns a silent revert into a visible one. The same argument applies to any wrapping mixin whose absence degrades rather than breaks: if the only evidence of the layer working is that the system feels fast, its disappearance is undetectable by construction. ## The design lesson behind the incident Behaviour that depends on the order of identifiers in a bases tuple is behaviour with no call site. Nothing at the point of use says the fetch is cached; the wiring lives in a class statement that a formatter may rewrite. Where that layering is load-bearing, the strong alternative is to stop expressing it as inheritance order and to hold the caching object explicitly — a collaborator whose presence is visible where it is constructed and cannot be lost by swapping two names.

  • The order is right but the behaviour is still missing. What next?
    Check whether the winning implementation delegates. A mixin placed first but lacking a `super()` call replaces the base rather than wrapping it, so the loss moves to the other side of the chain. Read the method body, and confirm with the base's method instrumented or with a small composed test class. The third possibility is that a newly added base pushed the mixin rightwards without anyone reordering anything.
  • What test would you add so this cannot regress?
    A behaviour test: fetch the same identifier twice against a stub source that counts calls, and assert the source was consulted once. That fails for every route to losing the layer — reordering, a removed base, a deleted `super()` call. Pinning the position in `__mro__` as well is cheap, but on its own it tests the convention rather than the outcome.
  • Why did this cost latency rather than raise an alert?
    Because both implementations return valid results and only one of them caches. The service stayed correct and simply did more upstream work per request, so at peak it showed as response time and load rather than as errors. A hit/miss counter with an alert on a sustained zero hit rate makes the same failure visible in minutes instead of after a capacity review.

saying these in an interview costs you the question

  • Starts debugging inside the method body instead of the class
  • Assumes a shadowed override would raise an error
  • Fixes the order without checking the mixin still calls super()
  • Guards the fix by asserting the MRO instead of the behaviour
  • Treats a bases-tuple reorder as a non-behavioural diff
  • Leaves a caching layer with no hit or miss metric

context