Chapter 16: Reflection, Attributes, and Source Generators
Reflection gives your code a way to examine types, members, and metadata at runtime. Attributes let you declare extra information that tools and libraries can discover. Source generators operate at compile time and create code before your assembly is built. This chapter shows how these features relate and how to use them in a practical and efficient way.
Type inspection at runtime
The System.Reflection API lets you read type information such as methods, constructors, properties, fields, interfaces, generic parameters, and custom attributes. Type is the central entry point; you obtain it from typeof(SomeType), instance.GetType(), or by loading an assembly and searching for exported types.
Discovering type structure
Use the members on Type to examine a structure. You can check whether a type is a class, struct, enum, or interface; you can test whether it is generic; you can read its base type and implemented interfaces. This powers object mappers, serializers, plug in systems, and more.
Type t = typeof(Dictionary<string, int>);
Console.WriteLine(t.FullName);
Console.WriteLine(t.IsClass);
Console.WriteLine(t.GetGenericArguments().Length);
foreach (var p in t.GetProperties())
{
Console.WriteLine($"{p.Name}: {p.PropertyType}");
}
Type members that avoid materializing arrays; for example EnumerateCustomAttributes from newer APIs helps reduce allocations.
Loading assemblies
You can load assemblies with Assembly.Load, Assembly.LoadFrom, or by reading the dependency context in an application. Once loaded, call GetTypes or ExportedTypes to explore available types. This is the backbone of plugin discovery.
var asm = Assembly.Load("MyPluginAssembly");
foreach (var type in asm.GetTypes())
{
if (typeof(IMyPlugin).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract)
{
Console.WriteLine("Found plugin: " + type.FullName);
}
}
Invoking members via reflection
Reflection can invoke methods, create instances, and set or get fields and properties. This gives flexible behavior but avoids compile time checks so you handle errors carefully. Performance is slower than direct calls because reflection goes through metadata and performs safety checks.
Constructing instances
Locate constructors with GetConstructors or GetConstructor then call Invoke. For parameterless constructors you can use Activator.CreateInstance which is shorter and supports generics.
Type target = typeof(Person);
object p = Activator.CreateInstance(target)!;
Console.WriteLine(p);
Invoking methods and accessing members
Methods come from GetMethod or from a LINQ search over GetMethods(). Properties expose GetValue and SetValue. Fields expose GetValue and SetValue directly.
var sayMethod = target.GetMethod("Say", new[] { typeof(string) });
sayMethod?.Invoke(p, new object[] { "Hello" });
var nameProp = target.GetProperty("Name");
nameProp?.SetValue(p, "Riley");
string name = (string)nameProp?.GetValue(p)!;
BindingFlags.Instance, BindingFlags.Public, BindingFlags.NonPublic, and BindingFlags.Static. Get the combination right or you may not find the member you expect.
Attributes and metadata
Attributes are small metadata objects attached to declarations. They guide frameworks, control serialization, describe validation rules, document intent, and influence tooling. They are passive until someone queries them through reflection or through a generator.
Declaring custom attributes
Attributes are classes that inherit from System.Attribute. You decorate targets like classes, methods, properties, enums, and parameters. The AttributeUsage attribute controls where your attribute can appear and whether multiple instances are allowed.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class TagAttribute : Attribute
{
public string Name { get; }
public TagAttribute(string name) => Name = name;
}
Reading attributes at runtime
Use GetCustomAttributes to retrieve instances. Many frameworks scan attributes to influence behavior; for example JSON serializers and dependency injection containers use them widely.
foreach (var attr in target.GetCustomAttributes(typeof(TagAttribute), inherit: true))
{
var tag = (TagAttribute)attr;
Console.WriteLine(tag.Name);
}
Caller info attributes
C# provides attributes that let you capture information about the calling location without writing extra code. These attributes attach to optional parameters and the compiler fills in values at the call site. This is helpful in logging, diagnostics, and validation.
Using caller info parameters
You attach attributes such as CallerMemberName, CallerLineNumber, and CallerFilePath to optional parameters. The compiler inserts the member name, line number, or file path of the calling location. This reduces boilerplate in debug code.
public void Log(
string message,
[CallerMemberName] string member = "...",
[CallerLineNumber] int line = 0,
[CallerFilePath] string file = "...")
{
Console.WriteLine($"{file}({line}) {member}: {message}");
}
Log("Testing");
Introduction to source generators
Source generators are Roslyn components that run during compilation and produce additional C# source code. They do not rewrite user code; instead they supply new files to the compiler. Generators help eliminate reflection at runtime, build serializers, create bindings, and generate repetitive or pattern based code.
How source generators work
A generator implements IIncrementalGenerator or ISourceGenerator. The compiler invokes it with syntax trees and semantic models. The generator analyzes this information then calls context.AddSource with generated code. Incremental generators cache inputs which reduces work for unchanged files.
[Generator]
public class HelloGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context) { }
public void Execute(GeneratorExecutionContext context)
{
string source = "public static class Hello { public static string Text => \"Hello\"; }";
context.AddSource("Hello.g.cs", source);
}
}
Scenarios for generators
Generators suit tasks where analyzing code at compile time lets you avoid runtime reflection. They are useful for serializers, dependency injection setups, binding layers, configuration mapping, and compile time validation. Generated code becomes normal C# in your assembly which improves startup time and reduces allocations.
Performance considerations
Reflection and attributes add flexibility but can be slower than direct code paths. Source generators shift work to compile time which often improves runtime performance but at the cost of more complex build steps. Understanding the trade offs helps you choose the right tool for the right scenario.
Reducing reflection overhead
Cache reflected members such as MethodInfo and PropertyInfo to avoid repeated metadata walks. Use delegates created with MethodInfo.CreateDelegate to invoke methods quickly. Prefer generic constraints and direct calls whenever possible because they let the JIT optimize aggressively.
MethodInfo mi = typeof(Person).GetMethod("Say")!;
var fast = (Action<Person, string>)mi.CreateDelegate(typeof(Action<Person, string>));
fast(new Person(), "Hi");
Balancing generators and reflection
Some systems combine generators and reflection. Generators scan types at compile time then produce code that avoids reflection during execution. This hybrid pattern reduces startup work and gives predictable performance for services that handle many requests.
Reflection, attributes, and source generators give you powerful tools across runtime and compile time. When used thoughtfully they help you build flexible, maintainable, and high performance applications.