Mert Özen Further With Every Line
Backend Advanced C# — #06

Attributes and Reflection in C#: Writing and Reading Your Own Attributes

Mert Özen Nov 7, 2026 11 min 4 views
Attributes and Reflection in C#: Writing and Reading Your Own Attributes

An attribute living in metadata and created when read, Obsolete and CS0618, AttributeUsage, positional and named arguments, decimal not being a valid attribute parameter and CS0181, a small validator with reflection, discovering IDiscountRule types and running them with Activator and Invoke, a BenchmarkDotNet comparison of GetValue, a cached PropertyInfo and a typed delegate, and the limits of trimming and source generators.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

We wrote our own attribute and put a Console.WriteLine in its constructor. The Book class is marked with this attribute. The program creates a book, then reads the attribute twice. How many times, and where, does the "attribute constructed" line appear? What does the last line print?

using System.Reflection;

Console.WriteLine("start");
Book book = new Book();
Console.WriteLine("book created");

DiscountAttribute? first = typeof(Book).GetCustomAttribute<DiscountAttribute>();
DiscountAttribute? second = typeof(Book).GetCustomAttribute<DiscountAttribute>();
Console.WriteLine(ReferenceEquals(first, second));

[Discount(10)]
public class Book
{
}

public sealed class DiscountAttribute : Attribute
{
    public DiscountAttribute(int percent)
    {
        Percent = percent;
        Console.WriteLine($"attribute constructed: {percent}");
    }

    public int Percent { get; }
}

Since we wrote the attribute on top of the class, you might think it's created along with the book. Let's look at the output.

Attributes: Information Attached to Code

start
book created
attribute constructed: 10
attribute constructed: 10
False

The attribute made no sound when the book was created. An attribute is information the compiler writes into the assembly's metadata: "Book has a Discount on it, with argument 10". At runtime that information just sits there, and no object is created until someone asks for it. Each call to GetCustomAttribute reads the metadata, runs the constructor and returns a new instance; the two calls return different objects. That's why you shouldn't put business logic in an attribute's constructor and should keep it a simple data carrier.

Two different parties can read an attribute. Some are read by the compiler and change how code is built; NotNullWhen from the previous post was like that, and so is [Obsolete]:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Book book = new Book("Dune", 45.90m);
Console.WriteLine(book.PriceWithTax());

public class Book
{
    public Book(string title, decimal price)
    {
        Title = title;
        Price = price;
    }

    public string Title { get; }
    public decimal Price { get; }

    [Obsolete("Use PriceWithVat; the tax rate is no longer fixed.")]
    public decimal PriceWithTax() => Price * 1.18m;

    public decimal PriceWithVat(decimal rate) => Price * (1 + rate);
}
Program.cs(6,19): warning CS0618: 'Book.PriceWithTax()' is obsolete: 'Use PriceWithVat; the tax rate is no longer fixed.'
54.1620

Others are read at runtime by frameworks: ASP.NET Core looks at [HttpGet] to build routes, System.Text.Json looks at [JsonPropertyName] to choose the JSON field name, EF Core looks at [Key] to find the primary key. All of them work through the same mechanism: reflection.

Writing Your Own Attribute

An attribute is an ordinary class deriving from System.Attribute. By convention its name ends with Attribute, and that suffix is left out when you use it: DiscountAttribute is used as [Discount]. [AttributeUsage] decides where it can go: targets such as class, method or property, whether it can be applied more than once to the same place (AllowMultiple, false by default), and whether it passes on to derived classes (Inherited, true by default).

Because attribute arguments are written into metadata at compile time, not every type can be used. Not even decimal, the natural choice for money:

[AttributeUsage(AttributeTargets.Property)]
public sealed class MinPriceAttribute : Attribute
{
    public MinPriceAttribute(decimal value)
    {
        Value = value;
    }

    public decimal Value { get; }
}

public class Book
{
    [MinPrice(0.99m)]
    public decimal Price { get; set; }
}
Program.cs(14,6): error CS0181: Attribute constructor parameter 'value' has type 'decimal', which is not a valid attribute parameter type

The types you can use are bool, char, string, the integer types, float and double, Type, enums, object, and single-dimensional arrays of these. The values must be constants too: you can't pass a variable or new Book(). If you need a decimal, take a double or a string and convert it inside the attribute; that's what we'll do in the validator below.

Reading Attributes with Reflection: A Small Validator

Reflection is querying the structure of types at runtime: which properties does this type have, what are their types, which attributes sit on them, what are their values on this object. The entry point is the Type object; you get it with typeof(Book) from a type you know at compile time, or with instance.GetType() from the actual type of an object you hold. Now let's mark properties with attributes and write a validator that checks them through reflection:

using System.Globalization;
using System.Reflection;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Book[] books =
{
    new Book { Isbn = "9780441172719", Title = "Dune", Price = 45.90m, Stock = 3 },
    new Book { Isbn = "123", Title = " ", Price = -5m, Stock = 2000 }
};

foreach (Book book in books)
{
    List<string> errors = SimpleValidator.Validate(book);
    Console.WriteLine(errors.Count == 0 ? $"{book.Isbn}: valid" : $"{book.Isbn}: {string.Join("; ", errors)}");
}

public class Book
{
    [Length(13)]
    public string Isbn { get; set; } = "";

    [RequiredText]
    public string Title { get; set; } = "";

    [Between(0, 10_000, Message = "Price must not be negative")]
    public decimal Price { get; set; }

    [Between(0, 1_000)]
    public int Stock { get; set; }
}

[AttributeUsage(AttributeTargets.Property)]
public sealed class RequiredTextAttribute : Attribute
{
}

[AttributeUsage(AttributeTargets.Property)]
public sealed class LengthAttribute : Attribute
{
    public LengthAttribute(int exact)
    {
        Exact = exact;
    }

    public int Exact { get; }
}

[AttributeUsage(AttributeTargets.Property)]
public sealed class BetweenAttribute : Attribute
{
    // double, because decimal isn't allowed
    public BetweenAttribute(double min, double max)
    {
        Min = min;
        Max = max;
    }

    public double Min { get; }
    public double Max { get; }
    public string? Message { get; set; }
}

public static class SimpleValidator
{
    public static List<string> Validate(object instance)
    {
        List<string> errors = new List<string>();

        foreach (PropertyInfo property in instance.GetType().GetProperties())
        {
            object? value = property.GetValue(instance);

            if (property.GetCustomAttribute<RequiredTextAttribute>() is not null && string.IsNullOrWhiteSpace(value as string))
            {
                errors.Add($"{property.Name} is required");
            }

            if (property.GetCustomAttribute<LengthAttribute>() is { } length && value is string text && text.Length != length.Exact)
            {
                errors.Add($"{property.Name} must be {length.Exact} characters");
            }

            if (property.GetCustomAttribute<BetweenAttribute>() is { } between && value is not null)
            {
                double number = Convert.ToDouble(value, CultureInfo.InvariantCulture);
                if (number < between.Min || number > between.Max)
                {
                    errors.Add(between.Message ?? $"{property.Name} must be between {between.Min} and {between.Max}");
                }
            }
        }

        return errors;
    }
}
9780441172719: valid
123: Isbn must be 13 characters; Title is required; Price must not be negative; Stock must be between 0 and 1000

You saw two kinds of arguments when using the attributes. The values in Between(0, 10_000) are positional arguments passed to the constructor in order; Message = "..." is a named argument assigned to a public property by name. Putting required information in the constructor and optional information in properties is an established habit.

SimpleValidator knows no type; all it says is "look at every property and decide based on the attribute on it". The same method works for Customer or Order too. ASP.NET Core's model validation is the mature form of exactly this idea, and its ready-made attributes live in System.ComponentModel.DataAnnotations; in a real project you use those, not your own validator. The point of this example is to see the mechanism.

Discovering and Invoking Types

Reflection doesn't only read; it can find types, create objects from them and call their methods. Instead of adding discount rules to a list by hand, let's find every class in the assembly that implements IDiscountRule and apply them in the order given in their attributes:

using System.Globalization;
using System.Reflection;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

List<IDiscountRule> rules = typeof(Program).Assembly.GetTypes()
    .Where(type => typeof(IDiscountRule).IsAssignableFrom(type) && type is { IsClass: true, IsAbstract: false })
    .Select(type => (Type: type, Info: type.GetCustomAttribute<DiscountRuleAttribute>()))
    .Where(rule => rule.Info is { Enabled: true })
    .OrderBy(rule => rule.Info!.Order)
    .Select(rule => (IDiscountRule)Activator.CreateInstance(rule.Type)!)
    .ToList();

decimal price = 100m;
foreach (IDiscountRule rule in rules)
{
    price = rule.Apply(price);
    Console.WriteLine($"{rule.GetType().Name,-16} {price}");
}

MethodInfo apply = typeof(CouponRule).GetMethod(nameof(CouponRule.Apply))!;
object? result = apply.Invoke(new CouponRule(), new object[] { 20m });
Console.WriteLine($"invoked: {result}");

public interface IDiscountRule
{
    decimal Apply(decimal price);
}

[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class DiscountRuleAttribute : Attribute
{
    public DiscountRuleAttribute(int order)
    {
        Order = order;
    }

    public int Order { get; }
    public bool Enabled { get; set; } = true;
}

[DiscountRule(2)]
public class CouponRule : IDiscountRule
{
    public decimal Apply(decimal price) => Math.Max(0, price - 15m);
}

[DiscountRule(1)]
public class MemberRule : IDiscountRule
{
    public decimal Apply(decimal price) => price * 0.90m;
}

[DiscountRule(3, Enabled = false)]
public class BlackFridayRule : IDiscountRule
{
    public decimal Apply(decimal price) => price * 0.50m;
}
MemberRule       90.00
CouponRule       75.00
invoked: 5

Adding a new rule now just means writing a new class; you don't touch the code that builds the list. BlackFridayRule was switched off in its attribute, so it was never applied. Activator.CreateInstance created objects through the parameterless constructor; MethodInfo.Invoke took the arguments in an object[] and returned the result as object. Dependency injection containers use a more advanced form of this technique: they look at constructor parameters through reflection to decide which services to pass in.

The Cost of Reflection

This flexibility has a price. I summed the prices of a thousand books in four different ways and measured them with BenchmarkDotNet: direct property access, a delegate created once from the getter and kept, GetValue with a kept PropertyInfo, and looking up with GetProperty every time and then GetValue:

[MemoryDiagnoser]
[InProcess]
public class PropertyAccessBenchmarks
{
    private readonly Book[] _books = Enumerable.Range(1, 1_000)
        .Select(i => new Book { Title = $"Book {i}", Price = i % 50 + 0.99m })
        .ToArray();

    private readonly PropertyInfo _priceProperty = typeof(Book).GetProperty(nameof(Book.Price))!;
    private readonly Func<Book, decimal> _priceGetter;

    public PropertyAccessBenchmarks()
    {
        // build a typed delegate from the getter method once
        _priceGetter = _priceProperty.GetMethod!.CreateDelegate<Func<Book, decimal>>();
    }

    [Benchmark(Baseline = true)]
    public decimal Direct()
    {
        decimal total = 0;
        foreach (Book book in _books)
        {
            total += book.Price;
        }
        return total;
    }

    [Benchmark]
    public decimal CachedDelegate()
    {
        decimal total = 0;
        foreach (Book book in _books)
        {
            total += _priceGetter(book);
        }
        return total;
    }

    [Benchmark]
    public decimal CachedPropertyInfo()
    {
        decimal total = 0;
        foreach (Book book in _books)
        {
            total += (decimal)_priceProperty.GetValue(book)!;
        }
        return total;
    }

    [Benchmark]
    public decimal LookupEveryTime()
    {
        decimal total = 0;
        foreach (Book book in _books)
        {
            total += (decimal)typeof(Book).GetProperty("Price")!.GetValue(book)!;
        }
        return total;
    }
}
BenchmarkDotNet v0.15.8, macOS 27.0.1 (26A434) [Darwin 27.0.0]
Apple M5, 1 CPU, 10 logical and 10 physical cores
.NET SDK 10.0.402
  [Host] : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a

| Method             | Mean      | Error     | StdDev    | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|------------------- |----------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| Direct             |  2.783 us | 0.0220 us | 0.0205 us |  1.00 |    0.01 |      - |         - |          NA |
| CachedDelegate     |  3.889 us | 0.0060 us | 0.0050 us |  1.40 |    0.01 |      - |         - |          NA |
| CachedPropertyInfo |  8.920 us | 0.0298 us | 0.0249 us |  3.20 |    0.02 | 3.8147 |   32000 B |          NA |
| LookupEveryTime    | 16.050 us | 0.0992 us | 0.0928 us |  5.77 |    0.05 | 3.8147 |   32000 B |          NA |

The project targets net6.0, was built in Release mode, and since this machine has no .NET 6 runtime it ran in-process on .NET 8.0.25 via DOTNET_ROLL_FORWARD=Major. Absolute numbers vary from machine to machine; look at the ratios.

GetValue is 3.2 times slower than direct access and allocates 32 bytes on every call: decimal is a value type and gets boxed when returned as object. Looking it up with GetProperty every time nearly doubles the time again. The typed delegate built once from the getter, on the other hand, is very close to direct access and allocates nothing. The lesson: use reflection for discovery, and keep the result. Find the PropertyInfo once, and if it'll be called often, build a delegate from it. Serializers and ORMs work exactly this way.

Reflection: Where and When

In everyday application code you rarely write reflection directly. But most of the frameworks you use run on it: model binding, validation, JSON serialization, dependency injection, EF Core building its model. Understanding reflection also explains why these frameworks set certain rules: they want public properties, they want parameterless constructors, they look at attributes.

There are two modern limits. The first is trimming and Native AOT: tools that remove unused code at publish time can't see that a member reached only through reflection is used, and may delete it. .NET produces warnings at publish time for these cases. The second is source generators: they move the discovery reflection does at runtime to compile time. The source generation support that came to System.Text.Json with .NET 6 is one example. We'll write our own source generator in a later post of the series.

Common Pitfalls

Writing member names as strings. GetProperty("Price") silently returns null when the property is renamed, and blows up at runtime because of the !. Use nameof(Book.Price) wherever you can.

Relying on order. There's no guarantee that GetProperties() returns properties in any particular order; the documentation says so explicitly. Don't tie a CSV's column order to it just because they come in declaration order today. If order matters, state it yourself with an attribute.

Breaking encapsulation. With BindingFlags.NonPublic you can reach private fields. Doing this outside of tests or diagnostics means depending on an internal detail that will disappear in the library's next version.

Not recognizing the exception from Invoke. If a method called through reflection throws, you don't get the exception directly but as the InnerException of a TargetInvocationException. Your catch block has to be written for that.

A Small Experiment

★☆☆ Add an attribute like [Pattern("^978")] to SimpleValidator and check with Regex.IsMatch that the ISBN starts with 978.

★★☆ Add AllowMultiple = true and a string Campaign parameter to DiscountRuleAttribute. Bind the same rule to two campaigns and, using GetCustomAttributes<DiscountRuleAttribute>(), apply only the rules of the "Summer" campaign.

★★★ Write a cache (Dictionary<Type, ...>) that does the GetProperties and GetCustomAttribute work, done on every Validate call in the validator, once per type. Compare before and after with BenchmarkDotNet.

The fastest reflection path in the benchmark was building a delegate from a method. But can you build a delegate from scratch at runtime, as a tree of code? In the next post we move on to expression trees: holding a lambda as data to inspect instead of code to run, and how EF Core turns LINQ queries into SQL.