Skip to main content

📝 Lesson 5.1: Attributes and Reflection

How does a serializer know your property names, or a test runner find your tests? Through attributes (metadata attached to code) and reflection (inspecting that code at runtime). Together they enable the "magic" behind many .NET frameworks.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Apply built-in attributes and write your own custom attributes
  • Inspect types and members at runtime with reflection
  • Read attribute data from code via reflection
  • Create objects and invoke members dynamically
  • Explain reflection's costs and when to avoid it

Estimated Time: 75 minutes

Project: Build a tiny attribute-driven validation framework.

In This Lesson

Metadata and Introspection

Compiled .NET code carries rich metadata: the names, types, and structure of everything you wrote. Attributes let you attach your own metadata; reflection lets code read all of it — its own or another assembly's — while running.

📖 Definitions

Attribute: a declarative tag that attaches metadata to code (a class, method, property, …), written in square brackets like [Obsolete].

Reflection: inspecting types and members — and reading their attributes — at runtime, plus creating instances and invoking members dynamically.

graph LR A["Your code + attributes"] -->|"compiled into"| B["Metadata in the assembly"] B -->|"read at runtime by"| C["Reflection"] C --> D["Serializers, validators,
DI, test runners"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style D fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

This pair is how System.Text.Json (Intermediate) discovers your properties, how xUnit (Intermediate) finds [Fact] methods, and how DI containers wire things up (next lesson).

Attributes

You've already used attributes: [Fact], [Benchmark], [MemoryDiagnoser]. .NET ships many built-in ones too. You apply an attribute by placing it in brackets above the target:

public class LegacyApi
{
    [Obsolete("Use ProcessV2 instead.")]   // compiler warns anyone who calls this
    public void Process() { }

    public void ProcessV2() { }
}
Built-in attributeEffect
[Obsolete("...")]Warns (or errors) when the member is used
[Serializable]Marks a type as serializable (legacy serialization)
[JsonPropertyName("...")]Customizes a property's JSON name
[Flags]Marks an enum as a bit-flags combination
[CallerMemberName]Compiler fills in the caller's name

💡 Attributes are just data

Applying an attribute doesn't run any code by itself — it records data in the metadata. Something else (the compiler, a framework, or your reflection code) reads it later and decides what to do. An attribute with no reader has no effect.

Custom Attributes

You define an attribute as a class deriving from System.Attribute (inheritance from the intro). Constructor parameters become positional arguments; properties become named arguments. [AttributeUsage] restricts where it can be applied:

// Restrict this attribute to properties
[AttributeUsage(AttributeTargets.Property)]
public class RequiredAttribute : Attribute
{
    // no data needed — its presence is the signal
}

[AttributeUsage(AttributeTargets.Property)]
public class RangeAttribute : Attribute
{
    public int Min { get; }
    public int Max { get; }
    public RangeAttribute(int min, int max) => (Min, Max) = (min, max);
}

Now apply them to a model. By convention you can drop the Attribute suffix when applying ([Required] means RequiredAttribute):

public class Product
{
    [Required]
    public string Name { get; set; } = "";

    [Range(1, 1000)]
    public int Quantity { get; set; }
}

✅ The attribute is a declaration of intent

By itself, [Range(1, 1000)] does nothing at runtime — it just records "this property should be between 1 and 1000." The validation engine we build with reflection below is what reads that intent and enforces it. This clean separation is why attribute-driven frameworks are so flexible.

Reflection Basics

Reflection starts with a Type object — obtained via typeof(X) (compile-time) or obj.GetType() (runtime). From a Type you can enumerate members:

using System.Reflection;

Type type = typeof(Product);

Console.WriteLine($"Type: {type.Name}");           // Product

foreach (PropertyInfo prop in type.GetProperties())
{
    Console.WriteLine($"  {prop.PropertyType.Name} {prop.Name}");
}
// String Name
// Int32 Quantity

foreach (MethodInfo method in type.GetMethods())
{
    Console.WriteLine($"  method: {method.Name}");
}

You can also read and write property values on an instance dynamically:

var product = new Product { Name = "Widget", Quantity = 5 };

PropertyInfo nameProp = typeof(Product).GetProperty("Name")!;
object? value = nameProp.GetValue(product);        // "Widget"
nameProp.SetValue(product, "Gadget");              // sets Name to "Gadget"

💡 typeof vs. GetType()

typeof(Product) is resolved at compile time from the type name. obj.GetType() returns the actual runtime type of an object — which may be a derived type. For polymorphic code, GetType() tells you what an object really is.

Reading Attributes

Now the two halves combine: reflection reads the attributes you attached. GetCustomAttribute<T>() returns the attribute instance (or null if absent), giving you access to its data:

using System.Reflection;

// A generic validator: reflect over properties, honor Required and Range
static List<string> Validate(object obj)
{
    var errors = new List<string>();
    Type type = obj.GetType();

    foreach (PropertyInfo prop in type.GetProperties())
    {
        object? value = prop.GetValue(obj);

        // [Required] present and value missing?
        if (prop.GetCustomAttribute<RequiredAttribute>() is not null)
        {
            if (value is null || (value is string s && string.IsNullOrWhiteSpace(s)))
                errors.Add($"{prop.Name} is required.");
        }

        // [Range] present and value out of bounds?
        var range = prop.GetCustomAttribute<RangeAttribute>();
        if (range is not null && value is int n && (n < range.Min || n > range.Max))
        {
            errors.Add($"{prop.Name} must be between {range.Min} and {range.Max}.");
        }
    }
    return errors;
}
var product = new Product { Name = "", Quantity = 5000 };

foreach (string error in Validate(product))
{
    Console.WriteLine(error);
}
// Name is required.
// Quantity must be between 1 and 1000.

✅ You just built a framework

This Validate works on any object, with no per-type code — it discovers rules from attributes at runtime. That's exactly how ASP.NET model validation, EF Core mapping, and serializers operate. Attributes declare intent; reflection enforces it generically.

Creating & Invoking Dynamically

Reflection can also create objects and call methods when you only know the type at runtime — the core trick behind plugin systems and DI containers.

using System.Reflection;

// Create an instance from a Type (must have a matching constructor)
Type t = typeof(Product);
object? instance = Activator.CreateInstance(t);      // new Product()

// Find and invoke a method by name
MethodInfo? method = t.GetMethod("ToString");
object? result = method?.Invoke(instance, null);     // calls instance.ToString()
Console.WriteLine(result);

You can also discover types across an assembly — e.g. "find every class implementing IPlugin" — and instantiate them:

var pluginTypes = Assembly.GetExecutingAssembly()
    .GetTypes()
    .Where(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract);

foreach (Type pt in pluginTypes)
{
    var plugin = (IPlugin)Activator.CreateInstance(pt)!;
    plugin.Run();
}

💡 This is how DI containers work

A dependency injection container (next lesson) uses exactly these techniques: reflect over a type's constructor, figure out what it needs, create those dependencies, and invoke the constructor. Understanding reflection demystifies the "magic" of frameworks.

The Cost of Reflection

Reflection is powerful but comes with real trade-offs — a fitting bookend to the performance module you just finished.

⚠️ Reflection is slow and unchecked

  • Performance: reflective member access and invocation are far slower than direct calls (often 10–100×), and can allocate. Avoid it on hot paths.
  • No compile-time safety: GetMethod("Prcoess") with a typo compiles fine and fails at runtime — you lose the compiler's checks.
  • Trimming/AOT: reflecting over types can break when apps are trimmed or ahead-of-time compiled, since the tools can't see the dynamic usage.

✅ Use it wisely

  • Do it once, cache the result. Reflect at startup (e.g. build a validation plan per type) and reuse it, rather than reflecting on every call.
  • Prefer it for framework/infrastructure code (serialization, DI, tooling), not tight application loops.
  • Consider source generators — a modern alternative that produces the equivalent code at compile time, giving reflection-like flexibility with direct-call speed and AOT safety. (System.Text.Json and many libraries now offer source-generated modes.)

Exercise & Quiz

🏋️ Exercise: An Attribute-Driven Describer

Objective: Define a custom attribute and use reflection to read it.

Instructions:

  1. Create a new project called Reflection.
  2. Define [AttributeUsage(AttributeTargets.Property)] class DisplayNameAttribute : Attribute with a string Name set via its constructor.
  3. Create a class Book with properties Title and Author, each tagged [DisplayName("...")] with a friendly label; leave a third property Isbn untagged.
  4. Write void Describe(object obj) that reflects over the properties and prints, for each, the friendly display name (from the attribute) if present, otherwise the property name — followed by its value.
  5. Call it on a Book instance.

Starter Code:

using System.Reflection;

var book = new Book { Title = "C# in Depth", Author = "Jon Skeet", Isbn = "12345" };
Describe(book);

[AttributeUsage(AttributeTargets.Property)]
class DisplayNameAttribute : Attribute
{
    public string Name { get; }
    public DisplayNameAttribute(string name) => Name = name;
}

class Book
{
    [DisplayName("Book Title")]
    public string Title { get; set; } = "";
    [DisplayName("Written By")]
    public string Author { get; set; } = "";
    public string Isbn { get; set; } = "";
}

static void Describe(object obj)
{
    // TODO: for each property, use [DisplayName] if present else the property name; print label + value
}
💡 Hint

For each PropertyInfo prop: var attr = prop.GetCustomAttribute<DisplayNameAttribute>();, then string label = attr?.Name ?? prop.Name; (null-coalescing from Intermediate), and prop.GetValue(obj) for the value.

✅ Solution
using System.Reflection;

var book = new Book { Title = "C# in Depth", Author = "Jon Skeet", Isbn = "12345" };
Describe(book);

[AttributeUsage(AttributeTargets.Property)]
class DisplayNameAttribute : Attribute
{
    public string Name { get; }
    public DisplayNameAttribute(string name) => Name = name;
}

class Book
{
    [DisplayName("Book Title")]
    public string Title { get; set; } = "";
    [DisplayName("Written By")]
    public string Author { get; set; } = "";
    public string Isbn { get; set; } = "";
}

static void Describe(object obj)
{
    foreach (PropertyInfo prop in obj.GetType().GetProperties())
    {
        var attr = prop.GetCustomAttribute<DisplayNameAttribute>();
        string label = attr?.Name ?? prop.Name;
        Console.WriteLine($"{label}: {prop.GetValue(obj)}");
    }
}

Output:

Book Title: C# in Depth
Written By: Jon Skeet
Isbn: 12345

Tagged properties show their friendly label; the untagged Isbn falls back to its property name.

🎯 Quick Quiz

Question 1: What does applying an attribute like [Range(1, 10)] do on its own?

Question 2: How do you read a custom attribute from a property via reflection?

Question 3: What is a key drawback of reflection?

Summary

🎉 Key Takeaways

  • Attributes attach declarative metadata to code; they're inert data until something reads them.
  • Write a custom attribute by deriving from Attribute; use [AttributeUsage] to restrict targets, constructor params for positional data.
  • Reflection (via Type from typeof/GetType()) inspects members and reads attributes with GetCustomAttribute<T>().
  • Reflection can create instances (Activator.CreateInstance) and invoke members dynamically — the basis of serializers, DI, and test runners.
  • It's slow and unchecked — cache reflected results, keep it in framework code, and consider source generators for compile-time speed.

📚 Additional Resources

🚀 What's Next?

You've seen how frameworks inspect and wire up code. Next, we use that to structure applications cleanly. In Lesson 5.2: Dependency Injection, you'll decouple your classes and let a container assemble them.

🎉 Metaprogramming unlocked!

You can inspect and drive code at runtime. Next: architecting apps with dependency injection.