Skip to content

Option 3: Null-contract proxy design for ModelReaderWriter#59318

Draft
m-redding wants to merge 37 commits into
Azure:mainfrom
m-redding:proxy-rw-opt-3
Draft

Option 3: Null-contract proxy design for ModelReaderWriter#59318
m-redding wants to merge 37 commits into
Azure:mainfrom
m-redding:proxy-rw-opt-3

Conversation

@m-redding
Copy link
Copy Markdown
Member

Option 3: Null-contract proxy design (no DiscriminatorRouter)

This PR explores the simplest alternative design for the ModelReaderWriter proxy/routing feature.

Comparison of all three approaches

Aspect Option 1 (PR #59083) Option 2 (PR #59313) Option 3 (this PR)
Core type ModelProxy (generic, IS-A model) DiscriminatorRouter (non-generic abstract class) No new type
Registration AddProxy AddProxy + AddDiscriminatorRouter AddProxy only
Write resolution Last registered proxy Last registered proxy Last in list
Read resolution Routers then proxy then model Routers (FIFO, CanHandle) then proxy then model List (FIFO), first non-null Create wins, then model
Discriminator pattern Explicit CanHandle methods Explicit CanHandle + Create methods Convention: return null from Create if can't handle
New public types ModelProxy DiscriminatorRouter None
Generic variance Blocked (invariance) Solved (non-generic router) N/A (no router type)

API Surface (Option 3)

// Registration - both write and read use the same list
options.AddProxy<T>(IPersistableModel<T> proxy);
options.AddProxy<T>(IJsonModel<T> proxy);

// Resolution (write)
options.ResolveProxy<T>(IPersistableModel<T> model);  // returns last in list
options.ResolveProxy<T>(IJsonModel<T> model);          // returns last in list

The null contract

The discriminator case is handled purely by convention:

  • Register multiple proxies via AddProxy
  • On read, proxies are consulted first-to-last
  • Each proxy's Create returns null if it can't handle the data
  • The first proxy to return non-null wins
  • If all proxies return null, the model itself handles deserialization

Tradeoffs

Pros:

  • Simplest API - no new public types at all
  • No conceptual split between proxy and router
  • Single registration mechanism (AddProxy)
  • Easy to understand: return null = can't handle, return value = handled

Cons:

  • Null-returning from Create is a convention, not enforced by the type system
  • No explicit CanHandle - must call Create to discover if a proxy can handle
  • Less discoverable - return null isn't as self-documenting as overriding CanHandle
  • List semantics mean order matters (same as Option 2 routers)

m-nash and others added 30 commits May 7, 2026 15:06
- Fix invalid ??= on method return values in ModelReaderWriter.cs
- Remove IJsonModel.Create path in ReadInternal that broke JsonPatch seeding
- Update AvailabilitySetDataProxy to match new DeserializeComputeSku and
  AvailabilitySetData constructor signatures
- Fix CompareAvailabilitySetData merge conflict (restore nameAlwaysExists logic)
- Regenerate net9.0 API listing with proxy additions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Change proxy storage from Dictionary<Type, object> to
Dictionary<Type, List<object>> to support multiple proxies per type.
Last registered proxy has highest priority (consulted first).
Resolution iterates the chain last-to-first.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add 9 new tests for chain behavior (multi-proxy registration, resolution
  order, backward compat, shared chain in copied options, read/write with
  multiple proxies)
- Add proxy chain documentation section to ModelReaderWriter.md
- Add sample snippet and OutputModelProxyOverride class

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix 'persitable' -> 'persistable' in ModelReaderWriter.md
- Move json variable inside snippet region for Readme_Proxy_Chain

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Run Update-Snippets.ps1 to match expected output

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Read path: proxies are iterated last-to-first; returning null from Create
means 'decline' and the next proxy is tried. If all decline, the model
itself handles the read as terminal fallback.

Write path: unchanged (last-registered-wins).

- ReadWithChain<T> (internal) iterates proxy chain for read resolution
- ReadInternal now delegates to options.ReadWithChain instead of ResolveProxy
- Updated docs to describe read vs write chain behavior
- Added chain tests: SimpleModel, ChainProxy, DiscriminatorProxy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…d collection reader

Implements the Utf8JsonReader snapshot technique (same pattern used by STJ
for polymorphic deserialization) to support chain-of-responsibility on the
reader-based paths:

- JsonModelConverter.Read: now uses ReadWithChain per element instead of
  single TryGetProxy resolution. Always activates the model as terminal
  fallback.
- JsonCollectionReader: now uses ReadWithChain per collection element
  instead of resolving the proxy once for all elements.
- Added ReadWithChain(IJsonModel<T>, ref Utf8JsonReader) overload with
  reader-snapshot semantics (copy reader before attempt, restore on null).
- Added ReadWithChain(Type, IJsonModel<object>, ref Utf8JsonReader) for
  non-generic call sites.

Reference: https://github.com/dotnet/runtime - Utf8JsonReader copy pattern
used in System.Text.Json polymorphic converters.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tests verify the implicit contracts around ProxiedModel:
- ProxiedModel is set to the original model during proxy.Write
- ProxiedModel is set during proxy.Create(BinaryData)
- ProxiedModel is set during proxy.Create(ref Utf8JsonReader)
- ProxiedModel is null when no proxy exists (write path)
- ProxiedModel remains set after successful proxy read
- ProxiedModel is cleared when all proxies decline and model handles read
- ProxiedModel is correctly set for each proxy in chain (not leaked)
- ProxiedModel is correct for each proxy in reader-based chain
- ProxiedModel is set per-element in collection reads

Each test has descriptive error messages explaining the contract violation
if it fails.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update all test assertions to expect first-registered proxy to be
resolved first, matching the FIFO ordering convention (consistent with
STJ JsonSerializerOptions.Converters). Rename test methods from
'LastRegistered' to 'FirstRegistered' and fix discriminator pattern
test to register specific proxies before catch-all proxies.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add ModelProxy/ModelProxy<T> to public API surface files for all TFMs.
Also include JsonModelConverter simplification that was missed in
prior commit — uses TryGetProxy directly instead of ReadWithChain
since the CanHandle-based chain is now resolved at the options level.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Non-generic ModelProxy replaced with internal IModelProxy interface
- ModelProxy<T> explicitly implements IPersistableModel<T> (hidden from API)
- Protected abstract PersistableModel*Core methods for subclass overrides
- Public API surface: only ModelProxy<T> with CanHandle methods visible
- All 1044 MRW tests pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ethods

Remove the protected *Core pattern and explicit interface implementation.
ModelProxy<T> now just has public abstract Create/Write/GetFormatFromOptions
that implicitly satisfy IPersistableModel<T>. Much cleaner API surface.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ModelProxy<T> now only defines CanHandle methods. Subclasses implement
IPersistableModel<T> (or IJsonModel<T>) themselves directly. This keeps
the public API surface minimal - only the CanHandle contract is added.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…thods

Follows the standard .NET pattern (Stream, TextWriter, JsonConverter<T>)
where an abstract class implements an interface and surfaces the methods
as public abstract for subclasses to override. This provides compile-time
enforcement that all proxies are valid IPersistableModel<T> implementations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…responsibility

The converter was calling TryGetProxy which returns the first proxy in the
chain, then directly calling Create on it - bypassing the CanHandle checks.
This caused all reads through STJ to always use the first proxy regardless
of the data content.

Now it always creates the template model and passes it through ReadWithChain,
which properly walks the chain calling CanHandle(BinaryData) on each proxy
until one accepts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…eneric path

Covers testing gaps found from the JsonModelConverter bug fix:
- JsonModelConverter.Read with chain (second proxy handles after first declines)
- JsonModelConverter.Read discriminator pattern via STJ
- JsonModelConverter.Write with chain CanHandle
- ResolveProxy write path where first proxy declines via CanHandle(model)
- Non-generic ReadWithChain(Type, IJsonModel<object>, ref reader) overload
- Collection read with per-element chain discrimination

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Benchmarks comparing no-proxy, 1 proxy, and 10 proxies (last wins) for
both read and write paths. Results show:
- Chain walk overhead (1 vs 10 proxies): ~28ns write, ~8ns read
- Proxy presence overhead (0 vs 1): ~62ns write, ~34ns read
- All in nanosecond range — negligible for real-world use

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix chain-of-responsibility section: was 'last registered wins' / 'null return',
  now correctly describes FIFO ordering with CanHandle gating
- Swap snippet registration order to demonstrate first-registered = highest priority
- Update write path description: CanHandle(model) instead of unconditional last-wins
- Update read path description: CanHandle(BinaryData, options) instead of null-return

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both CanHandle overloads now default to true for consistency:
- CanHandle(T model) => true (write path)
- CanHandle(BinaryData, options) => true (read path)

The simple case (single proxy for a type) no longer requires implementing
CanHandle at all. Override to selectively decline in chain scenarios.

Removed redundant 'override => true' from all test proxies.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add 4 STJ read benchmarks: NoProxy, OneProxy, TenProxiesLastWins, AllDecline
  (exercises the Utf8JsonReader snapshot + BinaryData CanHandle path)
- Fix outdated comment in JsonCollectionReader: was 'restore on null (decline)',
  now correctly describes CanHandle-based chain

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
TryGetProxy returned a proxy instance without CanHandle checks, which was
incompatible with the chain-of-responsibility model. Replace with HasProxy<T>()
which is a simple boolean check for whether any proxies are registered.

Generated code pattern for nested model properties:
- HasProxy<T>() → true: route through ModelReaderWriter.Read<T>(BinaryData)
  which performs full chain resolution (CanHandle walk + fallback)
- HasProxy<T>() → false: use normal fast-path deserialization (no allocation)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both CanHandle overloads now take ModelReaderWriterOptions:
- CanHandle(T model, ModelReaderWriterOptions options)
- CanHandle(BinaryData data, ModelReaderWriterOptions options)

This provides consistent access to options (e.g., format) on both the
write and read gating paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… docs

- Convert all chain-walk for loops to foreach for readability
- Revert variable rename back to iJsonModel in JsonModelConverter
- Update IModelProxy doc comment to explain T-erasure requirement
  (ModelReaderWriter.ReadInternal and STJ both call with T=object)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
m-redding and others added 6 commits May 18, 2026 11:19
- Remove ModelProxy<T> and IModelProxy entirely
- Add DiscriminatorProxy<T> with virtual CanHandle(BinaryData) and CanHandle(ref Utf8JsonReader)
- Write path: register raw IPersistableModel<T> via AddProxy, first registered wins
- Read path: discriminator proxies via AddDiscriminatorProxy with CanHandle selection
- Update all tests to reflect new design
- Export updated API listings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ctor)

- DiscriminatorRouter<T> takes IJsonModel<T> or IPersistableModel<T> via ctor
- Router only has CanHandle logic; model does deserialization
- _proxies is now single-slot (last AddProxy wins), used for both read fallback and write
- _routers is FIFO chain for discriminator read path
- Read resolution: routers -> proxy fallback -> model itself
- Updated test helpers to use composition pattern

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…semantics

- Remove public HasProxy<T>() and HasProxy(Type) methods
- Add AddProxy<T>(IJsonModel<T>) overload matching PR 50024 API
- Make IsJsonModel internal on DiscriminatorRouter
- AddProxy is single-slot (last registered wins)
- Update tests to reflect last-wins semantics

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove generic type parameter from DiscriminatorRouter class
- Add virtual Create(ref Utf8JsonReader, options) and Create(BinaryData, options) returning object?
- Remove IDiscriminatorRouter.IsJsonModel property
- T is now only on AddDiscriminatorRouter<T> for lookup key purposes
- Simplify ReadWithChain to not branch on IsJsonModel
- Fix test helpers to implement Create directly instead of base(model) ctor

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Simplifies the proxy system to a single concept:
- AddProxy<T> adds proxies to a list per type
- Write: last proxy in list wins
- Read: iterate first-to-last, first Create that returns non-null wins
- Returning null from Create = 'I cannot handle this, try next'
- Discriminator routing is convention-based (return null if discriminator doesn't match)
- No separate DiscriminatorRouter class needed

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@@ -78,7 +78,9 @@ private static void ReadJsonCollection(
}
else if (jsonModel is not null)
{
collectionWrapper.AddItem(jsonModel!.Create(ref reader, options), propertyName);
// Chain-of-responsibility per element: snapshot reader, try proxies via CanHandle,
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO comment is outdated

return model;
}

// Last in list wins for write
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO - honestly not 100% sure exactly what we want for this, if someone wants to just add some proxies for reading only, I guess they would need to manually delegate to the base model in the write path?

…ntee

Changed write resolution from last-wins to FIFO (first registered wins),
matching read resolution order. Both paths now use consistent first-wins
semantics. The reader path already snapshots before calling each proxy's
Create; updated xmldoc to make this guarantee explicit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants