CLR & Concurrency · Lesson 5

Reflection invocation and exception boundaries

Distinguish a reflection invocation wrapper from the failure of a directly called delegate.

External .NET 10 SDK / desktop runtime. OS-thread experiments run only here; the .NET 9 browser companion has the narrower scope stated in the guide.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

The runtime contract

Calling a method through reflection introduces an invocation boundary distinct from the method's own failure. This example deliberately invokes a known synchronous validation method with zero, then invokes the same behavior through a directly typed delegate. The selected reflection overload wraps an exception thrown by the target in TargetInvocationException; the original domain exception is available as InnerException. The delegate path does not add that reflection wrapper. This is actual invocation behavior on the portable runtime. It does not mean every binder error or asynchronously returned task failure follows the same wrapping path.

Trace the portable program

Rules.Positive rejects zero with ArgumentOutOfRangeException. MethodInfo.Invoke catches that target failure through its invocation machinery, so the first two lines identify outer:TargetInvocationException and inner:ArgumentOutOfRangeException. Calling the typed delegate with zero exposes the target exception directly, producing delegate:ArgumentOutOfRangeException. None of those lines asserts a localized exception message. Keep the two call boundaries separate when tracing the output. A broad catch that treats any exception as expected validation could accidentally accept a wrong signature, missing method, or unrelated reflection setup defect.

Execute the actual desktop lab

The desktop project preserves the same synchronous invocation path and exact exception types. Reflection setup failures form another category: missing members, ambiguous overload selection, invalid target objects, and incompatible arguments can fail before the target method executes. A real dispatcher should distinguish those defects from a valid target rejecting an input. Creating a typed delegate for a known compatible method can move some binding decisions earlier and avoid reflection wrapping during each call. That does not eliminate the method's own exceptions or grant permission to invoke untrusted code selected only by a convenient name.

Pitfalls and boundary limits

Asynchronous methods add a further distinction. Invoking a method that returns Task can succeed synchronously by returning the task, while the task later faults when awaited. That later failure is not automatically wrapped as though it were thrown during MethodInfo.Invoke. Preserve the actual boundary you observe and avoid flattening every error into one generic invalid-input result. Also do not rethrow only InnerException in a way that casually loses useful diagnostic context or stack information. The baseline prints type names instead of attempting a production exception-translation policy, keeping its educational scope bounded and inspectable.

Practice one: isolate behavior

First call Positive with one through both paths and predict a successful value with no exception-type lines from their catches. Then deliberately supply an incompatible reflection argument in a separate scenario and determine whether the target body ran. Hint: keep binder or argument failures distinct from the target's range rule. Record a specific expected category instead of catching every exception as validation success. Restore the valid lookup and argument shape before comparing wrapper behavior again, so an unrelated setup defect cannot masquerade as the domain exception being studied.

Practice two: design and verify

For another exercise, add a known method returning a faulted Task and compare invocation with awaiting the returned task. Hint: retain and await that exact task rather than claiming invocation observed its eventual completion. Define separate diagnostics for selection failure, synchronous target failure, and asynchronous target failure. If the dispatcher maps these outcomes into application results, preserve enough information to identify programming defects rather than translating all of them into an ordinary user rejection. Keep any new Trace-style expected output tied to an actually executed variant.

Review the evidence

Review dynamic invocation by locating the member-selection step, argument-binding step, target execution, and any returned asynchronous operation. The sample's three lines answer one precise synchronous comparison. The desktop project confirms that comparison without proving a universal exception wrapper rule for every reflection API or awaitable. Clear boundaries make dispatch diagnostics useful and prevent broad exception handlers from accepting broken setup as successful validation. Prefer typed calls or delegates when they fit the known contract, and reserve reflection for a deliberate bounded discovery or invocation need whose failures remain understandable. A dispatcher can validate parameter count and static-versus-instance expectations before invocation, then record the selected method identity alongside failures. That evidence makes an ambiguous lookup distinguishable from a legitimate domain rejection.

External .NET 10 SDK / desktop runtime. OS-thread experiments run only here; the .NET 9 browser companion has the narrower scope stated in the guide.

Reflection invocation and exception boundaries — desktop runtime lab

Set up the SDK project

  1. Save both files at their exact paths in a fresh owned directory.
  2. Use a .NET 10 SDK, then run dotnet run --project RuntimeLab.csproj. This project intentionally uses C# 13 syntax.
  3. Workers and waits are bounded; a timeout or a violated invariant fails the process. No benchmark or NativeAOT publication is implied by an ordinary run.

Download complete SDK project ↗

Project files

Program.csDownload file ↗
using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

var method = typeof(Rules).GetMethod(nameof(Rules.Positive))!;
try { method.Invoke(null, new object?[] { 0 }); }
catch (TargetInvocationException error)
{
    Console.WriteLine($"outer:{error.GetType().Name}");
    Console.WriteLine($"inner:{error.InnerException!.GetType().Name}");
}
Func<int, int> direct = Rules.Positive;
try { direct(0); } catch (ArgumentOutOfRangeException error) { Console.WriteLine($"delegate:{error.GetType().Name}"); }
static class Rules { public static int Positive(int value) => value > 0 ? value : throw new ArgumentOutOfRangeException(nameof(value)); }
RuntimeLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>13.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PublishAot>false</PublishAot>
  </PropertyGroup>
</Project>

Expected result

Exact behavioral stdout on the stated desktop lab: outer:TargetInvocationException inner:ArgumentOutOfRangeException delegate:ArgumentOutOfRangeException Variable diagnostics, where emitted, go to stderr and must not be treated as portable timing or allocation guarantees.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

var method = typeof(Rules).GetMethod(nameof(Rules.Positive))!;
try { method.Invoke(null, new object?[] { 0 }); }
catch (TargetInvocationException error)
{
    Console.WriteLine($"outer:{error.GetType().Name}");
    Console.WriteLine($"inner:{error.InnerException!.GetType().Name}");
}
Func<int, int> direct = Rules.Positive;
try { direct(0); } catch (ArgumentOutOfRangeException error) { Console.WriteLine($"delegate:{error.GetType().Name}"); }
static class Rules { public static int Positive(int value) => value > 0 ? value : throw new ArgumentOutOfRangeException(nameof(value)); }
Open C# practice

C# practice output

outer:TargetInvocationException
inner:ArgumentOutOfRangeException
delegate:ArgumentOutOfRangeException
Try this
Keep binder/signature errors separate from failures inside the invoked method. A returned faulted Task has a further asynchronous boundary.

Further reading

Interview questions for this lesson