A component can compile in a shared assembly and still be impossible to construct in one of the applications that renders it.
That is easy to miss when browser and .NET MAUI hosts reuse the same Blazor pages. The feature appears to exist once, but every host still owns a dependency-injection container, startup path, and lifetime model.
A recent committed fix made this concrete. A shared page gained a required workflow dependency. Native hosts registered it in their common bootstrap path. A browser host rendered the same page through another composition root, where the dependency was unknown. The page failed before it could render.
The lasting lesson was not “remember one more registration.” It was that shared UI creates a contract for every host capable of rendering it.
Sharing an assembly does not share a container
Compile-time reuse asks whether multiple projects can reference a component. Runtime composition asks whether each host can construct it and satisfy the required behaviour.
A native host may call a wrapper bootstrap, while a browser host calls a web-oriented startup path. Both load the same Razor component without building the same service graph.
This creates a useful review rule:
A required injection on a shared component is a required contract on every composition root that can render it.
The host inventory matters. “All native apps use this bootstrap” is not enough if a browser, desktop, test, or preview host renders the page by another route.
Do not repair a missing host capability with optionality
When one host cannot provide a dependency, making it nullable can feel pragmatic. That is reasonable for an optional enhancement such as haptic feedback. It is dangerous when the dependency enforces a workflow invariant.
In the reviewed change, the workflow had to know whether the runtime context remained the same while an operation was in flight. Native applications could change that context at runtime. The browser host could not; its context was fixed for the lifetime of the application.
Optionality would have turned “this host represents stability differently” into “this host may skip the safety check.” Those are not equivalent.
The better question was: what is the smallest capability the workflow genuinely needs?
It needed only three facts:
- a revision that identifies the current context;
- whether the context is ready for work;
- a signal that the context has changed.
It did not need the native host’s full navigation, storage, or UI service. Once the smaller contract was explicit, both hosts could tell the truth.
Fixed and live adapters can satisfy one invariant
The browser implementation could be fixed:
- its revision never changes;
- it is ready once the application starts;
- its change signal never fires.
The native implementation could be live:
- its revision comes from the runtime context;
- readiness reflects whether a selection exists;
- its signal follows genuine context changes.
They behave differently while preserving the same promise: work that begins in one context must not silently commit after that context changes. The abstraction names the smallest invariant every platform can honour instead of erasing platform differences.
Register the contract where the hosts actually enter
The original registration lived in a bootstrap shared by the native applications. It looked central because several hosts called it, but it was not central to every host rendering the page.
The fix moved the shared workflow contract into the two actual composition paths:
- the browser path registered the fixed adapter;
- the native path registered the live adapter;
- both registered the required shared workflow;
- the native-only wrapper stopped being the accidental authority.
The duplicated registrations document a real split: same workflow, different host truth. Common registration still suits identical services; host-specific adapters should stay visible where the host chooses them.
Test behaviour and composition
Registration tests are useful because this failure happens before component behaviour can begin.
A focused matrix can cover:
- the browser composition root contains the shared workflow and fixed adapter;
- the native composition root contains the shared workflow and live adapter;
- the old platform-only bootstrap is not a hidden third authority;
- the workflow succeeds when a host has a genuinely fixed context;
- the workflow rejects or repairs a completion when a live context changes mid-operation.
Descriptor checks catch missing registrations quickly. Behaviour tests prove the adapters preserve the invariant. Neither activates the complete production host. For high-value pages, add a smoke test that builds each host’s real provider and constructs the page boundary; this can expose lifetime or replacement mistakes that descriptor checks miss.
The trade-off
The narrow-contract approach costs an interface, two adapters, explicit registrations, and a wider test matrix.
The alternatives are cheaper only locally:
- a broad native service leaks platform concerns into shared workflow code;
- an optional dependency can silently disable a safety invariant;
- one oversized bootstrap hides which host owns which behaviour;
- ad hoc checks drift without a named contract.
I prefer small, intentional duplication at composition boundaries over implicit behavioural differences inside the workflow.
A practical review checklist
When a shared Blazor component gains a required dependency, ask:
- Which browser, native, desktop, test, and preview hosts can render it?
- Through which composition root does each host enter?
- Is the dependency a capability the workflow needs, or a large platform service it happened to receive?
- Can every host provide a truthful, non-optional implementation?
- Are host differences visible in adapters rather than null branches?
- Do tests exercise registration and behaviour for every root?
- Would a full-provider smoke test catch lifetime or replacement errors?
Shared UI is not evidence of shared runtime topology. Treat every required dependency as a cross-host design decision, and the composition roots become part of the component’s real API.
Which shared page in your system is quietly assuming it has only one host?
Top comments (1)
This is a strong distinction: shared UI creates a shared behavioral contract, not a shared runtime topology.
I especially like the decision to avoid making the dependency optional and instead reduce it to the smallest invariant every host can truthfully implement. That keeps platform differences visible at the composition boundary rather than leaking null checks and host-specific assumptions into shared workflow code.
The point about testing both registration and behavior is important too. A service being present in the container proves constructability, but only behavior tests prove that each adapter preserves the same invariant.