Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
746 changes: 746 additions & 0 deletions docs/plans/completed/serializer-responsibility-redesign.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/release-notes/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Releases with new features, breaking changes, or bug fixes.

| Version | Date | Highlights |
|---------|------|------------|
| [v0.22.0](v0.22.0.md) | 2026-03-20 | Serializer responsibility redesign: converter-level reference handling |
| [v0.21.3](v0.21.3.md) | 2026-03-20 | Fix record deserialization with `$id`/`$ref` metadata |
| [v0.21.2](v0.21.2.md) | 2026-03-08 | Trimming-safe factory registration for all factory types |
| [v0.21.1](v0.21.1.md) | 2026-03-08 | Fix factories trimmed when first overload has no callers |
Expand Down Expand Up @@ -46,6 +47,7 @@ Releases with new features, breaking changes, or bug fixes.

## All Releases

- [v0.22.0](v0.22.0.md) - 2026-03-20 - Serializer responsibility redesign
- [v0.21.3](v0.21.3.md) - 2026-03-20 - Fix record deserialization with reference metadata
- [v0.21.2](v0.21.2.md) - 2026-03-08 - Trimming-safe factory registration for all factory types
- [v0.21.1](v0.21.1.md) - 2026-03-08 - Fix [DynamicDependency] trimming on multi-overload factories
Expand Down
2 changes: 1 addition & 1 deletion docs/release-notes/v0.21.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ layout: default
title: "v0.21.3"
description: "Release notes for Neatoo RemoteFactory v0.21.3"
parent: Release Notes
nav_order: 1
nav_order: 2
---

# v0.21.3 - Fix Record Deserialization with Reference Metadata
Expand Down
75 changes: 75 additions & 0 deletions docs/release-notes/v0.22.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
layout: default
title: "v0.22.0"
description: "Release notes for Neatoo RemoteFactory v0.22.0"
parent: Release Notes
nav_order: 1
---

# v0.22.0 - Serializer Responsibility Redesign

**Release Date:** 2026-03-20
**NuGet:** [Neatoo.RemoteFactory 0.22.0](https://nuget.org/packages/Neatoo.RemoteFactory/0.22.0)

## Overview

Redesigns reference handling so converters access the resolver directly via `NeatooReferenceResolver.Current` (a static `AsyncLocal` accessor) instead of through `JsonSerializerOptions.ReferenceHandler`. Removes the dual-options split (`PlainOptions`, `IsNeatooType()`) introduced in v0.21.3, which broke downstream Neatoo converters. Returns to a single `JsonSerializerOptions` instance with no `ReferenceHandler` set.

## What's New

- **`NeatooReferenceResolver.Current` static accessor.** Public getter, internal setter. Converters that need reference tracking access the resolver directly via this `AsyncLocal` property instead of through `options.ReferenceHandler.CreateResolver()`. Returns `null` when no serialization operation is in progress.

## Breaking Changes

- **`options.ReferenceHandler` is no longer set on `JsonSerializerOptions`.** Downstream converters (including Neatoo's `NeatooBaseJsonTypeConverter`) that access `options.ReferenceHandler.CreateResolver()` will get `NullReferenceException`. Migrate to `NeatooReferenceResolver.Current`.

- **`NeatooReferenceHandler` class deleted.** The `NeatooReferenceHandler` class that bridged `JsonSerializerOptions.ReferenceHandler` to the `AsyncLocal<ReferenceResolver>` is removed entirely.

- **`IsNeatooType()` and `PlainOptions` removed from `NeatooJsonSerializer`.** The dual-options approach from v0.21.3 is deleted. A single `JsonSerializerOptions` instance is used for all types.

## Bug Fixes

- **Fixed v0.21.3's broken dual-options approach.** The `IsNeatooType()` type classification assumed Neatoo entities implement `IOrdinalSerializable`, but that interface is only generated for `[Factory]`-decorated types, not Neatoo's own base classes (`ValidateBase`, `EntityBase`). This caused 88 Neatoo serialization tests to fail with `NullReferenceException` when the `PlainOptions` path (no `ReferenceHandler`) was selected for Neatoo types that the classifier didn't recognize.

- **Fixed resolver scoping bug.** The v0.21.3 code placed `using var rr = new NeatooReferenceResolver()` inside an `if` block, causing the resolver to be disposed before serialization completed. The new `try/finally` pattern ensures the resolver stays alive for the entire serialize/deserialize operation.

- **Removed dead code in `NeatooInterfaceJsonTypeConverter.Read()`.** The `var id = string.Empty` variable was never assigned from the JSON stream, so the `AddReference` call was unreachable. Deleted both the variable and the dead guard block.

## Migration Guide

### Downstream converters (Neatoo and custom converters)

If your custom `JsonConverter` accesses `options.ReferenceHandler.CreateResolver()`, migrate to the `AsyncLocal` accessor:

**Before:**
```csharp
var resolver = options.ReferenceHandler!.CreateResolver();
var id = resolver.GetReference(value, out var alreadyExists);
```

**After:**
```csharp
var resolver = NeatooReferenceResolver.Current;
if (resolver != null)
{
var id = resolver.GetReference(value, out var alreadyExists);
// ... reference handling logic
}
```

The null check is required because `NeatooReferenceResolver.Current` is `null` when serialization is not in progress (e.g., bare STJ usage in unit tests).

### RemoteFactory consumers (no factory code changes)

No changes required to factory classes, attributes, or DI registration. The wire format is unchanged from v0.21.3 -- plain records/DTOs continue to serialize without `$id`/`$ref` metadata, and Neatoo types continue to use reference handling via their custom converters.

## Commits

- Add `NeatooReferenceResolver.Current` static `AsyncLocal` accessor (public getter, internal setter)
- Remove `PlainOptions`, `IsNeatooType()`, and `ReferenceHandler` property from `NeatooJsonSerializer`; single `JsonSerializerOptions` with `try/finally` resolver lifecycle
- Remove dead reference handler code from `NeatooInterfaceJsonTypeConverter.Read()`
- Delete `NeatooReferenceHandler.cs`
- Update stale `PlainOptions` comment in `InterfaceFactoryRecordSerializationTests.cs`

**Supersedes:** [v0.21.3](v0.21.3.md) dual-options approach
**Related:** [Serializer Responsibility Redesign Plan](../plans/serializer-responsibility-redesign.md)
6 changes: 3 additions & 3 deletions docs/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,11 @@ The first occurrence gets a `$id`, and subsequent references use `$ref` pointers

This handles circular references and ensures the deserialized graph has the same object identity as the original.

### Scope: Neatoo Types Only
### Scope: Converter-Level, Not Serializer-Level

Reference preservation (`$id`/`$ref` metadata) applies only to Neatoo types -- classes and records decorated with `[Factory]` that implement `IOrdinalSerializable`, and interface/abstract types registered in the factory assembly. Plain records and DTOs returned from Interface Factory methods are serialized without reference handling, using standard System.Text.Json behavior. This means plain records/DTOs do not support circular references, but they do support parameterized constructors (primary constructors) without issue.
Reference preservation (`$id`/`$ref` metadata) is a converter-level concern. RemoteFactory's `JsonSerializerOptions` has no `ReferenceHandler` set -- System.Text.Json serializes types natively unless a custom converter intervenes. Neatoo's custom converters access a per-operation `NeatooReferenceResolver` via a static `AsyncLocal` accessor (`NeatooReferenceResolver.Current`) to add `$id`/`$ref` metadata for Neatoo types. Plain records and DTOs have no custom converter, so they are serialized by System.Text.Json without reference metadata. This means plain records/DTOs do not support circular references, but they do support parameterized constructors (primary constructors) without issue.

Do not mix Neatoo domain types with plain records in the same return type. A record containing an `IValidateBase` property creates a serialization mismatch -- the record is serialized without reference handling, but the embedded Neatoo type expects it. Use either pure Neatoo types or pure records/DTOs.
Do not mix Neatoo domain types with plain records in the same return type. A record containing an `IValidateBase` property creates a serialization mismatch -- the record's native STJ serialization does not produce reference metadata, but the embedded Neatoo type's converter expects the resolver to be tracking references. Use either pure Neatoo types or pure records/DTOs.

## Debugging

Expand Down
52 changes: 52 additions & 0 deletions docs/todos/completed/isneatootype-missing-validatebase-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# PlainOptions/Options Split Breaks Downstream Converters

**Status:** Open
**Priority:** High
**Created:** 2026-03-20

---

## Problem

`NeatooJsonSerializer` in 0.21.3 introduced a `PlainOptions` code path (no `ReferenceHandler`) alongside the existing `Options` path (with `ReferenceHandler`). The `IsNeatooType()` method decides which path to use, but it only recognizes `IOrdinalSerializable` and interface/abstract types in `ServiceAssemblies`.

Both option sets share the same converter factories. So when a downstream consumer (like Neatoo) registers custom converters that need `ReferenceHandler`, those converters crash with `NullReferenceException` on the `PlainOptions` path — because `PlainOptions` has the converters but not the `ReferenceHandler` they depend on.

In 0.21.0, `Serialize` always used `Options` (with `ReferenceHandler`). This worked because `ReferenceHandler` is harmless for types that don't use `$id`/`$ref`.

### Root cause

The `PlainOptions` optimization assumes RemoteFactory knows which types need `ReferenceHandler`. It doesn't account for downstream converters that also need it. RemoteFactory shouldn't be making this decision — it's the converter's concern.

### The fix

Remove the `PlainOptions`/`Options` split. Always use `Options` (with `ReferenceHandler`) like 0.21.0 did. The overhead of an unused `ReferenceResolver` is negligible, and the split breaks extensibility.

Alternatively, if the optimization is worth keeping: set `ReferenceHandler` on both option sets and always initialize the resolver before serialization. That way downstream converters work regardless of which path is selected.

### Reproduction

In Neatoo repo: update RemoteFactory to 0.21.3, run `dotnet test src/Neatoo.sln` — 88 serialization tests fail with `NullReferenceException` at `NeatooBaseJsonTypeConverter.Write()` line 328:

```
options.ReferenceHandler.CreateResolver().GetReference(value, out var alreadyExists)
```

Stack: `NeatooJsonSerializer.Serialize` → selects `PlainOptions` → converter runs → `options.ReferenceHandler` is null.

---

## Tasks

- [ ] Fix PlainOptions to include ReferenceHandler (or remove the split)
- [ ] Verify Neatoo tests pass with updated package

---

## Progress Log

### 2026-03-20
- Discovered while upgrading Neatoo from RemoteFactory 0.21.0 to 0.21.3
- 88 Neatoo serialization tests fail with NullReferenceException
- Root cause: `PlainOptions` has converter factories but no `ReferenceHandler`
- Not a type-detection issue — the split itself is the problem
Loading
Loading