Disclaimer: This is generated by LLM but fully reviewed and guided by me (and some edits)
Background and motivation
BinaryFormatter was fully removed from .NET 9 (the APIs remain but always throw PlatformNotSupportedException). An unsupported compatibility package exists for temporary use, but the expectation is that all consumers migrate to safer serialization alternatives.
The primary customers for this proposal are organizations planning and executing migration away from BinaryFormatter — and more broadly, away from patterns that rely on Type.GetType-based dynamic type resolution. Before they can migrate, they need visibility into which types are being resolved at runtime during deserialization and other dynamic dispatch scenarios. Without instrumentation, teams must rely on code search and guesswork, which is error-prone for large codebases and third-party dependencies.
Why a new NuGet package?
Migration tooling must reach customers on existing servicing branches, including .NET 6/7/8 LTS and even .NET Framework 4.x. These branches do not accept new public API surface. The proposed design introduces a new NuGet package System.Runtime.Serialization.Instrumentation that:
- Ships independently of the runtime — can be released on its own cadence, decoupled from .NET release trains.
- Targets
netstandard2.0 — this single TFM covers .NET Framework 4.6.1+, .NET Core 2.0+, and all modern .NET versions (6/7/8/9+). The package's API surface is simple (events, delegates, EventArgs) and uses only reflection for hookup, so there is no need for TFM-specific API polyfills. Following the dotnet/runtime convention for OOB packages (see e.g. System.Formats.Nrbf), a $(NetCoreAppCurrent) target may also be added so the package can participate in the shared framework on current .NET, but netstandard2.0 is the primary asset that provides broad reach.
- Connects to runtime internals via reflection — registers callbacks through internal
RegisterTypeResolvingHandler static methods on BinaryFormatter and Type. No new public API is required on the runtime side; only internal methods need to be backported to servicing branches.
- Exposes
IsSupported properties so consumers can gracefully degrade on runtimes that lack the internal hooks (e.g., older servicing patch levels, or runtimes where BinaryFormatter is disabled).
- Has no hard dependency on
System.Runtime.Serialization.Formatters — the BinaryFormatterInstrumentation class references BinaryFormatter by type, but on .NET 9+ where the type's implementation throws, IsSupported will simply return false unless the compatibility package is also installed. The package handles type-load failure gracefully.
Package details
| Property |
Value |
| Package ID |
System.Runtime.Serialization.Instrumentation |
| Target frameworks |
netstandard2.0 (+ $(NetCoreAppCurrent) per dotnet/runtime OOB convention) |
| Dependencies |
None. BinaryFormatter is inbox on all target runtimes (netfx, netcoreapp). System.Type is always available. On .NET 9+ where BF is non-functional, consumers optionally install the compat package — but that is their dependency, not ours. |
| Ships from |
dotnet/runtime repo (like other OOB packages such as System.Formats.Nrbf) |
| Servicing model |
OOB — can ship patches independently of runtime servicing |
Key design consideration: BinaryFormatter dependency on .NET 9+
On .NET 9+, BinaryFormatter is inbox but throws. The System.Runtime.Serialization.Formatters compatibility package restores functionality. The instrumentation package must handle three cases:
| Runtime |
BinaryFormatter status |
BinaryFormatterInstrumentation.IsSupported |
| .NET Framework 4.x |
Fully functional, inbox |
true (if internal hook is backported) |
| .NET 6/7/8 |
Deprecated but functional |
true (if internal hook is backported) |
| .NET 9+ (no compat package) |
Throws PlatformNotSupportedException |
false (hook registration fails gracefully) |
| .NET 9+ (with compat package) |
Functional via compat package |
true (if compat package includes hook) |
The instrumentation package does not take a dependency on System.Runtime.Serialization.Formatters — it references the BinaryFormatter type that's always present inbox (the type exists even on .NET 9+, it just throws). If the type is non-functional and the internal hook is absent, registration fails and IsSupported = false. This keeps the dependency graph clean and means consumers only need to dotnet add package System.Runtime.Serialization.Instrumentation with no transitive dependency surprises.
Type-load isolation: The BinaryFormatterInstrumentation and TypeNameResolverInstrumentation classes are independent. Using TypeNameResolverInstrumentation alone must not trigger loading of BinaryFormatter-referencing types. This is achieved by the partial class design — the static constructor / type initializer for BinaryFormatterInstrumentation is separate and only runs when that class is first accessed.
TypeNameResolverInstrumentation — uses beyond BinaryFormatter migration
The Type.GetType(string) code path is used far beyond BinaryFormatter. Instrumenting it has value in several additional scenarios:
- Native AOT / Trimming diagnostics:
Type.GetType relies on metadata that may be trimmed away. Instrumentation can log which type names are resolved at runtime, helping developers build rd.xml directives or [DynamicDependency] annotations to preserve the right types. The trimmer emits IL2057 warnings for non-statically-analyzable type names, but runtime instrumentation captures what actually happens in production.
- Dynamic reflection auditing: Plugin architectures, DI containers, and serialization frameworks often resolve types by name at runtime. Monitoring these calls helps teams identify security exposure —
Type.GetType with attacker-controlled input is CWE-502 adjacent.
AssemblyLoadContext debugging: Type.GetType behaves differently across load contexts (dotnet/runtime#103222). Instrumentation provides visibility into resolution failures without custom wrappers.
- Migration from dynamic to static patterns: Teams moving to source generators or compile-time DI can use the instrumentation to inventory all dynamic
Type.GetType usage across their runtime behavior, not just their source code.
API Proposal
Public API — System.Runtime.Serialization.Instrumentation NuGet package
namespace System.Runtime.Serialization.Instrumentation
{
public static partial class BinaryFormatterInstrumentation
{
public static bool IsSupported { get; }
public static event BinaryFormatterTypeResolvingEventHandler TypeResolving { add { } remove { } }
}
public delegate void BinaryFormatterTypeResolvingEventHandler(
object? sender,
BinaryFormatterTypeResolvingEventArgs e);
public sealed partial class BinaryFormatterTypeResolvingEventArgs : EventArgs
{
internal BinaryFormatterTypeResolvingEventArgs() { }
public System.Runtime.Serialization.Formatters.Binary.BinaryFormatter Formatter { get; }
public string AssemblyName { get; }
public string TypeName { get; }
}
public static partial class TypeNameResolverInstrumentation
{
public static bool IsSupported { get; }
public static event TypeNameResolvingEventHandler TypeResolving { add { } remove { } }
}
public delegate void TypeNameResolvingEventHandler(
object? sender,
TypeNameResolvingEventArgs e);
public sealed partial class TypeNameResolvingEventArgs : EventArgs
{
internal TypeNameResolvingEventArgs() { }
public string? AssemblyName { get; }
public string TypeName { get; }
public bool IgnoreCase { get; }
}
}
Internal APIs (backported to servicing branches — not part of the NuGet package)
These internal static methods must be added to BinaryFormatter and Type in the runtime. They are not public API — the NuGet package discovers and invokes them via reflection. Because they are internal, they can be backported to servicing branches and .NET Framework without API review.
On System.Runtime.Serialization.Formatters.Binary.BinaryFormatter:
internal static void RegisterTypeResolvingHandler(Action<BinaryFormatter, string, string> handler);
// Parameters: (formatter, assemblyName, typeName)
Registration code in the NuGet package:
MethodInfo? registerHandlerMethod =
typeof(BinaryFormatter).GetMethod(
"RegisterTypeResolvingHandler",
BindingFlags.NonPublic | BindingFlags.Static,
binder: null,
types: new[] { typeof(Action<BinaryFormatter, string, string>) },
modifiers: null);
if (registerHandlerMethod is not null)
{
Action<BinaryFormatter, string, string> handler = TypeResolvingHandler;
try
{
registerHandlerMethod.Invoke(obj: null, parameters: new object[] { handler });
IsSupported = true;
}
catch
{
// Known scenario: BinaryFormatter compat package is present but disabled
// via AppContext switch. Fail gracefully — IsSupported remains false.
}
}
On System.Type:
internal static void RegisterTypeResolvingHandler(Action<string?, string, bool> handler)
// Parameters: (assemblyName, typeName, ignoreCase)
Registration code in the NuGet package:
MethodInfo? registerHandlerMethod =
typeof(Type).GetMethod(
"RegisterTypeResolvingHandler",
BindingFlags.NonPublic | BindingFlags.Static,
binder: null,
types: new[] { typeof(Action<string?, string, bool>) },
modifiers: null);
if (registerHandlerMethod is not null)
{
try
{
Action<string?, string, bool> handler = TypeResolvingHandler;
registerHandlerMethod.Invoke(obj: null, parameters: new object?[] { handler });
IsSupported = true;
}
catch
{
Debug.Fail("RegisterTypeResolvingHandler invocation is not expected to fail");
// Fail gracefully — IsSupported remains false.
}
}
API Usage
// === BinaryFormatter migration: discover which types are deserialized ===
if (BinaryFormatterInstrumentation.IsSupported)
{
BinaryFormatterInstrumentation.TypeResolving += (sender, e) =>
{
Log.Warning($"BinaryFormatter resolving type: [{e.AssemblyName}] {e.TypeName}");
};
}
// === General type resolution monitoring ===
// Useful for: AOT readiness auditing, trimming diagnostics,
// dynamic reflection inventory, security auditing
if (TypeNameResolverInstrumentation.IsSupported)
{
TypeNameResolverInstrumentation.TypeResolving += (sender, e) =>
{
Log.Info($"Type.GetType resolving: [{e.AssemblyName}] {e.TypeName} (ignoreCase={e.IgnoreCase})");
};
}
Alternative Designs
Add events directly to BinaryFormatter and Type
The more natural API shape would be static events directly on the target types:
// Hypothetical — cleaner, but problematic for servicing
public partial class BinaryFormatter
{
public static event Action<BinaryFormatter, string, string>? TypeResolving;
}
public partial class Type
{
public static event Action<string?, string, bool>? TypeResolving;
}
Advantages:
- Discoverable, no reflection, no
IsSupported check needed.
- Standard .NET event pattern on the types where the behavior actually occurs.
- No separate NuGet package needed — the API is just part of the runtime.
Disadvantages:
- Cannot ship to servicing branches or .NET Framework — these are new public APIs on core runtime types, which require a new major runtime release.
- Since the main customers are people migrating now on existing runtime versions, the clean API would not reach them.
- To make it work end-to-end in servicing, we would need both the clean public API on new releases and the reflection-based internal hookup for servicing/netfx — effectively maintaining the same capability twice, which feels unclean.
- Adding a public event to
System.Type has a very high API bar — it's one of the most fundamental types in the runtime.
- Would still need the OOB NuGet package for netfx/downlevel, so consumers would have two different API shapes for the same thing depending on runtime version.
The proposed design (separate NuGet package + reflection-based hookup) is pragmatic: it provides one consistent API surface across all target runtimes via netstandard2.0, reaches customers where they are today, and can be deprecated once the migration wave is complete.
Risks
- Reflection fragility: If internal
RegisterTypeResolvingHandler methods are renamed, removed, or have signature changes, IsSupported returns false and instrumentation silently becomes unavailable. Mitigated by the IsSupported check and by the fact that the internal methods are purpose-built for this scenario and would be backported in a coordinated manner.
- Trimming / Native AOT: Reflection-based discovery of internal methods will not survive aggressive trimming. Consumers in trimmed/AOT apps should check
IsSupported at runtime. This is acceptable because the primary targets are JIT-compiled apps on servicing branches and .NET Framework.
- BinaryFormatter type-load on .NET 9+: The
BinaryFormatterTypeResolvingEventArgs.Formatter property references BinaryFormatter as a type. On .NET 9+ without the compat package, loading this EventArgs type must not cause assembly-load failures. Since BinaryFormatter as a type still exists on .NET 9+ (it just throws at runtime), this is safe — the type loads fine, only its methods throw. Additionally, BinaryFormatterInstrumentation and TypeNameResolverInstrumentation are independent static classes, so accessing TypeNameResolverInstrumentation alone never triggers the class initializer for BinaryFormatterInstrumentation.
- Performance: The events add a delegate invocation on every type resolution. This is negligible compared to the cost of deserialization/reflection itself and is only active when subscribers are attached.
- Thread safety: Event registration is not expected to be hot-path. Standard thread-safe event patterns apply.
- Package versioning: As an OOB package,
System.Runtime.Serialization.Instrumentation must follow the same versioning and servicing conventions as other dotnet/runtime OOB packages (e.g., System.Formats.Nrbf). It should align major version with the .NET release it first ships in, while remaining installable on downlevel TFMs via the netstandard2.0 asset.
Disclaimer: This is generated by LLM but fully reviewed and guided by me (and some edits)
Background and motivation
BinaryFormatterwas fully removed from .NET 9 (the APIs remain but always throwPlatformNotSupportedException). An unsupported compatibility package exists for temporary use, but the expectation is that all consumers migrate to safer serialization alternatives.The primary customers for this proposal are organizations planning and executing migration away from
BinaryFormatter— and more broadly, away from patterns that rely onType.GetType-based dynamic type resolution. Before they can migrate, they need visibility into which types are being resolved at runtime during deserialization and other dynamic dispatch scenarios. Without instrumentation, teams must rely on code search and guesswork, which is error-prone for large codebases and third-party dependencies.Why a new NuGet package?
Migration tooling must reach customers on existing servicing branches, including .NET 6/7/8 LTS and even .NET Framework 4.x. These branches do not accept new public API surface. The proposed design introduces a new NuGet package
System.Runtime.Serialization.Instrumentationthat:netstandard2.0— this single TFM covers .NET Framework 4.6.1+, .NET Core 2.0+, and all modern .NET versions (6/7/8/9+). The package's API surface is simple (events, delegates, EventArgs) and uses only reflection for hookup, so there is no need for TFM-specific API polyfills. Following the dotnet/runtime convention for OOB packages (see e.g.System.Formats.Nrbf), a$(NetCoreAppCurrent)target may also be added so the package can participate in the shared framework on current .NET, butnetstandard2.0is the primary asset that provides broad reach.RegisterTypeResolvingHandlerstatic methods onBinaryFormatterandType. No new public API is required on the runtime side; only internal methods need to be backported to servicing branches.IsSupportedproperties so consumers can gracefully degrade on runtimes that lack the internal hooks (e.g., older servicing patch levels, or runtimes where BinaryFormatter is disabled).System.Runtime.Serialization.Formatters— theBinaryFormatterInstrumentationclass referencesBinaryFormatterby type, but on .NET 9+ where the type's implementation throws,IsSupportedwill simply returnfalseunless the compatibility package is also installed. The package handles type-load failure gracefully.Package details
System.Runtime.Serialization.Instrumentationnetstandard2.0(+$(NetCoreAppCurrent)per dotnet/runtime OOB convention)BinaryFormatteris inbox on all target runtimes (netfx, netcoreapp).System.Typeis always available. On .NET 9+ where BF is non-functional, consumers optionally install the compat package — but that is their dependency, not ours.dotnet/runtimerepo (like other OOB packages such asSystem.Formats.Nrbf)Key design consideration: BinaryFormatter dependency on .NET 9+
On .NET 9+,
BinaryFormatteris inbox but throws. TheSystem.Runtime.Serialization.Formatterscompatibility package restores functionality. The instrumentation package must handle three cases:BinaryFormatterInstrumentation.IsSupportedtrue(if internal hook is backported)true(if internal hook is backported)PlatformNotSupportedExceptionfalse(hook registration fails gracefully)true(if compat package includes hook)The instrumentation package does not take a dependency on
System.Runtime.Serialization.Formatters— it references theBinaryFormattertype that's always present inbox (the type exists even on .NET 9+, it just throws). If the type is non-functional and the internal hook is absent, registration fails andIsSupported = false. This keeps the dependency graph clean and means consumers only need todotnet add package System.Runtime.Serialization.Instrumentationwith no transitive dependency surprises.Type-load isolation: The
BinaryFormatterInstrumentationandTypeNameResolverInstrumentationclasses are independent. UsingTypeNameResolverInstrumentationalone must not trigger loading ofBinaryFormatter-referencing types. This is achieved by thepartial classdesign — the static constructor / type initializer forBinaryFormatterInstrumentationis separate and only runs when that class is first accessed.TypeNameResolverInstrumentation— uses beyond BinaryFormatter migrationThe
Type.GetType(string)code path is used far beyondBinaryFormatter. Instrumenting it has value in several additional scenarios:Type.GetTyperelies on metadata that may be trimmed away. Instrumentation can log which type names are resolved at runtime, helping developers buildrd.xmldirectives or[DynamicDependency]annotations to preserve the right types. The trimmer emitsIL2057warnings for non-statically-analyzable type names, but runtime instrumentation captures what actually happens in production.Type.GetTypewith attacker-controlled input is CWE-502 adjacent.AssemblyLoadContextdebugging:Type.GetTypebehaves differently across load contexts (dotnet/runtime#103222). Instrumentation provides visibility into resolution failures without custom wrappers.Type.GetTypeusage across their runtime behavior, not just their source code.API Proposal
Public API —
System.Runtime.Serialization.InstrumentationNuGet packageInternal APIs (backported to servicing branches — not part of the NuGet package)
These internal static methods must be added to
BinaryFormatterandTypein the runtime. They are not public API — the NuGet package discovers and invokes them via reflection. Because they are internal, they can be backported to servicing branches and .NET Framework without API review.On
System.Runtime.Serialization.Formatters.Binary.BinaryFormatter:Registration code in the NuGet package:
On
System.Type:Registration code in the NuGet package:
API Usage
Alternative Designs
Add events directly to
BinaryFormatterandTypeThe more natural API shape would be static events directly on the target types:
Advantages:
IsSupportedcheck needed.Disadvantages:
System.Typehas a very high API bar — it's one of the most fundamental types in the runtime.The proposed design (separate NuGet package + reflection-based hookup) is pragmatic: it provides one consistent API surface across all target runtimes via
netstandard2.0, reaches customers where they are today, and can be deprecated once the migration wave is complete.Risks
RegisterTypeResolvingHandlermethods are renamed, removed, or have signature changes,IsSupportedreturnsfalseand instrumentation silently becomes unavailable. Mitigated by theIsSupportedcheck and by the fact that the internal methods are purpose-built for this scenario and would be backported in a coordinated manner.IsSupportedat runtime. This is acceptable because the primary targets are JIT-compiled apps on servicing branches and .NET Framework.BinaryFormatterTypeResolvingEventArgs.Formatterproperty referencesBinaryFormatteras a type. On .NET 9+ without the compat package, loading this EventArgs type must not cause assembly-load failures. SinceBinaryFormatteras a type still exists on .NET 9+ (it just throws at runtime), this is safe — the type loads fine, only its methods throw. Additionally,BinaryFormatterInstrumentationandTypeNameResolverInstrumentationare independent static classes, so accessingTypeNameResolverInstrumentationalone never triggers the class initializer forBinaryFormatterInstrumentation.System.Runtime.Serialization.Instrumentationmust follow the same versioning and servicing conventions as other dotnet/runtime OOB packages (e.g.,System.Formats.Nrbf). It should align major version with the .NET release it first ships in, while remaining installable on downlevel TFMs via thenetstandard2.0asset.