Introduction
It's tempting to hard-code framework behaviors directly into your classes. It works until you need to change how validation runs, how logging happens, or how serialization behaves across dozens of classes. Suddenly, you're editing every file to make one conceptual change.
Custom attributes provide a cleaner pattern. They let you declare behavior through metadata rather than procedural code. ASP.NET uses attributes to mark API routes. Validation frameworks use them to specify rules. Testing frameworks use them to identify test methods. Your code describes what it needs, and frameworks discover that metadata through reflection.
You'll learn how to create your own attribute classes, control where they can be used, read them through reflection, and build practical systems that leverage attributes for validation, logging, and configuration.
Creating Your First Custom Attribute
A custom attribute is a class that inherits from System.Attribute. The class name typically ends with "Attribute", though you can omit that suffix when applying it. You define properties or constructor parameters to hold the attribute's data.
The simplest custom attribute has no parameters. It serves as a marker that flags a class or method for special treatment. More complex attributes carry data that influences how the attributed element behaves.
using System;
// Define a custom attribute
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method,
AllowMultiple = true)]
public class AuthorAttribute : Attribute
{
public string Name { get; }
public string Date { get; set; }
public int Version { get; set; }
public AuthorAttribute(string name)
{
Name = name;
}
}
// Apply the attribute
[Author("Alice Johnson", Date = "2025-11-04", Version = 1)]
public class Calculator
{
[Author("Bob Smith", Version = 2)]
public int Add(int a, int b)
{
return a + b;
}
}
The AttributeUsage attribute controls where your custom attribute can be applied. AttributeTargets.Class means it can decorate classes, while AttributeTargets.Method allows it on methods. The pipe operator combines multiple targets. AllowMultiple permits applying the same attribute multiple times to one element.
The Name property uses a constructor parameter, making it required. Date and Version use property initializers, making them optional named parameters. This pattern gives you flexibility in how developers apply your attribute.
Controlling Attribute Usage
The AttributeUsage attribute determines where your custom attribute is valid and how it behaves with inheritance. You specify targets using the AttributeTargets enumeration, which includes Class, Method, Property, Field, Parameter, and more.
Setting AllowMultiple controls whether the same attribute can appear multiple times on one element. Setting Inherited determines whether derived classes automatically receive the attribute from their base class.
using System;
// Only valid on properties, single use, inherited by subclasses
[AttributeUsage(AttributeTargets.Property,
AllowMultiple = false,
Inherited = true)]
public class RequiredAttribute : Attribute
{
public string ErrorMessage { get; set; } = "This field is required";
}
// Valid on properties, multiple uses allowed
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public class ValidationRuleAttribute : Attribute
{
public string RuleName { get; }
public string Pattern { get; set; }
public ValidationRuleAttribute(string ruleName)
{
RuleName = ruleName;
}
}
// Example usage
public class User
{
[Required(ErrorMessage = "Username cannot be empty")]
public string Username { get; set; }
[ValidationRule("Email", Pattern = @"^\S+@\S+\.\S+$")]
[ValidationRule("Length", Pattern = @"^.{5,50}$")]
public string Email { get; set; }
}
The Required attribute can only appear once per property, which prevents conflicting error messages. The ValidationRule attribute allows multiple instances, letting you stack validation rules on a single property. This design gives you flexibility while preventing nonsensical attribute combinations.
Reading Attributes with Reflection
After defining custom attributes, you need to read them at runtime. The reflection API provides methods like GetCustomAttribute and GetCustomAttributes to retrieve attribute instances from types, methods, properties, and other elements.
Reading attributes involves getting the Type or MemberInfo for the element, then calling reflection methods to access the attached attributes. The attribute constructor and properties execute when reflection instantiates them.
using System;
using System.Reflection;
public class AttributeReader
{
public static void ReadClassAttributes()
{
var type = typeof(Calculator);
// Read single attribute
var author = type.GetCustomAttribute<AuthorAttribute>();
if (author != null)
{
Console.WriteLine($"Author: {author.Name}");
Console.WriteLine($"Date: {author.Date}");
Console.WriteLine($"Version: {author.Version}");
}
// Read multiple attributes
var allAuthors = type.GetCustomAttributes<AuthorAttribute>();
foreach (var a in allAuthors)
{
Console.WriteLine($"Contributor: {a.Name}");
}
}
public static void ReadPropertyAttributes()
{
var type = typeof(User);
var emailProperty = type.GetProperty("Email");
var rules = emailProperty?.GetCustomAttributes<ValidationRuleAttribute>();
if (rules != null)
{
foreach (var rule in rules)
{
Console.WriteLine($"Rule: {rule.RuleName}");
Console.WriteLine($"Pattern: {rule.Pattern}");
}
}
}
}
GetCustomAttribute returns a single attribute or null if not found. GetCustomAttributes returns all matching attributes as an enumerable. Always check for null before accessing attribute properties, since the attribute might not be present.
Trimming and Native AOT Considerations: When publishing trimmed or Native AOT applications, reflection may require annotations like [DynamicallyAccessedMembers] or [DynamicDependency]. Libraries using reflection should annotate types and members accordingly. See Microsoft's trimming documentation at https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/
Building a Simple Validation Framework
Let's build a practical validation system using custom attributes. This demonstrates how frameworks like ASP.NET Data Annotations work internally. You'll define validation attributes and create a validator that reads them through reflection.
using System;
using System.Collections.Generic;
using System.Reflection;
[AttributeUsage(AttributeTargets.Property)]
public class RangeAttribute : Attribute
{
public int Min { get; }
public int Max { get; }
public string ErrorMessage { get; set; }
public RangeAttribute(int min, int max)
{
Min = min;
Max = max;
ErrorMessage = $"Value must be between {min} and {max}";
}
}
[AttributeUsage(AttributeTargets.Property)]
public class StringLengthAttribute : Attribute
{
public int MaxLength { get; }
public string ErrorMessage { get; set; }
public StringLengthAttribute(int maxLength)
{
MaxLength = maxLength;
ErrorMessage = $"Length cannot exceed {maxLength} characters";
}
}
public class Validator
{
public static List<string> Validate(object obj)
{
var errors = new List<string>();
var type = obj.GetType();
foreach (var prop in type.GetProperties())
{
var value = prop.GetValue(obj);
// Check Required
var required = prop.GetCustomAttribute<RequiredAttribute>();
if (required != null && (value == null ||
(value is string s && string.IsNullOrEmpty(s))))
{
errors.Add(required.ErrorMessage);
}
// Check Range
var range = prop.GetCustomAttribute<RangeAttribute>();
if (range != null && value is int intValue)
{
if (intValue < range.Min || intValue > range.Max)
{
errors.Add(range.ErrorMessage);
}
}
// Check StringLength
var length = prop.GetCustomAttribute<StringLengthAttribute>();
if (length != null && value is string strValue)
{
if (strValue.Length > length.MaxLength)
{
errors.Add(length.ErrorMessage);
}
}
}
return errors;
}
}
// Example usage
public class Product
{
[Required(ErrorMessage = "Product name is required")]
[StringLength(50, ErrorMessage = "Name too long")]
public string Name { get; set; }
[Range(1, 1000, ErrorMessage = "Price must be between 1 and 1000")]
public int Price { get; set; }
}
The Validator iterates through all properties, reads validation attributes, and checks values against rules. Each attribute type has specific validation logic. This pattern separates validation rules from business logic, making your domain classes cleaner.
Try It Yourself
Here's a complete example that demonstrates creating and using custom attributes. This program defines attributes, applies them to classes, and reads them through reflection.
using System;
using System.Collections.Generic;
var product = new Product
{
Name = "Laptop",
Price = 500
};
var errors = Validator.Validate(product);
if (errors.Count == 0)
{
Console.WriteLine("Validation passed!");
}
else
{
Console.WriteLine("Validation errors:");
foreach (var error in errors)
{
Console.WriteLine($" - {error}");
}
}
// Test with invalid data
var invalidProduct = new Product
{
Name = "",
Price = 1500
};
errors = Validator.Validate(invalidProduct);
Console.WriteLine($"\nFound {errors.Count} validation errors:");
foreach (var error in errors)
{
Console.WriteLine($" - {error}");
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Output:
Validation passed!
Found 2 validation errors:
- Product name is required
- Price must be between 1 and 1000
Run this with dotnet run. The validator reads attributes and applies rules automatically. Add more attributes or properties to see how the system scales without modifying the validator code.
Security and Trimming Considerations
Custom attributes often rely on reflection, which introduces security and deployment considerations. When using reflection, you need to be aware of trimming and Native AOT compatibility.
Trimming removes unused code from published applications to reduce size. The trimmer may remove types, methods, or properties that you access only through reflection. You must annotate reflection-heavy code with attributes like DynamicallyAccessedMembers to preserve necessary metadata.
Native AOT compilation eliminates the just-in-time compiler for faster startup and smaller memory footprint. However, it restricts reflection significantly. Attributes used only for compile-time code generation work fine, but runtime reflection requires careful annotation or may not work at all.
For attribute-based systems, consider these alternatives. Use source generators to process attributes at compile time instead of runtime. Cache reflection results in static fields during application startup to minimize performance impact. For frequently validated objects, generate validators at build time rather than using reflection on every validation call.
Common Pitfalls and Solutions
Forgetting AttributeUsage: Without the AttributeUsage attribute, your custom attribute defaults to AttributeTargets.All, allowing it anywhere. This causes confusion when developers apply it to inappropriate elements. Always explicitly declare valid targets.
Mutable attribute properties: Attribute instances are created through reflection and shared across multiple reads. If you make properties publicly settable, cached attribute instances might have modified values. Use read-only properties or constructor parameters for immutable data.
Expensive attribute constructors: Attribute constructors execute during reflection calls, not at compile time. Avoid database queries, file I/O, or complex computations in attribute constructors. Keep them lightweight with simple property assignments.
Ignoring inheritance behavior: Setting Inherited = true on AttributeUsage means derived classes automatically receive the attribute. This can cause unexpected behavior if the attribute carries class-specific data. Consider whether inheritance makes sense for your attribute's semantics.