You have written [HttpGet], [Required] and [Serializable] without ever writing the code that reads them. This post is about that code. Reflection is how a compiled assembly inspects itself at runtime, and attributes are how you leave notes for it. Together they explain how ASP.NET Core finds your controllers, how EF Core maps a class to a table, and how a test runner finds your tests without you registering anything.
Reflection: an assembly reading itself
Every .NET assembly carries metadata describing its types, members, parameters and attributes. Reflection is the API for reading it, and for invoking things you found.
var type = typeof(Book); // compile-time: you know the type
var type = book.GetType(); // runtime: from an instance
var type = Type.GetType("MyApp.Book");// from a string name
foreach (var prop in type.GetProperties())
Console.WriteLine($"{prop.PropertyType.Name} {prop.Name}");
// Int32 Id
// String Title
// String Author
// Int32 Price
Once you have a Type, you can inspect and act:
var instance = Activator.CreateInstance<Book>(); // construct
var prop = type.GetProperty(nameof(Book.Title));
prop!.SetValue(instance, "1984"); // set a property by name
var value = prop.GetValue(instance); // read it back
var method = type.GetMethod("Describe");
method!.Invoke(instance, [42]); // call a method by name
Note GetProperties() returns public instance properties by default. Anything else needs binding flags:
type.GetFields(BindingFlags.NonPublic | BindingFlags.Instance); // private fields
That capability (reading private state, calling private methods) is exactly why reflection deserves care. It bypasses private, which means it bypasses every assumption the type’s author made.
Two simple, legitimate uses you will actually write:
// map any object to a dictionary, for logging or diagnostics
public static Dictionary<string, object?> ToDictionary(object obj) =>
obj.GetType().GetProperties()
.ToDictionary(p => p.Name, p => p.GetValue(obj));
// find every implementation of an interface in an assembly, and register them
var handlers = typeof(Program).Assembly.GetTypes()
.Where(t => typeof(IHandler).IsAssignableFrom(t) && !t.IsAbstract);
That second one is convention over configuration, the reason you can add a handler class and have the application pick it up with no registration line.
Attributes: metadata you attach
An attribute is a class whose instances are recorded in metadata against a type or member. It does nothing on its own; something must read it.
[Table("books")]
public class Book
{
[Key]
public int Id { get; set; }
[Required, MaxLength(200)]
public string Title { get; set; } = "";
[Obsolete("Use PriceInPence instead")]
public int Price { get; set; }
}
The crucial point for understanding the whole ecosystem: [Required] does not validate anything. It is an inert marker. ASP.NET Core’s model binder reflects over your model, finds the attribute, and then performs validation. [HttpGet] does not route anything; the routing system reads it at startup and builds a table. Attributes are declarative notes; behaviour lives in whatever reads them.
Writing one is ordinary class authoring:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class CsvColumnAttribute : Attribute
{
public string Header { get; }
public int Order { get; init; }
public CsvColumnAttribute(string header) => Header = header;
}
Conventions: derive from Attribute, suffix the name with Attribute (C# lets you omit the suffix at the usage site), seal it, and declare [AttributeUsage] so it can only be applied where it makes sense. Constructor parameters become positional arguments; properties become named ones. Attribute arguments must be compile-time constants, which is why you cannot pass a new List<T>() or a lambda to one.
Putting them together
Here is a small, genuinely useful CSV exporter driven entirely by attributes, the same shape as every serialiser, mapper and validator you have used:
public class Book
{
[CsvColumn("ID", Order = 1)] public int Id { get; set; }
[CsvColumn("Title", Order = 2)] public string Title { get; set; } = "";
[CsvColumn("Price", Order = 3)] public int Price { get; set; }
public string Notes { get; set; } = ""; // no attribute: skipped
}
public static class CsvExporter
{
public static string Export<T>(IEnumerable<T> items)
{
var columns = typeof(T).GetProperties()
.Select(p => (Property: p, Column: p.GetCustomAttribute<CsvColumnAttribute>()))
.Where(x => x.Column is not null)
.OrderBy(x => x.Column!.Order)
.ToList();
var sb = new StringBuilder();
sb.AppendLine(string.Join(",", columns.Select(c => c.Column!.Header)));
foreach (var item in items)
sb.AppendLine(string.Join(",", columns.Select(c => c.Property.GetValue(item))));
return sb.ToString();
}
}
CsvExporter.Export(books) now works for any type, including ones written after the exporter. That is the whole value proposition of reflection plus attributes: a library that adapts to types it has never seen. It is also, notice, using generics, LINQ and a StringBuilder, the series compounding.
Where the framework does this
Almost everything “magic” in .NET is this mechanism:
- ASP.NET Core scans assemblies for controllers, reads
[HttpGet],[Route],[Authorize]and[FromBody], builds a routing table at startup, and binds request data to your parameters by matching names to properties. - EF Core reads your entity classes, maps properties to columns, and uses
[Key],[Table]and[Column](or the fluent API) to override its conventions. System.Text.Jsonreflects over properties to serialise, guided by[JsonPropertyName]and[JsonIgnore].- xUnit / NUnit find test methods by looking for
[Fact]or[Test], which is why a test method needs no registration. - Dependency injection containers inspect constructors to work out what to inject, which is the next post in this series.
- Mocking libraries generate types at runtime that implement your interfaces.
[Obsolete]is read by the compiler rather than at runtime, producing the warning at every call site.
The costs
Reflection is powerful and it is not free.
It is slow. A reflective property read is roughly an order of magnitude slower than a direct one; MethodInfo.Invoke is worse. Fine at startup, fine once per request; a problem inside a loop over a million rows.
The standard fix is to do the reflection once and cache the result:
private static readonly Dictionary<Type, PropertyInfo[]> Cache = new();
var props = Cache.TryGetValue(type, out var cached)
? cached
: Cache[type] = type.GetProperties();
For hot paths, compile a delegate once and call it many times: Expression.Lambda(...).Compile() produces a real Func<T, object> that runs at close to normal speed. That is what fast serialisers and ORMs do internally, and it connects back to the expression trees mentioned in the lambdas post.
It defeats the compiler. type.GetProperty("Titel") returns null instead of failing the build. Renaming a property breaks reflective code silently. Use nameof(Book.Title) wherever you can, so a rename is caught at compile time.
It breaks trimming and AOT. Publishing trimmed or Native AOT removes code that nothing appears to reference, and a type only used via reflection looks unreferenced. This is why modern .NET is moving hard towards source generators: System.Text.Json source generation, compile-time DI registration, and [LoggerMessage] all do at build time what reflection used to do at runtime, with no startup cost and full trimming support. If you are writing new framework-style code today, consider a source generator before reflection.
It bypasses encapsulation. Reading private fields works, and it couples you to another type’s internals. Legitimate in a serialiser or a test helper; a smell almost anywhere else.
The same idea inside Java, Python and Go
Java (Netflix, Android). Class<?>, getDeclaredFields(), setAccessible(true), and annotations (@Entity, @Autowired, @Test) are the direct equivalents. Spring is essentially an enormous reflection-and-annotation engine, and Spring’s slow startup is the cost. Java’s answer is the same as .NET’s: annotation processors that generate code at build time.
Python. Everything is introspectable by default: dir(obj), getattr, obj.__dict__, and decorators as the attribute equivalent. Because Python is dynamic, there is no metadata/runtime distinction at all, which is why Django and Flask feel so declarative, and why large Python codebases lean on type checkers to recover what the compiler would have caught.
Go (Google). The reflect package exists, and struct tags are the attribute analogue:
type Book struct {
ID int `json:"id" db:"book_id"`
Title string `json:"title"`
}
Go’s culture treats reflection as a last resort (it is slow, untyped and un-idiomatic) so code generation via go generate is preferred. That is the direction .NET source generators are taking too.
Rust. No runtime reflection at all. Derive macros run at compile time: #[derive(Serialize)] generates the serialisation code during the build. Zero runtime cost, full type checking, no trimming problem. It is the purest version of the source-generator argument.
TypeScript (Meta). Decorators plus reflect-metadata, used heavily by Angular and NestJS. Types are erased at runtime, so it needs an explicit metadata emit, a reminder of how much .NET gets for free by keeping metadata in the assembly.
Summary
- Every .NET assembly carries metadata about its types and members; reflection is the API for reading and using it at runtime.
typeof(T),obj.GetType()andType.GetType(name)get you aType; from there you can enumerate members, construct instances, and read or invoke by name, including private members.- An attribute is an inert marker recorded in metadata.
[Required]validates nothing by itself; something must reflect over it and act. - Write one by deriving from
Attribute, sealing it, and declaring[AttributeUsage]. Arguments must be compile-time constants. - Reflection plus attributes is how ASP.NET Core routes, EF Core maps, serialisers serialise and test runners discover tests, libraries adapting to types they have never seen.
- Costs: it is roughly an order of magnitude slower than direct access, it turns compile-time errors into runtime nulls, it breaks trimming and AOT, and it bypasses encapsulation.
- Cache reflection results, or compile a delegate with expression trees for hot paths.
- Prefer source generators for new work: the same declarative model, resolved at build time.
Interview questions and answers
1. What is reflection?
The ability to inspect and interact with the metadata that every .NET assembly carries about its types and members at runtime: enumerating properties and methods, reading attributes, constructing instances, and invoking members discovered by name rather than referenced at compile time.
2. What is an attribute and how does it work?
A class deriving from Attribute whose instances are recorded in an assembly’s metadata against a type, member, parameter or assembly. It has no behaviour of its own; it is data. A framework reads it with GetCustomAttribute<T>() and acts on what it finds, which is how declarative markers like [HttpGet] or [Required] produce behaviour.
3. How do you create a custom attribute?
Derive from Attribute, name it with an Attribute suffix, seal it, and apply [AttributeUsage] to declare the valid targets and whether it can be repeated. Constructor parameters become positional arguments and settable properties become named arguments; all values must be compile-time constants.
4. How does ASP.NET Core use reflection and attributes?
At startup it scans assemblies for controller types, reflects over their methods, reads routing and filter attributes such as [HttpGet], [Route] and [Authorize], and builds a routing table. Per request it uses reflection-derived metadata to bind values to action parameters, run validation attributes and invoke the action.
5. What are the performance costs of reflection?
Member lookup and invocation are roughly an order of magnitude slower than direct calls, and repeated GetProperties() or Invoke calls in a loop dominate the work. Mitigate by caching Type and PropertyInfo lookups, or by compiling an expression tree into a delegate once and reusing it, which brings later calls close to direct-call speed.
6. What is the difference between typeof and GetType?
typeof(T) is resolved at compile time from a type name and works without an instance. GetType() is an instance method returning the runtime type of the object, which may be a derived type. For a variable declared as a base type holding a derived instance, they return different results.
7. Can reflection access private members?
Yes, using BindingFlags.NonPublic | BindingFlags.Instance. It is how serialisers restore private state and how some test helpers reach internals. It bypasses encapsulation and couples code to another type’s implementation details, so outside those narrow cases it is a design smell, and it can be blocked in restricted trust environments.
8. Why does reflection cause problems with trimming and Native AOT?
Trimming removes code that no static reference points to. A type or member used only through reflection has no such reference, so it can be removed and the reflective lookup fails at runtime. Workarounds include DynamicDependency attributes and trimming annotations, but the recommended modern approach is a source generator instead.
9. What are source generators and how do they relate to reflection?
Compile-time components that inspect your code and emit additional source during the build. They deliver the same “framework adapts to your types” experience (JSON serialisation, logging, DI registration) with no runtime reflection cost, full compile-time checking, and trimming compatibility. Modern .NET is steadily replacing startup reflection with them.
10. When is reflection the right tool?
When the code genuinely cannot know the types in advance: serialisers, mappers, ORMs, dependency injection containers, test runners, plugin loaders and diagnostic tooling. Inside ordinary application code, reflection is usually a sign that an interface, a generic constraint, or a delegate would express the intent better and be checked by the compiler.