Skip to content

[API Proposal]: Instrumentation APIs for BinaryFormatter and Type.GetType type resolution #126831

@krwq

Description

@krwq

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:

  1. Ships independently of the runtime — can be released on its own cadence, decoupled from .NET release trains.
  2. 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.
  3. 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.
  4. 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).
  5. 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.

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions