[API Proposal]: Instrumentation APIs for BinaryFormatter and Type.GetType type resolution
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
*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](https://github.com/dotnet/runtime/issues/106240) (the APIs remain but always throw `PlatformNotSupportedException`). An unsupported [compatibility package](https://learn.microsoft.com/dotnet/standard/serialization/binaryformatter-migration-guide/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](https://www.nuget.org/packages/System.Runtime.Serialization.Formatters) 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](https://cwe.mitre.org/data/definitions/502.html) adjacent.
- **`AssemblyLoadContext` debugging:** `Type.GetType` behaves differently across load contexts ([dotnet/runtime#103222](https://github.com/dotnet/runtime/issues/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
```csharp
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`:**
```csharp
internal static void RegisterTypeResolvingHandler(Action handler);
// Parameters: (formatter, assemblyName, typeName)
```
Registration code in the NuGet package:
```csharp
MethodInfo? registerHandlerMethod =
typeof(BinaryFormatter).GetMethod(
"RegisterTypeResolvingHandler",
BindingFlags.NonPublic | BindingFlags.Static,
binder: null,
types: new[] { typeof(Action) },
modifiers: null);
if (registerHandlerMethod is not null)
{
Action 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`:**
```csharp
internal static void RegisterTypeResolvingHandler(Action handler)
// Parameters: (assemblyName, typeName, ignoreCase)
```
Registration code in the NuGet package:
```csharp
MethodInfo? registerHandlerMethod =
typeof(Type).GetMethod(
"RegisterTypeResolvingHandler",
BindingFlags.NonPublic | BindingFlags.Static,
binder: null,
types: new[] { typeof(Action) },
modifiers: null);
if (registerHandlerMethod is not null)
{
try
{
Action 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
```csharp
// === 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:
```csharp
// Hypothetical — cleaner, but problematic for servicing
public partial class BinaryFormatter
{
public static event Action? TypeResolving;
}
public partial class Type
{
public static event Action? 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.
Contributor guide
Assessment
This issue has not been assessed yet.