You are viewing a preview of this lesson. Sign in to start learning
Back to C# Programming

Attributes & Metadata

Apply and create custom attributes for declarative programming

Attributes & Metadata in C#

Master the power of attributes and metadata with free flashcards and spaced repetition practice. This lesson covers custom attribute creation, reflection-based metadata querying, and practical attribute applications—essential concepts for building extensible C# applications that leverage compile-time annotations and runtime inspection.

Welcome to the World of Declarative Programming 💻

Attributes in C# are a powerful metaprogramming feature that allows you to add declarative information to your code. Think of attributes as sticky notes you attach to classes, methods, properties, or other code elements—these notes can be read at runtime (or even compile-time) to change behavior, enforce rules, or provide additional context.

Metadata is the underlying data that describes your program's structure—classes, methods, parameters, and their relationships. Attributes are one way to extend this metadata with your own custom information.

🤔 Did you know? Attributes are used extensively throughout .NET—from marking methods for serialization ([Serializable]) to defining API routes ([Route("/api/users")]) in ASP.NET Core. Understanding attributes unlocks the ability to create frameworks and libraries that feel "magical" to users.

What Are Attributes? 🏷️

An attribute is a class that inherits from System.Attribute. You apply attributes to code elements using square bracket syntax:

[Obsolete("Use NewMethod instead")]
public void OldMethod() 
{
    // Method implementation
}

In this example:

  • Obsolete is the attribute
  • The string parameter provides additional information
  • The compiler will warn developers who call this method

Key characteristics:

  • Attributes are metadata attached to code elements
  • They don't execute on their own—something must read them
  • They're typically read via reflection at runtime
  • They can have parameters (positional and named)

Built-in Attributes You Should Know 📚

C# comes with many built-in attributes:

AttributePurposeExample Usage
[Obsolete]Marks code as deprecatedWarn developers about old APIs
[Serializable]Marks classes for serializationEnable binary serialization
[DllImport]Imports native methodsCall Windows API functions
[Conditional]Conditional compilationDebug-only method calls
[CallerMemberName]Captures caller informationLogging and INotifyPropertyChanged

💡 Tip: The [Obsolete] attribute has two parameters: a message string and a boolean indicating whether usage should cause a compiler error (not just a warning).

Creating Custom Attributes ⚒️

To create your own attribute:

  1. Create a class inheriting from Attribute
  2. Name it with the Attribute suffix (by convention)
  3. Add properties or constructor parameters for data
  4. Apply [AttributeUsage] to control where it can be used

Here's a complete example:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, 
                AllowMultiple = false, 
                Inherited = true)]
public class AuthorAttribute : Attribute
{
    public string Name { get; }
    public string Version { get; set; }
    
    public AuthorAttribute(string name)
    {
        Name = name;
    }
}

Breaking it down:

  • AttributeUsage controls where [Author] can be applied
  • AttributeTargets.Class | AttributeTargets.Method means classes and methods only
  • AllowMultiple = false means you can't apply it twice to the same element
  • Inherited = true means derived classes inherit the attribute
  • Name is a positional parameter (required, set via constructor)
  • Version is a named parameter (optional, set via property)

Usage:

[Author("Jane Smith", Version = "2.1")]
public class DataProcessor
{
    [Author("John Doe")]
    public void ProcessData() { }
}

🧠 Memory device: Think of AttributeUsage as the "rules of where the sticky note can stick"—you wouldn't put a "Refrigerate After Opening" sticker on a book!

AttributeTargets: Where Can Attributes Go? 🎯

The AttributeTargets enum specifies valid locations:

TargetApplied ToExample
AssemblyEntire assembly[assembly: InternalsVisibleTo("Tests")]
ClassClasses[Serializable] class Data { }
MethodMethods[Obsolete] void Old() { }
PropertyProperties[JsonIgnore] string Secret { get; }
FieldFields[NonSerialized] private int _temp;
ParameterMethod parametersvoid Method([NotNull] string s)
ReturnValueReturn values[return: MarshalAs(...)]
AllAny code elementMost permissive option

You can combine targets using the bitwise OR operator (|):

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)]
public class DocumentationAttribute : Attribute { }

Reading Attributes with Reflection 🔍

Reflection is the mechanism that reads metadata at runtime. Here's how to query attributes:

// Get attributes from a type
Type type = typeof(DataProcessor);
var attrs = type.GetCustomAttributes(typeof(AuthorAttribute), true);

foreach (AuthorAttribute attr in attrs)
{
    Console.WriteLine($"Author: {attr.Name}, Version: {attr.Version}");
}

// Modern generic approach
var authorAttr = type.GetCustomAttribute<AuthorAttribute>();
if (authorAttr != null)
{
    Console.WriteLine($"Created by {authorAttr.Name}");
}

Common reflection methods:

MethodPurposeReturns
GetCustomAttributes()Get all attributes of a typeArray of attributes
GetCustomAttribute<T>()Get single attribute (generic)Single attribute or null
IsDefined()Check if attribute existsBoolean
GetCustomAttributesData()Get raw attribute dataMetadata without instantiation

💡 Tip: The second parameter in GetCustomAttributes(type, inherit) controls whether to search base classes for inherited attributes.

Practical Example: Validation Framework 🛡️

Let's build a simple validation system using attributes:

Step 1: Create validation attributes

[AttributeUsage(AttributeTargets.Property)]
public abstract class ValidationAttribute : Attribute
{
    public abstract bool IsValid(object value);
    public string ErrorMessage { get; set; }
}

public class RequiredAttribute : ValidationAttribute
{
    public override bool IsValid(object value)
    {
        return value != null && !string.IsNullOrWhiteSpace(value.ToString());
    }
}

public class RangeAttribute : ValidationAttribute
{
    public int Min { get; }
    public int Max { get; }
    
    public RangeAttribute(int min, int max)
    {
        Min = min;
        Max = max;
    }
    
    public override bool IsValid(object value)
    {
        if (value is int intValue)
            return intValue >= Min && intValue <= Max;
        return false;
    }
}

Step 2: Apply attributes to a model

public class User
{
    [Required(ErrorMessage = "Name is required")]
    public string Name { get; set; }
    
    [Range(18, 120, ErrorMessage = "Age must be between 18 and 120")]
    public int Age { get; set; }
}

Step 3: Create a validator using reflection

public static class Validator
{
    public static List<string> Validate(object obj)
    {
        var errors = new List<string>();
        Type type = obj.GetType();
        
        foreach (var property in type.GetProperties())
        {
            var validationAttrs = property.GetCustomAttributes<ValidationAttribute>();
            
            foreach (var attr in validationAttrs)
            {
                object value = property.GetValue(obj);
                
                if (!attr.IsValid(value))
                {
                    errors.Add(attr.ErrorMessage ?? $"{property.Name} is invalid");
                }
            }
        }
        
        return errors;
    }
}

Step 4: Use the validator

var user = new User { Name = "", Age = 15 };
var errors = Validator.Validate(user);

foreach (var error in errors)
{
    Console.WriteLine(error);
}
// Output:
// Name is required
// Age must be between 18 and 120

🌍 Real-world analogy: This is exactly how ASP.NET Core's model validation works! When you use [Required] or [Range] on model properties, the framework uses reflection to read these attributes and validate incoming data automatically.

Advanced: Attribute Parameters 🎛️

Attributes can have different parameter types:

Positional Parameters (Constructor):

public class TableAttribute : Attribute
{
    public string Name { get; }
    
    public TableAttribute(string name)  // Positional
    {
        Name = name;
    }
}

[Table("Users")]  // Must provide name
public class User { }

Named Parameters (Properties):

public class ColumnAttribute : Attribute
{
    public string Name { get; set; }
    public bool IsPrimaryKey { get; set; }
    public int MaxLength { get; set; } = 255;
}

[Column(Name = "user_id", IsPrimaryKey = true)]
public int Id { get; set; }

Mixing Both:

public class CacheAttribute : Attribute
{
    public int Duration { get; }  // Positional (required)
    public string Key { get; set; }  // Named (optional)
    public bool SlidingExpiration { get; set; }  // Named (optional)
    
    public CacheAttribute(int duration)
    {
        Duration = duration;
    }
}

[Cache(300, Key = "user-data", SlidingExpiration = true)]
public UserData GetUserData() { }

⚠️ Important: Attribute parameter values must be compile-time constants. You can use:

  • Primitive types (int, string, bool, etc.)
  • typeof() expressions
  • Enums
  • Arrays of the above

You cannot use:

  • Variables
  • Method calls
  • Object initializers (except arrays)

Performance Considerations ⚡

Reflection has performance costs:

Cost breakdown:

  1. Type discovery: Getting Type objects (moderate)
  2. Member enumeration: Iterating properties/methods (moderate)
  3. Attribute querying: Reading attributes (expensive)
  4. Invocation: Calling methods via reflection (very expensive)

Optimization strategies:

// ❌ BAD: Query attributes repeatedly
for (int i = 0; i < 1000; i++)
{
    var attr = type.GetCustomAttribute<AuthorAttribute>();  // Slow!
}

// ✅ GOOD: Cache attribute data
var attr = type.GetCustomAttribute<AuthorAttribute>();
for (int i = 0; i < 1000; i++)
{
    // Use cached attr
}

// ✅ BETTER: Use a caching strategy
private static readonly Dictionary<Type, AuthorAttribute> _cache = new();

public static AuthorAttribute GetCachedAttribute(Type type)
{
    if (!_cache.TryGetValue(type, out var attr))
    {
        attr = type.GetCustomAttribute<AuthorAttribute>();
        _cache[type] = attr;
    }
    return attr;
}

💡 Tip: Many frameworks (like ASP.NET Core) build attribute caches during startup to avoid repeated reflection at runtime.

Common Use Cases 🎯

1. Object-Relational Mapping (ORM):

[Table("customers")]
public class Customer
{
    [Column("customer_id"), PrimaryKey]
    public int Id { get; set; }
    
    [Column("full_name"), MaxLength(100)]
    public string Name { get; set; }
    
    [Ignore]  // Don't map to database
    public string TempData { get; set; }
}

2. Serialization Control:

public class ApiResponse
{
    [JsonProperty("user_name")]
    public string UserName { get; set; }
    
    [JsonIgnore]
    public string Password { get; set; }
}

3. Dependency Injection:

public class OrderService
{
    [Inject]
    public ILogger Logger { get; set; }
    
    [Inject]
    public IDatabase Database { get; set; }
}

4. Testing:

[TestClass]
public class CalculatorTests
{
    [TestMethod]
    [ExpectedException(typeof(DivideByZeroException))]
    public void Divide_ByZero_ThrowsException()
    {
        var calc = new Calculator();
        calc.Divide(10, 0);
    }
}

5. API Routing:

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    [HttpGet("{id}")]
    [Authorize(Roles = "Admin")]
    public IActionResult GetUser(int id) { }
}

Assembly-Level Attributes 🏛️

You can apply attributes to an entire assembly:

// Typically in AssemblyInfo.cs or at top of any .cs file
[assembly: AssemblyTitle("MyApplication")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: InternalsVisibleTo("MyApplication.Tests")]

These attributes:

  • Apply to the whole compiled assembly
  • Are placed outside any namespace
  • Use the assembly: target specifier
  • Affect assembly metadata visible in file properties

🤔 Did you know? The [InternalsVisibleTo] attribute is commonly used to expose internal members to test projects, allowing you to test internal implementation details without making them public.

Conditional Attributes & Compilation ⚙️

The [Conditional] attribute makes method calls conditional based on compilation symbols:

using System.Diagnostics;

public class Logger
{
    [Conditional("DEBUG")]
    public static void DebugLog(string message)
    {
        Console.WriteLine($"DEBUG: {message}");
    }
    
    [Conditional("TRACE")]
    public static void TraceLog(string message)
    {
        Console.WriteLine($"TRACE: {message}");
    }
}

// Usage
Logger.DebugLog("This only runs in DEBUG builds");
Logger.TraceLog("This only runs when TRACE is defined");

How it works:

  • If the symbol (DEBUG, TRACE) is not defined, the compiler removes the call entirely
  • The method still exists in the assembly, but calls to it vanish
  • No runtime overhead—it's a compile-time optimization
  • Multiple [Conditional] attributes use OR logic (any match executes)

⚠️ Important: [Conditional] only works on methods that return void. This makes sense—if the call is removed, what would you do with a return value?

Caller Information Attributes 📞

These attributes automatically capture information about the calling code:

using System.Runtime.CompilerServices;

public class Logger
{
    public static void Log(
        string message,
        [CallerMemberName] string memberName = "",
        [CallerFilePath] string filePath = "",
        [CallerLineNumber] int lineNumber = 0)
    {
        Console.WriteLine($"[{memberName}] {message}");
        Console.WriteLine($"  at {filePath}:{lineNumber}");
    }
}

// Usage
public void ProcessData()
{
    Logger.Log("Processing started");  // Don't pass extra parameters
}

// Output:
// [ProcessData] Processing started
//   at C:\Projects\MyApp\DataProcessor.cs:42

Available caller attributes:

AttributeCapturesCommon Use
[CallerMemberName]Method/property nameINotifyPropertyChanged, logging
[CallerFilePath]Source file pathDebugging, diagnostics
[CallerLineNumber]Line number in sourceError reporting
[CallerArgumentExpression]Argument expression (C# 10+)Assertion messages

INotifyPropertyChanged pattern:

public class Person : INotifyPropertyChanged
{
    private string _name;
    
    public string Name
    {
        get => _name;
        set
        {
            _name = value;
            OnPropertyChanged();  // No need to pass "Name"!
        }
    }
    
    public event PropertyChangedEventHandler PropertyChanged;
    
    protected void OnPropertyChanged([CallerMemberName] string propertyName = "")
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

💡 Tip: Caller information attributes must be applied to optional parameters with default values. The compiler fills in the actual values at compile time.

Common Mistakes to Avoid ⚠️

1. Forgetting the Attribute suffix:

// ❌ BAD: Confusing class name
public class Author : Attribute { }

// ✅ GOOD: Clear it's an attribute
public class AuthorAttribute : Attribute { }

While both work, the convention makes code more readable. When you write [Author], C# automatically looks for AuthorAttribute.

2. Using non-constant values in attributes:

// ❌ ERROR: Won't compile
string GetTableName() => "Users";

[Table(GetTableName())]  // ERROR: Attribute argument must be constant
public class User { }

// ✅ GOOD: Use constant
const string TableName = "Users";

[Table(TableName)]
public class User { }

3. Not caching reflection results:

// ❌ BAD: Queries attributes on every access
public string GetTableName<T>()
{
    var attr = typeof(T).GetCustomAttribute<TableAttribute>();
    return attr?.Name;  // Slow if called repeatedly
}

// ✅ GOOD: Cache results
private static readonly ConcurrentDictionary<Type, string> _tableCache = new();

public string GetTableName<T>()
{
    return _tableCache.GetOrAdd(typeof(T), type =>
    {
        var attr = type.GetCustomAttribute<TableAttribute>();
        return attr?.Name ?? type.Name;
    });
}

4. Forgetting AttributeUsage:

// ❌ BAD: Can be applied anywhere, multiple times
public class PrimaryKeyAttribute : Attribute { }

// ✅ GOOD: Explicit control
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class PrimaryKeyAttribute : Attribute { }

5. Wrong return type with [Conditional]:

// ❌ ERROR: Conditional methods must return void
[Conditional("DEBUG")]
public int GetDebugValue() => 42;  // Won't compile

// ✅ GOOD: Returns void
[Conditional("DEBUG")]
public void LogDebugValue(int value) { }

6. Incorrect attribute parameter types:

// ❌ ERROR: Can't use complex objects
public class ConfigAttribute : Attribute
{
    public ConfigAttribute(Dictionary<string, string> settings) { }  // ERROR
}

// ✅ GOOD: Use simple types
public class ConfigAttribute : Attribute
{
    public string[] Keys { get; set; }  // Arrays are OK
}

Key Takeaways 🎯

Attributes are metadata that you attach to code elements using [AttributeName] syntax

Custom attributes must inherit from System.Attribute and conventionally end with "Attribute"

[AttributeUsage] controls where attributes can be applied and whether they can be used multiple times

Reflection is how you read attributes at runtime using methods like GetCustomAttribute<T>()

Performance matters—cache reflection results instead of querying repeatedly

Attribute parameters must be compile-time constants (primitives, strings, typeof, enums, arrays)

Built-in attributes like [Obsolete], [Conditional], and [CallerMemberName] provide powerful functionality

Common use cases include validation, serialization, ORM mapping, dependency injection, and API routing

Assembly-level attributes apply to the entire compiled output and use the assembly: target

Caller information attributes automatically capture method names, file paths, and line numbers for diagnostics

📋 Quick Reference Card

Create Attributeclass MyAttribute : Attribute { }
Apply Attribute[My(param)] or [MyAttribute(param)]
Control Usage[AttributeUsage(AttributeTargets.Class)]
Read Singletype.GetCustomAttribute<MyAttribute>()
Read Multipletype.GetCustomAttributes<MyAttribute>()
Check Existstype.IsDefined(typeof(MyAttribute))
Assembly Level[assembly: MyAttribute]
Positional ParamConstructor parameter (required)
Named ParamPublic property (optional)
Conditional[Conditional("DEBUG")] void Method()

📚 Further Study

  1. Microsoft Docs - Attributes: https://learn.microsoft.com/en-us/dotnet/csharp/advanced-topics/reflection-and-attributes/
  2. Reflection in C#: https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/reflection
  3. AttributeUsage Reference: https://learn.microsoft.com/en-us/dotnet/api/system.attributeusageattribute

You now have the knowledge to create powerful, extensible C# applications using attributes and metadata! Practice by building your own validation framework, creating custom ORM attributes, or implementing an attribute-based plugin system. The metaprogramming possibilities are endless! 🚀