What is Ivy?
Ivy is an open-source .NET framework for building internal applications with AI and pure C#. Think of it as a server-driven UI framework: you write your entire app in C# — layout, state, events — and Ivy renders it in the browser. Widgets like Button, TextBlock, and DataTable are plain C# classes decorated with [Prop] and [Event] attributes, and the framework serializes them to JSON on every render cycle to ship the UI state to the frontend.
This architecture is powerful, but it comes with a cost: reflection. Today, Ivy leans on PropertyInfo.GetValue(), GetCustomAttribute<T>(), and assembly scanning in several hot paths. With C# 14 shipping interceptors as a first-class feature, we see an opportunity to eliminate that overhead entirely — at compile time, with zero runtime cost.
This article walks through our concrete plan.
The Problem: Reflection in Hot Paths
Ivy uses runtime reflection in four key areas:
1. Widget Serialization (hottest path)
Every time a widget renders, WidgetSerializer needs to know which properties have [Prop] attributes, read their values, compare them to defaults, and write JSON. Today that looks roughly like this:
// Simplified — the real code caches type metadata in a ConcurrentDictionary
var properties = widgetType.GetProperties();
foreach (var prop in properties)
{
var attr = prop.GetCustomAttribute<PropAttribute>();
if (attr == null) continue;
var value = prop.GetValue(widget); // reflection call per prop, per render
var defaultValue = prop.GetValue(defaultInstance);
if (!ValuesAreEqual(value, defaultValue))
writer.WritePropertyName(attr.Name);
// ... serialize value
}
The type metadata is cached, so the initial scan only happens once per type. But PropertyInfo.GetValue() is called on every serialization — that's every render cycle, for every widget in the tree. On a DataTable with 100 rows, that adds up fast.
2. App Discovery (startup)
AppHelpers.GetApps() scans the entry assembly for classes decorated with [App]:
assembly.GetLoadableTypes()
.Where(t => t.GetCustomAttribute<AppAttribute>() != null)
This runs at startup so it's not a per-frame cost, but it's incompatible with Native AOT trimming — the trimmer can't statically determine which types are needed.
3. External Widget Registry
ExternalWidgetRegistry scans all loaded assemblies and DLLs on disk looking for [ExternalWidget] attributes. Same AOT and startup cost concerns.
4. Form Field Access
FormBuilderField stores PropertyInfo/FieldInfo references and reads form values via reflection at runtime.
The Solution: Interceptors + Source Generators
C# 14 introduces interceptors — a compile-time mechanism where a source generator can tell the compiler: "replace this specific method call at this specific location with my generated method instead." The substitution happens at the IL level. There is no runtime dispatch, no dictionary lookup, no delegate indirection. The original call simply doesn't exist in the compiled output.
Here's our phased plan.
Phase 1: Widget Serialization (Highest Impact)
We'll create a new Ivy.SourceGenerator project that runs at compile time and generates type-specific serializers for every IWidget implementation.
What the generator produces
For each widget type, the generator emits a static serializer with direct property access — no PropertyInfo involved:
// Auto-generated by Ivy.SourceGenerator
namespace Ivy.Generated;
file static class ButtonWidgetSerializer
{
public static void WriteProps(Button widget, Utf8JsonWriter writer, Button? defaultInstance)
{
// Direct property access — no reflection
if (defaultInstance == null || widget.Label != defaultInstance.Label)
writer.WriteString("label", widget.Label);
if (defaultInstance == null || widget.Variant != defaultInstance.Variant)
writer.WriteString("variant", widget.Variant.ToString());
// ... generated for all [Prop] properties
}
public static void WriteEvents(Button widget, Utf8JsonWriter writer)
{
if (widget.OnClick != null)
writer.WriteStringValue("OnClick");
}
}
Where the interceptor comes in
The generator also emits an interceptor that replaces every call to WidgetSerializer.Serialize(IWidget) with a generated dispatch:
[InterceptsLocation("Core/RenderPipeline.cs", line: 47, column: 12)]
public static JsonNode Serialize_Intercepted(IWidget widget)
{
return widget switch
{
Button b => ButtonWidgetSerializer.Serialize(b),
TextBlock t => TextBlockWidgetSerializer.Serialize(t),
DataTable d => DataTableWidgetSerializer.Serialize(d),
// ... all widget types known at compile time
_ => WidgetSerializer.Serialize(widget) // fallback for external/unknown widgets
};
}
The [InterceptsLocation] attribute points to the exact file, line, and column of the original call. The compiler rewires the call at the IL level. At runtime, the pattern match dispatches to a type-specific serializer that reads properties directly — the same way you'd write it by hand.
The _ => fallback arm is important: external widgets loaded from plugin assemblies aren't known at compile time, so they still go through the existing reflection path. Zero breaking changes.
Why this matters for performance
| Operation | Before (reflection) | After (interceptor) |
|---|---|---|
| Read a widget property |
PropertyInfo.GetValue() — boxing, security checks, internal cache lookup |
Direct property access — single IL instruction |
| Determine serializable props | Cached ConcurrentDictionary lookup per type |
Compile-time constant — no lookup at all |
| Dispatch to serializer | Virtual call + dictionary lookup | Pattern match on known types (JIT-optimized) |
For a DataTable with 100 rows, each containing 10 props, that's 1,000 PropertyInfo.GetValue() calls eliminated per render cycle.
Phase 2: Compile-Time App Registry
The same source generator scans for [App]-decorated classes and emits a static registry:
namespace Ivy.Generated;
public static class AppRegistry
{
public static AppDescriptor[] GetAll() =>
[
new() { Id = "customers", Title = "Customers", Type = typeof(CustomersApp) },
new() { Id = "dashboard", Title = "Dashboard", Type = typeof(DashboardApp) },
];
}
An interceptor replaces AppHelpers.GetApps() with AppRegistry.GetAll(). No more assembly scanning at startup, and — crucially — this is Native AOT compatible because the trimmer can see exactly which types are referenced.
Phase 3: Form Field Accessors
For FormBuilder<T>, the generator emits typed getter/setter delegates:
public static class CustomerFormAccessors
{
public static object? GetName(Customer c) => c.Name;
public static void SetName(Customer c, object? value) => c.Name = (string)value;
}
FormBuilderField uses these instead of PropertyInfo.GetValue()/SetValue(). Lower priority than phases 1 and 2, but it completes the picture of a reflection-free framework.
How Interceptors Actually Work
If you haven't seen interceptors before, here's the mental model:
You write normal code. Call
WidgetSerializer.Serialize(widget)wherever you need it. No special syntax.A source generator runs at compile time. It analyzes the syntax tree, finds call sites it wants to intercept, and emits replacement methods annotated with
[InterceptsLocation].The compiler rewires the call. The original method still exists (important for fallback and IDE navigation), but the call site now points to the generated method. This happens in the IL — there's no runtime wrapper or proxy.
The key insight: interceptors are not method replacement or monkey-patching. They're a compile-time call-site redirect. Each [InterceptsLocation] targets a specific file + line + column. The original method is untouched and still callable from other sites.
What We Gain
Performance: Eliminating
PropertyInfo.GetValue()from the render loop is the biggest win. Direct property access is orders of magnitude faster — no boxing, no security checks, no reflection cache lookups.Native AOT compatibility: Assembly scanning is the #1 blocker for AOT. Source-generated registries make the app fully trimmable.
Zero breaking changes: The reflection-based code stays as a fallback. External widgets, plugins, and existing user code continue to work. The interceptor is invisible to consumers.
Better diagnostics: Since the generator sees all widget types at compile time, it can emit warnings for common mistakes (missing
[Prop]on a public property, unsupported property types, etc.) — catching errors before runtime.
The Tradeoffs
We're being honest about the costs:
Complexity: Source generators are not simple. Debugging generated code requires
EmitCompilerGeneratedFilesand stepping through syntax trees. This is a significant investment in build infrastructure.Interceptors are still new: While C# 14 promotes them from experimental to supported, the ecosystem is still catching up. IDE support for "go to definition" on intercepted calls is evolving.
Build time: Running an incremental generator over all
IWidgettypes adds compile-time cost. We'll need to be careful with the incremental pipeline to avoid regenerating on every keystroke.Two code paths: Until we can drop the reflection fallback entirely (which requires all widget authors to compile with the generator), we maintain both paths. That's tech debt we're knowingly taking on.
When?
This is on our roadmap as a nice-to-have, not a v-next blocker. We're waiting for C# 14 to ship with stable interceptor support before committing implementation effort. In the meantime, we've documented the plan in detail (the internal design doc is the basis for this article) and identified the exact call sites to intercept.
If you're working on a framework that uses reflection in hot paths — serialization, DI, mapping — interceptors are worth watching. They're the first C# feature that lets you transparently replace runtime reflection with compile-time code generation without changing the public API.
Try Ivy
Ivy is open source and available on NuGet. If you're building internal tools in C# and want a pure-C# alternative to frontend frameworks, check it out:
We'd love feedback on the interceptor plan — drop an issue or leave a comment below.
Top comments (0)