Reflection
By the end of this lesson
Inspect types at run time, and understand the cost of doing so.
Compiled code carries a full description of itself. Every assembly records the types it contains, their properties, methods, parameters, base types, interfaces and attributes. That description is called metadata, and it is why a .NET assembly can be inspected without its source code.
Reflection is the API for reading that metadata while the program is running, and for acting on it — creating an instance of a type you were handed as a value, reading a property whose name you only have as a string, calling a method chosen at run time.
This is a different mode of programming from everything else in the course. Normally you name a type and the compiler checks you. With reflection you describe a type and find out whether you were right when the code runs.
The handful of entry points that cover most real use:
- typeof(Order)
- The Type object for a type you can name at compile time. No instance needed, and it is resolved at compile time so there is no lookup cost.
- order.GetType()
- The Type of an actual object. This gives you the real run-time type, which may be more derived than the variable's declared type.
- Type.GetProperties() and GetMethods()
- Arrays of PropertyInfo and MethodInfo describing what the type has. Public members by default; other visibilities need BindingFlags.
- PropertyInfo.GetValue and SetValue
- Read or write that property on an instance you supply. This is the part that actually touches your data, and the slowest part of the API.
- Activator.CreateInstance(type)
- Construct an instance of a Type you hold as a value. It fails at run time, not build time, if there is no suitable constructor.
- nameof(Order.Total)
- Not reflection, but the thing that prevents most reflection bugs. It produces the string "Total" and is checked by the compiler, so a rename updates it.
public static class CsvExporter
{
// Cache the property list per type. Discovery is the expensive part.
private static readonly Dictionary<Type, PropertyInfo[]> _propertyCache = new();
public static string Export<T>(IEnumerable<T> rows)
{
PropertyInfo[] properties = GetReadableProperties(typeof(T));
StringBuilder csv = new();
csv.AppendLine(string.Join(",", properties.Select(p => p.Name)));
foreach (T row in rows)
{
IEnumerable<string> cells = properties
.Select(p => Quote(p.GetValue(row)));
csv.AppendLine(string.Join(",", cells));
}
return csv.ToString();
}
private static PropertyInfo[] GetReadableProperties(Type type)
{
if (!_propertyCache.TryGetValue(type, out PropertyInfo[]? properties))
{
properties = type
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.ToArray();
_propertyCache[type] = properties;
}
return properties;
}
private static string Quote(object? value)
{
string text = value?.ToString() ?? string.Empty;
return text.Contains(',') ? string.Concat("\"", text.Replace("\"", "\"\""), "\"") : text;
}
}
// The exporter has never heard of Order or Customer.
string ordersCsv = CsvExporter.Export(orders);
string customersCsv = CsvExporter.Export(customers);- typeof(T) gives the Type for whatever the caller supplied. From there, GetProperties returns a description of every public instance property — names, types, and whether each can be read.
- The header row is built from property names. No Order-specific code exists anywhere in this class, which is the whole point: one exporter serves every type in the application.
- p.GetValue(row) is the reflective read. It is the equivalent of writing row.Total, decided at run time instead of compile time, and it costs considerably more than the direct version.
- The cache is the important detail. Discovering properties is slow and the answer never changes for a given type, so it is worth keeping. Only GetValue then runs per cell.
- BindingFlags.Public | BindingFlags.Instance states the intent explicitly rather than relying on defaults. Excluding indexed properties avoids a run-time failure on any type that has one, since GetValue with no arguments would throw.
- This class is a fair illustration of when reflection is the right answer. An exporter that needed a hand-written method per type would be a maintenance burden, and every new type would be one somebody forgot.
- The cache shown here is a plain Dictionary for readability. Used concurrently it would need ConcurrentDictionary, since a Dictionary being written from two threads can corrupt itself.
Most of the .NET tooling you already use is built on reflection:
- JSON serialisers discover which properties a type has in order to read and write them, which is why System.Text.Json works on a class you wrote this morning.
- Dependency injection containers examine a constructor's parameters to work out what to supply, which is the subject of a later lesson in this module.
- Test runners scan assemblies for methods marked with a test attribute, then invoke them. Nothing registers your tests; they are found.
- ASP.NET Core discovers controllers and their actions, matches route parameters to method parameters, and validates models by reading attributes.
- Object-relational mappers map columns to properties, and mocking libraries generate types that implement your interfaces at run time.
- Debuggers, object inspectors and admin UIs display the contents of an object they have never been compiled against.
Summary
- Compiled assemblies describe themselves, and reflection is the API for reading and acting on that description at run time
- It powers serialisers, dependency injection containers, test runners and model binding — code written against types it has never seen
- The costs are real: slower than direct calls, no compile-time checking, breaks silently under rename, and fragile under trimming and AOT
- Cache discovery such as GetProperties; it is the expensive half and its answer never changes for a type
- Use it for infrastructure, not for ordinary application logic where the compiler already knows the type
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Think about it
An audit feature compares two versions of an entity and records what changed. The implementation reflects over every public property and compares the values, so it works for orders, customers and invoices alike.
A performance review finds it accounts for 40% of the time spent saving an order. Before optimising anything, what would you want to know, and what are the options in order of cost to the team?
Show solution
First, what is actually slow. Is it discovery — GetProperties on every comparison — or the per-property GetValue calls? Those have very different fixes, and guessing wastes effort. A profiler answers it in minutes.
Second, how often it runs. 40% of a save that happens twice a minute is not worth a week of work. 40% of a save on a hot path at a thousand per second is a different conversation. A share of the time means nothing without the absolute number.
Cheapest option: cache the PropertyInfo array per type. If discovery is the cost, this alone often removes most of it, and the code barely changes.
Next: narrow what is compared. Most entities have properties that never need auditing. Filtering by an attribute cuts the work proportionally and is a small change.
More expensive: replace GetValue with a compiled delegate per property, built once from an expression tree and cached. This approaches direct-call speed and is what serialisation libraries did for years. It is real complexity and needs a measurement to justify it.
Most expensive: generate the comparison code at build time with a source generator, or hand-write it per entity. Fastest at run time, no reflection at all, survives trimming — and now every new property is something a developer must remember. That is the trade the reflective version was buying in the first place, which is why it should be the last resort rather than the first instinct.
Try it yourself
Try it yourself
Write a small helper that takes any object and returns a dictionary of property name to value, skipping properties that cannot be read.
Then explain what you would have to add before using it on a class that holds a password or an API key.
Show solution
The mechanics are short: GetType on the instance, GetProperties, filter on CanRead, and GetValue for each. Indexed properties need excluding, because GetValue with no arguments throws on them.
The security question is the real content of the exercise. A helper like this is typically used for logging or diagnostics, and it will happily read a Password, an ApiKey or a CardNumber property straight into a log file — where it is retained, indexed, and visible to anyone with log access.
Reflection is what makes this dangerous. Hand-written logging code has a person choosing each field. Reflective code takes everything, including properties added after it was written, so the default behaviour of the next developer's new field is to be exposed.
The fix is to make exclusion explicit and to fail safe: an attribute marking sensitive properties, checked here, so that marking a property is a deliberate act at the point of declaration. A hard-coded list of names in the helper is worse — it lives far from the property and nobody updates it.
There is a broader principle worth taking away. When code processes everything automatically, the safe default has to be built in, because nobody will remember to opt out.
[AttributeUsage(AttributeTargets.Property)]
public sealed class SensitiveAttribute : Attribute
{
}
public static class ObjectDescriber
{
public static Dictionary<string, object?> Describe(object instance)
{
Dictionary<string, object?> values = new();
PropertyInfo[] properties = instance.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (PropertyInfo property in properties)
{
if (!property.CanRead || property.GetIndexParameters().Length > 0)
{
continue;
}
bool sensitive = property.GetCustomAttribute<SensitiveAttribute>() is not null;
values[property.Name] = sensitive ? "[redacted]" : property.GetValue(instance);
}
return values;
}
}
public class ApiCredential
{
public string Name { get; set; } = "";
[Sensitive]
public string Key { get; set; } = "";
}Saved in this browser only.