Custom attributes represent one of C#'s most powerful features for metadata programming. If you've used [Serializable]
, [Required]
, or [Obsolete]
, you've seen attributes in action. But creating your own attributes
unlocks the ability to attach declarative information to your code that can be inspected at runtime through
reflection.
In this deep dive, we'll explore custom attributes from the ground up. You'll learn how to create them, control their usage, read them with reflection, and apply them in advanced patterns like aspect-oriented programming. Whether you're building frameworks, libraries, or just want to understand how ASP.NET and Entity Framework work under the hood, this knowledge is essential.
What Are Attributes, Really?
Attributes are metadata - information about your code that's embedded in the compiled assembly. Unlike comments (which are stripped at compile time), attributes survive compilation and can be read at runtime using reflection. They enable declarative programming, where you specify what you want rather than how to achieve it.
// This metadata survives compilation
[Serializable]
public class Product
{
[Required]
public string Name { get; set; }
[Range(0, 1000000)]
public decimal Price { get; set; }
}
This code example demonstrates how attributes provide metadata that survives compilation and can be read at runtime. The [Serializable] attribute applied to the Product class tells the .NET serialization framework that instances of this class can be converted to a stream of bytes for storage or transmission. Within the class, the [Required] attribute on the Name property indicates that this field must have a value and cannot be null or empty, which validation frameworks like ASP.NET MVC will enforce. The [Range(0, 1000000)] attribute on the Price property specifies that the price value must fall between 0 and 1,000,000 inclusive, providing automatic validation that prevents invalid price values from being accepted. These attributes transform simple properties into self-documenting, self-validating fields that frameworks can automatically process without requiring explicit validation code in business logic.
The .NET runtime, frameworks, and your own code can read these attributes to change behavior. For example, ASP.NET
uses [Required]
for validation, and the serializer uses [Serializable]
to determine what can be serialized.
Creating Your First Custom Attribute
All custom attributes inherit from System.Attribute
. The basic structure is
simple, but the real power comes from how you design them to capture domain-specific metadata.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthorAttribute : Attribute
{
public string Name { get; }
public string Email { get; set; }
public AuthorAttribute(string name)
{
Name = name;
}
}
So here's how you create a custom attribute. You start with [AttributeUsage]
to
tell C# where you can use this attribute - in this case, on classes and methods.
The class inherits from System.Attribute
, and you can have properties and a
constructor just like any other class. The Name
property is read-only and gets
set in the constructor, while Email
is optional.
This lets you use it in different ways: [Author("John Doe")]
for simple cases, or
[Author("John Doe", Email = "[email protected]")]
when you want to include contact
info. Pretty flexible, right?
The AttributeUsage
attribute controls where this attribute can be applied. The
class inherits from System.Attribute
, and the constructor parameter becomes a
required positional argument when using the attribute.
Now you can use it like this:
[Author("Vaibhav Lawand", Email = "[email protected]")]
public class UserService
{
[Author("John Doe")]
public void CreateUser(string username)
{
// Implementation
}
}
See how this works in practice? The UserService
class has [Author("Vaibhav Lawand", Email = "[email protected]")]
on it, so you know Vaibhav
is the main person responsible for this class.
But then inside, the CreateUser
method has [Author("John Doe")]
- maybe John implemented just that specific method.
This is really useful for tracking who worked on what, especially in larger teams where different people might own different parts of the codebase.
Notice how the first usage provides both required and optional parameters, while the second usage only provides the required parameter. This flexibility makes attributes powerful for capturing different levels of metadata.
AttributeUsage: Controlling Attribute Behavior
The AttributeUsage
attribute is your primary tool for controlling how custom
attributes behave. It takes three important properties that determine targeting, multiplicity, and inheritance
rules.
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
public class LogAttribute : Attribute
{
public LogLevel Level { get; set; } = LogLevel.Info;
public string Message { get; set; }
}
Check out this LogAttribute
- it's a great example of using AttributeUsage
to really control how your attribute works.
By setting AttributeTargets.Method
, we're saying "this attribute can only go on
methods." AllowMultiple = true
means you can put multiple [Log]
attributes on the same method, which is useful if you want different log levels
for different scenarios.
And Inherited = false
means if you override a method in a subclass, the logging
won't automatically carry over - you have to explicitly add it.
The attribute has a Level
property (defaults to Info) and an optional Message
. This gives you a lot of control over logging behavior while keeping things
clean and intentional.
This attribute can only be applied to methods, can be used multiple times on the same method, and won't be inherited by overriding methods in derived classes. These constraints prevent misuse and make the attribute's intent clear.
Always specify AttributeUsage
for your custom attributes. It prevents misuse
and makes your intent clear to other developers.
Reading Attributes with Reflection
Attributes are useless unless you can read them. Reflection provides the APIs to inspect attributes at runtime. The most common pattern is getting all attributes from a type and filtering them.
// Get all attributes on a type
var attributes = typeof(UserService).GetCustomAttributes(false);
foreach (var attr in attributes)
{
if (attr is AuthorAttribute authorAttr)
{
Console.WriteLine($"Author: {authorAttr.Name}, Email: {authorAttr.Email}");
}
}
So here's how you actually read attributes at runtime. You use typeof()
to get
the Type
object for your class, then call GetCustomAttributes()
to get all the attributes on it.
The code loops through each attribute and uses pattern matching to check if it's an AuthorAttribute
. If it is, it prints out the name and email.
This is the basic pattern - get the attributes, check their types, and use them.
This code uses typeof()
to get the Type object, then calls GetCustomAttributes()
to retrieve all attributes. It then checks each attribute type
and casts accordingly.
For better type safety and performance, use the generic version:
// Get specific attribute types
foreach (var method in typeof(UserService).GetMethods())
{
var methodAttrs = method.GetCustomAttributes();
foreach (var attr in methodAttrs)
{
Console.WriteLine($"Method {method.Name} by {attr.Name}");
}
}
For a cleaner approach, use the generic version - it's type-safe and doesn't require casting.
This code gets all methods from UserService
, then for each method it gets only
the AuthorAttribute
instances and prints who wrote each method.
Much nicer than the non-generic version!
The generic GetCustomAttributes<T>()
method returns the correct type
directly, eliminating manual casting and type checking.
Sometimes you just need to check if an attribute exists:
if (typeof(Product).IsDefined(typeof(SerializableAttribute), false))
{
// Type is serializable
}
The IsDefined method provides an efficient way to check for attribute presence without retrieving the full attribute instances. This approach is particularly useful when you only need to know whether a specific attribute exists on a type, rather than accessing its properties or values. The typeof(Product) expression gets the Type object for the Product class, and IsDefined checks if the SerializableAttribute is applied to it. The false parameter indicates that inherited attributes should not be considered, so only attributes directly applied to the Product class will be detected. If the attribute is found, the code inside the if block executes, allowing conditional logic based on attribute presence. This method is much more performant than calling GetCustomAttributes() and searching through the results when you only need a boolean answer about attribute existence.
This is more efficient than retrieving all attributes when you only need to check for presence.
Aspect-Oriented Programming with Attributes
Aspect-oriented programming (AOP) is one of the most powerful applications of custom attributes. AOP allows you to separate cross-cutting concerns like logging, caching, and validation from your core business logic. Attributes serve as the markers that identify where these aspects should be applied.
[Log(Level.Info)]
[Cache(Duration.Minutes(5))]
public User GetUserById(int id)
{
return _repository.GetById(id);
}
This example illustrates aspect-oriented programming using attributes to separate cross-cutting concerns from business logic. The GetUserById method is decorated with two attributes that define behavioral aspects independent of the core functionality. The [Log(Level.Info)] attribute instructs the AOP framework to automatically log method invocations at the Info level, capturing details like execution time, parameters, and return values without cluttering the business logic. The [Cache(Duration.Minutes(5))] attribute enables result caching, where the framework checks for a cached result before executing the method and stores the result for five minutes if not found. The method body itself contains only the essential business logic of retrieving a user from the repository, completely unaware of the logging and caching behaviors. This separation allows aspects to be configured declaratively through attributes, making the code more maintainable and enabling aspects to be easily added, removed, or modified without changing the core business logic.
Multiple attributes can be combined to create rich behavioral compositions. The framework interprets these attributes to apply the appropriate aspects automatically.
Validation Frameworks with Attributes
Custom attributes excel at building validation frameworks. By creating attributes that define validation rules, you can separate validation logic from business logic, making both more maintainable and testable.
public class ValidationAttribute : Attribute
{
public string ErrorMessage { get; set; }
public virtual bool IsValid(object value) => true;
}
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public class RequiredAttribute : ValidationAttribute
{
public override bool IsValid(object value)
{
return value != null && !string.IsNullOrEmpty(value.ToString());
}
}
Let's break down this validation framework step by step. The ValidationAttribute
base class is the foundation - it provides the basic structure that all validation attributes will inherit from.
It has an ErrorMessage
property so you can customize the error message when
validation fails. The IsValid
method returns true
by default, meaning "everything is valid unless a specific attribute says
otherwise."
The RequiredAttribute
inherits from this base class and adds its own logic.
Notice the [AttributeUsage]
- it can only be applied to properties, and you can
have multiple validation attributes on the same property.
The key part is the overridden IsValid
method. It checks if the value is not null
and not an empty string. This is the actual validation logic that determines whether the input passes the
"required" rule.
This design is brilliant because it creates a whole ecosystem. You can now create [Range]
, [Email]
, [StringLength]
attributes that all inherit from ValidationAttribute
. Combine them on properties, and frameworks can automatically
discover and execute all these validations through reflection.
Metadata-Driven Architecture Patterns
As you deepen your understanding of custom attributes, you'll see opportunities for metadata-driven architectures. These architectures use attributes as the primary mechanism for defining system behavior, rather than hardcoding logic.
In a metadata-driven system, the attributes become the domain-specific language (DSL) for your application. Instead of writing imperative code to configure behavior, you declare what you want using attributes, and the framework interprets these declarations.
[Service(Lifetime.Singleton)]
[Authorize(Roles.Admin)]
public class UserManagementService
{
[Transactional]
[RetryOnFailure(MaxAttempts = 3)]
public void UpdateUser(User user)
{
// Business logic
}
}
Check out this metadata-driven architecture example. Instead of hardcoding behavior, attributes become your domain-specific language for configuring how things work.
The UserManagementService
class is decorated with multiple attributes that define
its lifecycle and access control. Notice how all the infrastructure concerns are declared through attributes,
completely separate from the implementation.
The [Service(Lifetime.Singleton)]
attribute tells the dependency injection
container to create just one instance and share it throughout the application. No explicit registration code
needed!
The [Authorize(Roles.Admin)]
attribute specifies that only administrators can
access this service. Security frameworks can automatically enforce this.
At the method level, [Transactional]
ensures the operation runs in a database
transaction - automatic commit or rollback based on success. And [RetryOnFailure(MaxAttempts = 3)]
handles transient failures by retrying up to 3
times.
Look at the method body - it contains only pure business logic for updating a user. All the infrastructure concerns (security, transactions, resilience) are declared through attributes and handled by the framework automatically.
This creates highly declarative, maintainable code. Instead of imperative setup code scattered throughout your application, behavior is specified through metadata. The framework composes all these attribute declarations into the final implementation.
Performance Considerations
While custom attributes are powerful, they come with performance considerations. Reflection operations are generally slower than direct method calls, so attribute inspection should be done during application startup rather than in performance-critical paths.
One common pattern is to perform attribute inspection once during startup and cache the results. For example, a validation framework might scan all types at startup, build a fast lookup table for property validations, and use that table during runtime validation.
// Cache validation rules at startup
private static readonly Dictionary>
_validationCache = new();
public static void BuildValidationCache(IEnumerable types)
{
foreach (var type in types)
{
var validations = new List();
foreach (var prop in type.GetProperties())
{
var attrs = prop.GetCustomAttributes();
validations.AddRange(attrs.Select(attr =>
new PropertyValidation(prop, attr)));
}
_validationCache[type] = validations;
}
}
This performance optimization pattern is crucial when working with attributes. Reflection is expensive, so you want to do it once during startup, not on every request.
The idea is to amortize the cost - perform the expensive attribute inspection upfront and cache the results for fast runtime access. This static dictionary serves as a high-performance lookup table.
During startup, the BuildValidationCache
method does the heavy lifting. It
iterates through all the types you specify and examines each property for validation attributes.
For each property that has validation attributes, it creates PropertyValidation
objects that pair the property info with its validation rules. The LINQ Select
transforms each attribute into a validation object, and AddRange
efficiently adds
them to the list.
Once cached, runtime validation becomes lightning fast. Instead of expensive reflection calls during request processing, you just do simple dictionary lookups to get the pre-computed validation rules.
This pattern dramatically improves performance for applications that do frequent validation, while keeping your code clean and maintainable.
This approach amortizes the cost of reflection across the lifetime of the application, providing fast validation at runtime.
Advanced Patterns: Conditional Compilation
Attributes can be combined with conditional compilation to create sophisticated configuration systems. By using preprocessor directives, you can create attributes that are only active in certain build configurations.
[Conditional("DEBUG")]
[PerformanceMonitor]
public void ProcessData()
{
// Implementation
}
The Conditional
attribute ensures that performance monitoring code is only
executed when the DEBUG symbol is defined, providing zero overhead in release builds.
Common Gotchas and Best Practices
Custom attributes are powerful but come with some gotchas. Here are the most common issues and how to avoid them:
- Attribute inheritance: By default, attributes are not inherited. Use
Inherited = true
if you want derived classes to inherit attributes. - Constructor constraints: Attribute constructors can't have complex types or optional parameters in the way you might expect.
- Reflection performance: Cache attribute lookups to avoid repeated reflection calls in hot paths.
- Versioning: Changing attribute signatures can break existing code, so design with backward compatibility in mind.
Design attributes with extensibility in mind. Use optional parameters and consider future requirements when defining attribute APIs.
Real-World Example: API Documentation Generator
Let's build a practical example: a system that generates API documentation from attributes.
[ApiDescription("Manages user accounts")]
public class UserController
{
[HttpGet]
[ApiDescription("Retrieves a user by ID")]
[ApiParameter("id", "The user identifier")]
public User GetUser(int id)
{
return _userService.GetById(id);
}
}
public class ApiDocumentationGenerator
{
public string GenerateDocumentation(Type controllerType)
{
var builder = new StringBuilder();
var controllerAttr = controllerType
.GetCustomAttribute();
builder.AppendLine($"## {controllerType.Name}");
builder.AppendLine(controllerAttr?.Description);
foreach (var method in controllerType.GetMethods())
{
var methodAttr = method.GetCustomAttribute();
if (methodAttr != null)
{
builder.AppendLine($"### {method.Name}");
builder.AppendLine(methodAttr.Description);
}
}
return builder.ToString();
}
}
This example shows how attributes can drive automated API documentation generation. Instead of maintaining separate documentation files, the docs stay synchronized with your code through metadata.
The UserController
class has an [ApiDescription("Manages user accounts")]
attribute that provides a high-level
description of its purpose.
The GetUser
method is decorated with multiple attributes: [HttpGet]
for routing, [ApiDescription("Retrieves a user by ID")]
for detailed documentation, and [ApiParameter("id", "The user identifier")]
for parameter descriptions.
The ApiDocumentationGenerator
class is the framework component that reads these
attributes and generates structured documentation. The GenerateDocumentation
method uses reflection to inspect the controller type.
It starts by extracting the class-level ApiDescriptionAttribute
to create a main
heading with the controller name and description.
Then it iterates through all methods using GetMethods()
, checking each one for
ApiDescriptionAttribute
instances and generating subheadings with method names
and descriptions when found.
The null-conditional operator (?.
) safely handles cases where attributes might
not be present, and StringBuilder
efficiently constructs the Markdown-formatted
documentation.
This ensures API documentation stays synchronized with code changes, as developers update attributes directly in their controller classes rather than maintaining separate documentation files.
Summary
Custom attributes transform C# from a statically-typed language into a metadata-rich programming environment. They enable declarative programming patterns that separate concerns and create extensible systems. By understanding how to create, apply, and read attributes, you gain the ability to build frameworks and libraries that are both powerful and maintainable.
The key is balance: use attributes for metadata that needs to be inspected at runtime, but don't overuse them. When designed well, attributes create clean, declarative APIs that hide complexity while enabling sophisticated behaviors.