BrandGhost
Custom Attributes in C#: Build, Apply, and Read Them with Reflection

Custom Attributes in C#: Build, Apply, and Read Them with Reflection

Custom Attributes in C#: Build, Apply, and Read Them with Reflection

C# custom attributes are one of those features that look simple on the surface but unlock genuinely powerful runtime behaviors once you understand how to read them with reflection. They're how validation frameworks know which properties to check, how MVC controllers know which routes to handle, and how plugin systems know which types to register -- all without coupling the framework to your specific types. MVC controllers, filters, and conventional routing in ASP.NET Core are heavily attribute-driven (e.g., [HttpGet], [Authorize], [Route]). Minimal APIs use a code-first, method-chaining approach and rely less on attribute discovery at runtime. In .NET 10, the attribute + reflection combination remains the backbone of countless libraries and frameworks, and knowing how to build your own is a skill worth having.

This article walks through creating custom attributes from scratch, applying them in different contexts, and reading them back with reflection at runtime. We'll cover three practical examples -- validation, plugin registration, and command routing -- along with performance notes and a look at when source generators are a better fit.


What Attributes Are (and Aren't)

An attribute is metadata. It's a class that inherits from System.Attribute and can be attached to code elements like classes, methods, properties, parameters, and assemblies. The key word is "attached" -- at compile time, the C# compiler encodes the attribute data into the assembly's metadata tables. At runtime, nothing happens with an attribute unless some code explicitly reads it.

That's the important distinction: attributes are passive. They don't run. They don't intercept method calls. They don't modify behavior on their own. They're annotations that your code -- or a framework's code -- can discover and act on at runtime.

This is different from AOP (aspect-oriented programming) tools like PostSharp or .NET's own MethodImpl attribute (which instructs the JIT directly). Most custom attributes you write are pure metadata, not behavioral hooks.


Creating a Custom Attribute Class

Every custom attribute inherits from System.Attribute. You also apply [AttributeUsage] to the attribute itself to control where it can appear and whether it can be applied multiple times.

[AttributeUsage(
    AttributeTargets.Property,
    AllowMultiple = false,
    Inherited = true)]
public sealed class RequiredAttribute : Attribute
{
    public string ErrorMessage { get; }

    public RequiredAttribute(string errorMessage = "This field is required.")
    {
        ErrorMessage = errorMessage;
    }
}

A few conventions to follow:

  • Name the class with an Attribute suffix. The compiler lets you omit the suffix when you apply it ([Required] vs [RequiredAttribute]), but the class name should include it.
  • Seal the class unless you intend for it to be inherited.
  • Keep constructors simple -- attribute constructor arguments must be compile-time constants (literals, typeof(), or enum values).
  • Properties set via named parameters (e.g., [MyAttr(Name = "foo")]) must have a public setter.

AttributeUsage Parameters in Depth

AttributeUsage controls three things:

AttributeTargets -- a flags enum that specifies where the attribute can be applied. Common values:

Value Applies to
Class Class declarations
Method Method declarations
Property Property declarations
Parameter Method/constructor parameters
Assembly Assembly-level attributes
All Any valid target

You can combine targets: AttributeTargets.Class | AttributeTargets.Interface.

AllowMultiple -- when true, the same attribute can appear on the same target more than once. Useful for things like multiple route templates on a single method. Defaults to false.

Inherited -- when true, a subclass inherits the attribute from its base class when you call GetCustomAttributes(inherit: true). Defaults to true. Note that Inherited = true only applies to class and method attributes -- it has no effect on property or parameter attributes.


Applying Attributes to Different Code Elements

// On a class
[PluginMetadata("OrderProcessor", Version = "1.0")]
public sealed class OrderProcessorPlugin : IPlugin { }

// On a method
[Command("greet")]
public void HandleGreet(string name) { }

// On a property
[Required("Name is required.")]
[MaxLength(100)]
public string Name { get; set; } = string.Empty;

// On a parameter
public void Process([NotNull] string input) { }

// On an assembly (must appear outside namespace declarations, usually in AssemblyInfo.cs)
[assembly: AssemblyVersion("1.0.0.0")]

Multiple attributes on the same target are stacked. Attributes with AllowMultiple = true can appear multiple times.


Reading Attributes with Reflection

The primary API for reading attributes is in System.Reflection. Given a MemberInfo (which Type, MethodInfo, PropertyInfo, etc. all inherit from), you call:

// Get a single attribute, returns null if not found
RequiredAttribute? attr = propertyInfo.GetCustomAttribute<RequiredAttribute>();

// Get all attributes of a type
IEnumerable<MaxLengthAttribute> allAttrs = propertyInfo.GetCustomAttributes<MaxLengthAttribute>();

// Get all attributes, non-generic
object[] allRaw = propertyInfo.GetCustomAttributes(inherit: true);

The inherit parameter on GetCustomAttributes controls whether attributes from base class members are included. For most validation scenarios you want true.

For a deeper look at the broader reflection APIs these calls sit within, see Reflection in C#: 4 Simple But Powerful Code Examples.


Practical Example 1: Validation Attribute

Let's build a minimal object validator that reads attributes from properties and enforces constraints.

The attributes:

[AttributeUsage(AttributeTargets.Property)]
public sealed class RequiredAttribute : Attribute
{
    public string ErrorMessage { get; init; } = "This field is required.";
}

[AttributeUsage(AttributeTargets.Property)]
public sealed class MaxLengthAttribute : Attribute
{
    public int Length { get; }
    public MaxLengthAttribute(int length) => Length = length;
}

The validator:

public static class ObjectValidator
{
    public static IReadOnlyList<string> Validate(object instance)
    {
        var errors = new List<string>();
        Type type = instance.GetType();

        foreach (PropertyInfo prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
        {
            object? value = prop.GetValue(instance);

            var required = prop.GetCustomAttribute<RequiredAttribute>();
            if (required is not null && string.IsNullOrWhiteSpace(value?.ToString()))
            {
                errors.Add($"{prop.Name}: {required.ErrorMessage}");
            }

            var maxLength = prop.GetCustomAttribute<MaxLengthAttribute>();
            if (maxLength is not null && value is string str && str.Length > maxLength.Length)
            {
                errors.Add($"{prop.Name}: Must be {maxLength.Length} characters or fewer.");
            }
        }

        return errors;
    }
}

Usage:

public sealed class CreateOrderRequest
{
    [Required]
    [MaxLength(200)]
    public string CustomerName { get; init; } = string.Empty;

    [Required]
    public string ProductId { get; init; } = string.Empty;
}

var request = new CreateOrderRequest { CustomerName = "", ProductId = "P001" };
var errors = ObjectValidator.Validate(request);
// errors contains: "CustomerName: This field is required."

This is the pattern behind FluentValidation, DataAnnotations, and every other attribute-based validator in the .NET ecosystem.


Practical Example 2: Plugin Registration Attribute

Attribute scanning is a natural fit for plugin discovery. When you load an assembly and need to find all types that are plugins, an attribute provides a clean registration mechanism without forcing every plugin type to implement a marker interface.

The attribute:

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class PluginMetadataAttribute : Attribute
{
    public string Name { get; }
    public string Description { get; init; } = string.Empty;
    public string Version { get; init; } = "1.0.0";

    public PluginMetadataAttribute(string name)
    {
        Name = name;
    }
}

Scanning an assembly for plugins:

public static IReadOnlyList<(Type Type, PluginMetadataAttribute Metadata)> DiscoverPlugins(
    Assembly assembly)
{
    return assembly.GetTypes()
        .Where(t => t.IsClass && !t.IsAbstract)
        .Select(t => (Type: t, Metadata: t.GetCustomAttribute<PluginMetadataAttribute>()))
        .Where(x => x.Metadata is not null)
        .Select(x => (x.Type, Metadata: x.Metadata!))
        .ToList();
}

A plugin implementation:

[PluginMetadata("OrderProcessor",
    Description = "Handles order processing pipeline",
    Version = "2.1.0")]
public sealed class OrderProcessorPlugin : IPlugin
{
    public void Execute(IPluginContext context) { /* ... */ }
}

This approach is explored in depth in the context of full plugin systems in Plugin Architecture in C#: The Complete Guide to Extensible .NET Applications. The assembly scanning patterns that power this discovery are also covered in Assembly Scanning in Needlr: Filtering and Organizing Type Discovery.


Practical Example 3: Command Routing via Attribute

Minimal command routers in CLI tools or chatbot frameworks commonly use attributes to map string commands to handler methods. Here's a stripped-down version:

The attribute:

[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class CommandAttribute : Attribute
{
    public string Name { get; }
    public CommandAttribute(string name) => Name = name;
}

Building a command registry:

public sealed class CommandRegistry
{
    private readonly Dictionary<string, MethodInfo> _commands = new(StringComparer.OrdinalIgnoreCase);
    private readonly object _handler;

    public CommandRegistry(object handler)
    {
        _handler = handler;

        foreach (MethodInfo method in handler.GetType()
            .GetMethods(BindingFlags.Public | BindingFlags.Instance))
        {
            foreach (var attr in method.GetCustomAttributes<CommandAttribute>())
            {
                _commands[attr.Name] = method;
            }
        }
    }

    public bool TryInvoke(string commandName, string[] args)
    {
        if (!_commands.TryGetValue(commandName, out MethodInfo? method))
        {
            return false;
        }

        method.Invoke(_handler, new object[] { args });
        return true;
    }
}

A handler class:

public sealed class BotCommandHandler
{
    [Command("help")]
    [Command("?")]  // AllowMultiple = true enables this
    public void HandleHelp(string[] args)
    {
        Console.WriteLine("Available commands: help, greet, status");
    }

    [Command("greet")]
    public void HandleGreet(string[] args)
    {
        string name = args.Length > 0 ? args[0] : "World";
        Console.WriteLine($"Hello, {name}!");
    }
}

Note the double [Command] attribute on HandleHelp -- that works because AllowMultiple = true on the CommandAttribute class.


Performance Notes: Cache Your Attribute Lookups

Calling GetCustomAttribute<T>() on a MemberInfo is a reflection operation. It scans the member's metadata every time. In a tight loop -- say, validating thousands of objects per second -- this adds up fast.

The fix is straightforward: cache the results keyed on Type or MemberInfo:

public static class AttributeCache
{
    private static readonly ConcurrentDictionary<PropertyInfo, RequiredAttribute?> _required = new();

    public static RequiredAttribute? GetRequired(PropertyInfo prop)
        => _required.GetOrAdd(prop, static p => p.GetCustomAttribute<RequiredAttribute>());
}

The general pattern of caching reflection metadata -- including attributes -- is covered in detail alongside FrozenDictionary in the companion article on reflection caching in .NET 10.


Source Generator Alternative for Compile-Time Attribute Processing

Reading attributes at runtime via reflection is powerful but pays a runtime cost. .NET's Roslyn source generators let you process attributes at compile time and emit code that has no runtime overhead at all.

The tradeoff: source generators are more complex to write and require your types to be available as source (not just as compiled assemblies). For scenarios where you control all the types and performance is critical, source generators are worth it. For plugin systems where types arrive in external assemblies at runtime, reflection is the only option.

The Needlr library's evolution from reflection to source generation is a good case study: Source Generation vs Reflection in Needlr walks through exactly when and why you'd make that shift.


FAQ

What is the difference between a C# attribute and a regular property?

A regular property holds data at runtime and is accessible on an instance of an object. An attribute holds metadata at compile time -- it's encoded in the assembly's metadata tables and retrieved via reflection. Attributes don't affect runtime behavior unless code explicitly reads them with GetCustomAttribute.

Can I use variables in attribute constructors?

No. Attribute constructor arguments must be compile-time constants: string literals, numeric literals, typeof() expressions, nameof() expressions, or enum values. Variables and method return values are not allowed. This constraint exists because the compiler encodes attribute arguments directly into the assembly metadata at compile time.

What happens if I apply an attribute where AttributeUsage doesn't allow it?

You get a compile-time error. The C# compiler enforces AttributeUsage restrictions. If you try to put a [Property]-only attribute on a method, the compiler rejects it with an error like "Attribute 'RequiredAttribute' is not valid on this declaration type."

How do I check if an attribute exists without retrieving it?

Use IsDefined:

bool hasRequired = propertyInfo.IsDefined(typeof(RequiredAttribute), inherit: true);

IsDefined is slightly faster than GetCustomAttribute when you only need to know presence, not the attribute's data.

Is GetCustomAttribute thread-safe?

Yes. The reflection APIs for reading metadata are thread-safe in .NET. Multiple threads can call GetCustomAttribute on the same MemberInfo concurrently without issues. The underlying metadata is read-only once the assembly is loaded.

What is AllowMultiple and when should I set it to true?

AllowMultiple = true lets the same attribute appear more than once on a single code element. Use it when your attribute represents a collection of values -- multiple route templates, multiple allowed roles, multiple command aliases. When AllowMultiple = false (the default), applying the same attribute twice is a compile-time error.

How do I handle attributes on base class members?

Pass inherit: true to GetCustomAttributes or IsDefined. This traverses the inheritance chain and includes attributes from base class members. Note that this behavior applies to class and method attributes only -- PropertyInfo.GetCustomAttributes(inherit: true) does not traverse the base class property chain automatically due to how property metadata is stored.


Conclusion

Custom attributes in C# are a clean way to attach metadata to your types and members and act on it at runtime. The pattern is consistent: define a class inheriting from Attribute, apply [AttributeUsage] to control scope, and read it back with GetCustomAttribute<T>() at runtime.

The three examples here -- validation, plugin registration, and command routing -- cover the most common real-world attribute patterns. They all share the same core loop: get the type's members, check each member for an attribute, and take action based on what you find.

Cache your attribute lookups whenever they're in a hot path. Consider source generators when your types are source-available and you need zero runtime overhead. And remember that attributes are passive -- they're just data until something reads them.

C# Source Generator Attributes: Generating Code with ForAttributeWithMetadataName

Learn how C# source generator attributes trigger code generation in .NET. Complete guide to the marker attribute pattern with ForAttributeWithMetadataName.

How to Create Custom Plugins for Semantic Kernel in C#

Learn how to create custom plugins for Semantic Kernel in C# step by step. Build native function plugins, prompt plugins, and multi-function plugin classes with KernelFunction, Description attributes, and dependency injection.

C# Reflection: The Complete .NET 10 Guide

Master C# reflection in .NET 10 -- learn Type, PropertyInfo, MethodInfo, performance caching with FrozenDictionary, and when to avoid reflection entirely.

An error has occurred. This application may no longer respond until reloaded. Reload