|
| 1 | +--- |
| 2 | +summary: `resolve_provider` is not an interception seam and `Container` subclassing is not a supported way to intercept resolution — since the compiled resolvers landed it has only ever seen top-level calls, so inlining it into `resolve()` narrows nothing that worked. |
| 3 | +--- |
| 4 | + |
| 5 | +# `resolve_provider` is not an interception seam |
| 6 | + |
| 7 | +**Decision:** `Container.resolve_provider` is an entry point, not a hook. Overriding |
| 8 | +it in a `Container` subclass is not a supported way to observe or intercept |
| 9 | +resolution, and the resolve path is free to bypass it. This licenses inlining its |
| 10 | +body into `Container.resolve`. `find_container` is **not** affected and remains a |
| 11 | +blessed extension point. |
| 12 | + |
| 13 | +## Context |
| 14 | + |
| 15 | +Inlining `find_provider` + `resolve_provider` into `Container.resolve` measures |
| 16 | +**-19% (~38 ns) on every by-type resolve** — the path every `@inject` marker and |
| 17 | +framework integration takes. It was deferred partly because the same structural |
| 18 | +objection that killed |
| 19 | +[`2026-08-01-scope-map-inline-declined.md`](2026-08-01-scope-map-inline-declined.md) |
| 20 | +appears to apply: `resolve_provider` is a public method on a subclassable class, |
| 21 | +and `Container.__init__` builds children via `self.__class__`, so a subclass rides |
| 22 | +the whole tree. Bypassing it would mean a subclass's override no longer runs for |
| 23 | +by-type calls. |
| 24 | + |
| 25 | +## Decision & rationale |
| 26 | + |
| 27 | +**It is already not a seam, and that is measurable rather than arguable.** Since |
| 28 | +the compiled resolvers shipped in 2.29.0, a resolver calls its dependencies' |
| 29 | +resolvers *directly*; nothing routes a nested node through `resolve_provider`. Its |
| 30 | +only callers are `resolve()`, `resolve_dependency()`, and the cycle back-edge |
| 31 | +thunk in `ProvidersRegistry.resolver_for`. Demonstrated on `main` before this |
| 32 | +change: a `Container` subclass overriding `resolve_provider` and resolving a |
| 33 | +**4-node chain** records exactly **1** call — the top-level one. An override has |
| 34 | +never seen the graph. Inlining removes one of the three top-level call sites; the |
| 35 | +by-reference and marker-dispatch entries still route through it. |
| 36 | + |
| 37 | +So the thing the objection protects does not exist. What a subclass can still do |
| 38 | +after this change is instrument the *entry points* by overriding `resolve` and |
| 39 | +`resolve_provider` — which is what someone wanting that would actually reach for, |
| 40 | +and it keeps working. |
| 41 | + |
| 42 | +**This is deliberately narrower than the `_scope_map` ruling, which stands.** |
| 43 | +`find_container` is consulted on every cross-scope hop, and the container it |
| 44 | +returns owns the cached instance and runs its finalizer — bypassing an override |
| 45 | +there silently relocates lifecycle ownership, which is a bug, not a missed hook. |
| 46 | +`resolve_provider` has no such consequence: bypassing an override loses |
| 47 | +observation, not correctness. The two are not the same call and are not being |
| 48 | +ruled on together. |
| 49 | + |
| 50 | +**Field check.** An audit of all 13 sibling integration wheels found zero |
| 51 | +`Container` subclasses and zero `resolve_provider` overrides. `Container` |
| 52 | +subclassing is not documented as an extension point anywhere in `architecture/` or |
| 53 | +`docs/`. |
| 54 | + |
| 55 | +**Accepted costs**, disclosed rather than discovered later: |
| 56 | + |
| 57 | +- A genuinely duplicated ~8-line body (closed check, memo hit, `resolver_for` |
| 58 | + fallback, resolver call, `RecursionError` conversion) now lives in both `resolve` |
| 59 | + and `resolve_provider` and must be edited in lockstep. This is the real price and |
| 60 | + it is permanent. |
| 61 | +- An exception raised through `resolve()` loses one traceback frame (5 → 4; |
| 62 | + `resolve_provider` no longer appears). Verified directly. |
| 63 | +- Recursion headroom moves by one frame in the benign direction. |
| 64 | + |
| 65 | +**Consequence worth naming.** Together with |
| 66 | +[`2026-07-30-debug-resolution-tracing-declined.md`](2026-07-30-debug-resolution-tracing-declined.md), |
| 67 | +modern-di offers no built-in way to observe *per-node* resolution. That was already |
| 68 | +true — the compiled resolvers removed the last interior call — and this decision |
| 69 | +records it rather than creating it. Entry-point instrumentation remains available |
| 70 | +by overriding both public entry methods. |
| 71 | + |
| 72 | +## Revisit trigger |
| 73 | + |
| 74 | +A concrete request for per-resolve interception from a real integration or user. |
| 75 | +The answer then is a designed seam with a stated contract — not a re-blessing of |
| 76 | +subclass overrides, which the compiled resolve path stopped honouring in 2.29.0. |
0 commit comments