Senior 7 min · March 06, 2026

C# Attributes — The Reflection Caching Gap Killing Your RPS

GetCustomAttribute allocates a new instance every call.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Attributes attach metadata to code elements (classes, methods, properties) — stored in assembly IL, instantiated only when read via reflection
  • Key components: Attribute class (inherits System.Attribute), AttributeUsage (targets, allow multiple, inheritance), GetCustomAttribute (reflection reader)
  • Performance: GetCustomAttribute costs ~1µs on first call (type lookup + constructor), subsequent calls similar without caching — use static Dictionary cache for hot paths
  • Production trap: Calling GetCustomAttribute in a hot loop (e.g., per-request) without caching — adds microseconds per call, becomes milliseconds at scale (1000 req/s = 1ms overhead)
  • Biggest mistake: Forgetting Inherited=true in [AttributeUsage] — subclass silently gets the attribute, causing table mapping collisions in EF Core or routing conflicts in ASP.NET
Plain-English First

Imagine every piece of luggage at an airport has a tag stuck to it — that tag doesn't change what's inside the bag, but it tells the airline system how to handle it: fragile, first-class, priority loading. C# attributes are exactly that: sticky tags you attach to your classes, methods, or properties that tell the runtime, a framework, or your own code how to treat them. The code itself doesn't change — the tag just adds extra meaning.

Every production .NET codebase is full of attributes — [Serializable], [HttpGet], [Required], [Authorize] — and yet most junior developers treat them like magic spells they copy from Stack Overflow without understanding what's actually happening. That's a problem, because attributes are one of the most powerful tools in C# for writing clean, self-documenting, and extensible code without littering your business logic with repetitive boilerplate.

Attributes solve a specific problem: how do you attach extra information to a piece of code — a method, a class, a property — without changing its logic or signature? Before attributes, you'd need conventions, separate config files, or mountains of if-statements. Attributes let you declare intent right next to the code it describes, and then read that intent at runtime using reflection or at compile time using Roslyn analyzers.

By the end you'll understand exactly what attributes are and how the CLR handles them, how to read them at runtime using reflection, how to write your own custom attributes for real use cases like validation or logging, and the gotchas that trip up even experienced developers. You'll go from copy-pasting [JsonIgnore] to knowing precisely why it does what it does.

What Attributes Actually Are Under the Hood

An attribute in C# is just a class that inherits from System.Attribute. That's it. When the C# compiler sees [Obsolete("Use NewMethod instead")] above your method, it doesn't generate any runtime code at that call site — it embeds metadata into the compiled assembly's IL (Intermediate Language). The attribute instance doesn't even get created until something asks for it via reflection.

This is the key insight most developers miss: attributes are lazy. They cost nothing at runtime unless you read them. The compiled assembly carries the attribute data around like a passport stamp — it's there if anyone checks, but it doesn't slow you down at the border unless the border agent actually looks.

The square-bracket syntax [AttributeName] is just compiler sugar. Writing [Obsolete] is identical to writing [ObsoleteAttribute] — the compiler strips the 'Attribute' suffix automatically when resolving names. And you can pass arguments to the attribute's constructor or set its public properties using named parameters inside the brackets.

Attributes can target specific language elements: classes, methods, properties, fields, parameters, assemblies, and more. You control this with the [AttributeUsage] attribute on your custom attribute class — which is wonderfully meta.

io/thecodeforge/csharp/reflection/AttributeBasics.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
using System;
using System.Reflection;

// A plain old class decorated with a built-in attribute.
// [Serializable] tells the runtime this class can be converted
// to bytes (e.g., for file storage or network transfer).
[Serializable]
public class CustomerOrder
{
    public int OrderId { get; set; }

    // [Obsolete] is a compiler-level attribute.
    // The string is the message shown in IDE warnings and build output.
    [Obsolete("Use CalculateTotalWithTax() instead. This method ignores VAT.")]
    public decimal CalculateTotal()
    {
        return 99.99m;
    }

    public decimal CalculateTotalWithTax(decimal vatRate)
    {
        return 99.99m * (1 + vatRate);
    }
}

class Program
{
    static void Main()
    {
        var order = new CustomerOrder { OrderId = 42 };

        // Reflect on the CustomerOrder TYPE (not an instance).
        // GetType() returns a Type object — the runtime's description
        // of what CustomerOrder looks like.
        Type orderType = order.GetType();

        // Check whether [Serializable] is present on this class.
        // IsDefined() is the fast path — it doesn't instantiate the attribute,
        // just checks if the metadata token exists.
        bool isSerializable = orderType.IsDefined(typeof(SerializableAttribute), inherit: false);
        Console.WriteLine($"Is CustomerOrder serializable? {isSerializable}");

        // Now look at a specific method and read its ObsoleteAttribute.
        MethodInfo oldMethod = orderType.GetMethod("CalculateTotal")!;

        // GetCustomAttribute<T>() DOES instantiate the attribute object.
        // This is the moment the attribute's constructor runs.
        ObsoleteAttribute? obsoleteInfo =
            oldMethod.GetCustomAttribute<ObsoleteAttribute>();

        if (obsoleteInfo != null)
        {
            // The Message property is set in the attribute's constructor.
            Console.WriteLine($"Warning — method is obsolete: {obsoleteInfo.Message}");
        }
    }
}
Key Insight:
Attributes don't run when your code runs — they're read when something asks for them via reflection. That means [Obsolete] doesn't throw an error at runtime; it fires a compiler warning at build time because the compiler itself reads it. This distinction matters when you're designing your own attributes.
Production Insight
GetCustomAttribute allocates a new attribute instance EVERY TIME you call it — constructor runs, properties assigned, object allocated on heap.
The runtime does NOT cache attribute instances. If you call it twice, you get two separate objects.
Rule: For attributes read more than once (per request, per loop), cache them in a static ConcurrentDictionary<MethodInfo, MyAttribute>. One dictionary lookup is ~10ns; reflection + allocation is ~1000ns — 100x slower.
Key Takeaway
Attributes are just classes that inherit System.Attribute — the square brackets are compiler sugar, and the attribute object isn't instantiated until reflection asks for it.
Attributes store metadata in the compiled assembly IL — they have zero runtime cost unless you call GetCustomAttribute().
Rule: Cache GetCustomAttribute results for anything called more than once — store them in a static dictionary at startup, because reflection reads are expensive at scale.
Caching Attribute Reads
IfAttribute read once at application startup (like routing table build)
UseNo caching needed. Read attributes during startup, store in data structure. Reflection cost amortised over app lifetime.
IfAttribute read per request (authorisation, rate limiting, audit logging)
UseCache in static ConcurrentDictionary<MethodInfo, T>. Use GetOrAdd to compute once. 1000 requests → 1 reflection call, 999 dictionary lookups.
IfAttribute read inside hot loop (thousands of iterations)
UseRead attribute before loop, store in variable. Avoid GetCustomAttribute inside loop entirely.
IfReflection still too slow even with caching (ultra-low latency requirements, <1ms p99)
UseUse source generators (Roslyn) to generate attribute access code at compile time. No reflection at runtime. Example: 'System.Text.Json' uses source generators for fast serialization.
IfNeed to read attributes from assembly loaded dynamically (plugins)
UseCache per loaded assembly. Use Assembly.GetTypes() once at load time, read attributes, store in Dictionary keyed by Type or MethodInfo.

Attribute Targets, AllowMultiple, and Inheritance — The Rules That Bite You

Once you start writing custom attributes, three settings in [AttributeUsage] determine everything: AttributeTargets, AllowMultiple, and Inherited. Getting these wrong is the most common source of confusing bugs.

AttributeTargets is a flags enum, so you can combine targets with the bitwise OR operator: AttributeTargets.Class | AttributeTargets.Method means the attribute is valid on both classes and methods. Use AttributeTargets.All if it genuinely makes sense everywhere, but be conservative — narrow targeting helps other developers avoid misusing your attribute.

AllowMultiple = true means you can stack the same attribute multiple times on one member. This is useful for things like [InlineData(1), InlineData(2)] in xUnit where each instance carries different test data. When AllowMultiple is false (the default) and you accidentally apply the same attribute twice, you get a compile error — which is actually the safe, desirable outcome.

Inherited = true means if ClassA has your attribute and ClassB inherits from ClassA, calling GetCustomAttribute on ClassB will also return the attribute. This is the default and usually what you want. Set it to false when the attribute is specifically about the declaring class, not its descendants — for example, a [DatabaseTable("customers")] attribute mapping a class to a DB table name shouldn't silently inherit to subclasses that might map to different tables.

io/thecodeforge/csharp/reflection/AttributeTargetsDemo.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
using System;
using System.Reflection;

// This attribute can go on a class OR a method — not a property or field.
// AllowMultiple = true so we can attach multiple tags to one method.
// Inherited = false — subclasses don't silently inherit ownership tags.
[AttributeUsage(
    AttributeTargets.Class | AttributeTargets.Method,
    AllowMultiple = true,
    Inherited = false
)]
public sealed class OwnedByTeamAttribute : Attribute
{
    public string TeamName { get; }
    public string SlackChannel { get; set; } = "#engineering";

    public OwnedByTeamAttribute(string teamName)
    {
        TeamName = teamName;
    }
}

// Two attributes on the same class — allowed because AllowMultiple = true.
[OwnedByTeam("Payments", SlackChannel = "#payments-team")]
[OwnedByTeam("Platform", SlackChannel = "#platform-infra")]
public class RefundProcessor
{
    // A single attribute on a method.
    [OwnedByTeam("Payments")]
    public void IssueRefund(string orderId, decimal amount)
    {
        Console.WriteLine($"Refund of £{amount} issued for order {orderId}");
    }

    public void LogRefundAttempt(string orderId)
    {
        Console.WriteLine($"Logging refund attempt for {orderId}");
    }
}

// Subclass — does NOT inherit the [OwnedByTeam] attributes
// because Inherited = false on the attribute definition.
public class PartialRefundProcessor : RefundProcessor { }

class Program
{
    static void Main()
    {
        Type processorType = typeof(RefundProcessor);

        // GetCustomAttributes returns ALL instances when AllowMultiple = true.
        OwnedByTeamAttribute[] classOwners =
            (OwnedByTeamAttribute[])processorType
                .GetCustomAttributes(typeof(OwnedByTeamAttribute), inherit: false);

        Console.WriteLine("=== RefundProcessor owners ===");
        foreach (var owner in classOwners)
        {
            Console.WriteLine($"  Team: {owner.TeamName} | Channel: {owner.SlackChannel}");
        }

        // Now check the subclass — should have ZERO owners because Inherited = false.
        Type subType = typeof(PartialRefundProcessor);
        OwnedByTeamAttribute[] subOwners =
            (OwnedByTeamAttribute[])subType
                .GetCustomAttributes(typeof(OwnedByTeamAttribute), inherit: false);

        Console.WriteLine($"\n=== PartialRefundProcessor owners (expect 0): {subOwners.Length} ===");

        // Check the method — one attribute, attached at method level.
        MethodInfo issueRefundMethod = processorType.GetMethod("IssueRefund")!;
        OwnedByTeamAttribute[] methodOwners =
            (OwnedByTeamAttribute[])issueRefundMethod
                .GetCustomAttributes(typeof(OwnedByTeamAttribute), inherit: false);

        Console.WriteLine("\n=== IssueRefund method owners ===");
        foreach (var owner in methodOwners)
        {
            Console.WriteLine($"  Team: {owner.TeamName}");
        }
    }
}
Watch Out:
The inherit parameter in GetCustomAttributes(type, inherit: true) and the Inherited property in [AttributeUsage] are NOT the same switch. Inherited on [AttributeUsage] controls whether the CLR considers the attribute inheritable at all. The inherit parameter on GetCustomAttributes is just asking 'should I walk up the inheritance chain to look?' — but if Inherited = false on the attribute definition, walking the chain still won't find it. Both need to be true for inherited attribute reading to work.
Production Insight
Inherited = true (default) means your attribute will appear on ALL subclasses via reflection, even if those subclasses are in different assemblies.
This can cause unintended behaviour: a [DatabaseTable("orders")] attribute on a base class will be read for every derived class, causing all to map to the same table.
Rule: Set Inherited = false for any attribute that carries context-specific data (table name, route prefix, resource ID). Set Inherited = true only for cross-cutting concerns that should logically apply to all subclasses (e.g., [Authorize]).
Key Takeaway
Always set Inherited = false on attributes that carry class-specific data (table names, route prefixes, resource identifiers) — silent inheritance is a subtle, hard-to-debug bug.
Cache GetCustomAttribute() results for anything called more than once — store them in a static dictionary at startup, because reflection reads are expensive at scale.
Rule: AttributeUsage defaults (AllowMultiple=false, Inherited=true) are not always safe; override them explicitly based on your attribute's semantics.

Attribute Targets Quick-Reference Table

The AttributeTargets enum defines every code element you can decorate with an attribute. You combine them with bitwise OR (|) in [AttributeUsage]. Below is the complete list of values, each with a description and an example of when you'd use it.

ValueApplies ToCommon Example
AssemblyThe entire assembly (file)[AssemblyTitle("MyApp")] — assembly-level metadata
ModuleA module (DLL/EXE within assembly)[Module] — rarely used directly
ClassA class[Serializable] public class Order { }
StructA struct[StructLayout(LayoutKind.Sequential)] for interop
EnumAn enum[Flags] public enum Permissions { }
ConstructorA constructor[Obsolete] public MyClass() { }
MethodA method[HttpGet] public IActionResult Get() { }
PropertyA property[JsonPropertyName("id")] public int Id { get; set; }
FieldA field[NonSerialized] private int _cache;
EventAn event[field: NonSerialized] public event EventHandler Changed; — field target
InterfaceAn interface[ServiceContract] public interface IMyService { }
ParameterA method parameter[CallerMemberName] string callerName = ""
DelegateA delegate type[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
ReturnValueA method return value[return: MarshalAs(UnmanagedType.Bool)] bool Method()
GenericParameterA generic type parameterRarely used; [type: ...] syntax
AllCombination of all targetsUse with caution: [AttributeUsage(AttributeTargets.All)]

When designing your own attribute, be as specific as possible. For example, if your attribute only makes sense on methods, use AttributeTargets.Method — not All. This gives compile-time validation and makes intent clear.

io/thecodeforge/csharp/reflection/AttributeTargetsEnum.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property)]
public class SensitiveDataAttribute : Attribute { }

[SensitiveData] // on class is valid
public class UserCredentials
{
    [SensitiveData] // on property is valid
    public string Password { get; set; } = string.Empty;

    // Compile error: Attribute 'SensitiveDataAttribute' is not valid on this declaration type.
    // [SensitiveData]
    // public void Encrypt() { }

    // This would work if we added AttributeTargets.Method above.
}
Combo Syntax
You can combine targets with the vertical bar: [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]. This is valid and common. If you forget to specify any target, the C# compiler defaults to AttributeTargets.All — which is almost always too broad.
Production Insight
Overly broad AttributeTargets (e.g., All) means your custom attribute might accidentally be placed on parameters or return values, where it silently gets ignored by your reading code but still clutters metadata. Be conservative: only allow the exact targets your code actually reads. This also speeds up reflection if you later scan for your attribute — the CLR doesn't have to check targets that aren't allowed.
Key Takeaway
Always specify AttributeTargets explicitly in [AttributeUsage]. Narrow targets prevent misuse and keep your attribute design clean.

Visual Reflection Pipeline: How Attributes Are Read and Cached

The pipeline is straightforward but the performance cliff is hidden in the 'No' branch. Each 'No' costs a constructor invocation, property assignment, and heap allocation. At 10,000 RPS, even a 1-microsecond instantiation adds 10ms of CPU time per second — pinning a core at 100%.

The diagram shows the key enabler: a ConcurrentDictionary<MethodInfo, T> sitting between the reflection call and the business logic. The first request suffers the allocation cost; all subsequent requests pay only the dictionary lookup (nanoseconds). For ultra-hot paths, source generators (Roslyn) skip the entire pipeline by generating attribute access code at compile time — the attribute data is baked into static fields, eliminating both reflection and caching.

Hidden Cost of the 'No' Branch:
Every time you cache-miss, the CLR must not only construct the attribute but also walk the type's inheritance chain (if Inherited=true) and perform security checks. These overheads are absent from the cached path. The first request for each method pays all these costs.
Production Insight
In production, the first request to each endpoint pays the full reflection cost. If you use health checks or warm-up requests (e.g., Azure App Service Always On), you can populate the cache during application startup rather than on first user request. This eliminates the initial latency spike.
Key Takeaway
Attribute reading follows a simple pipeline: metadata → instantiation → use. Caching with ConcurrentDictionary eliminates the instantiation cost on subsequent reads. Warm-up requests can pre-populate the cache to avoid first-request latency.

Attributes and Conditional Compilation — The #if (DEBUG) Pattern

Attributes are metadata attached at compile time, but their presence can be controlled by conditional compilation symbols like DEBUG, RELEASE, or custom symbols. This is useful when you want attribute-driven behaviour only in certain build configurations without modifying the source code.

There are two mechanisms: one is the [Conditional] attribute from System.Diagnostics — it marks a method so that calls to it (including attribute instantiation if the attribute's code uses it) are omitted unless the specified symbol is defined. However, [Conditional] on an attribute class itself only works if the attribute's constructor or property setters are called — but attribute instantiation via reflection always happens at runtime regardless of conditional compilation symbols. So [Conditional] is not the right tool for conditional attribute application.

The correct approach is to use #if SYMBOL / #endif blocks around the attribute application itself. This physically removes the attribute from the source code during compilation when the symbol is not defined. For example, you might want a [LogEveryRequest] attribute that should only exist in debug builds. By wrapping it in #if DEBUG, the attribute is not compiled into the release assembly — reflection will never find it because the metadata token is absent.

This technique is often paired with a using static directive or alias to keep code clean. The key trade-off: conditional compilation removes the attribute entirely for non-debug builds, which means your reflective code must handle the case where the attribute is absent (returning null). This is usually fine — the default behaviour (no attribute) is the release path.

io/thecodeforge/csharp/reflection/ConditionalCompilation.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
using System;
using System.Diagnostics;
using System.Reflection;

public class DebugOnlyAttribute : Attribute
{
    public string Reason { get; }
    public DebugOnlyAttribute(string reason) => Reason = reason;
}

public class RequestHandler
{
    // This attribute only exists in DEBUG builds.
    // In RELEASE, the compiler sees nothing above this method.
#if DEBUG
    [DebugOnly("Tracing all requests for debugging")]
#endif
    public void HandleRequest()
    {
        Console.WriteLine("Request handled.");
    }
}

class Program
{
    static void Main()
    {
        var method = typeof(RequestHandler).GetMethod(nameof(RequestHandler.HandleRequest))!;
        var attr = method.GetCustomAttribute<DebugOnlyAttribute>();

        if (attr != null)
        {
            Console.WriteLine($"Debug attribute found: {attr.Reason}");
        }
        else
        {
            Console.WriteLine("No DebugOnly attribute — we're in RELEASE mode.");
        }
    }
}
When to Use This Pattern:
Use conditional compilation around attribute applications for debug-only instrumentation (logging, timing, telemetry) that must have zero overhead in release. Never use it for security attributes (like [Authorize]) — those must always be present regardless of build configuration.
Production Insight
When you use #if DEBUG around attribute applications, the release build has zero reflection cost for that attribute because the metadata doesn't exist. This is the most aggressive optimisation: you don't just cache it, you eliminate it entirely. However, it also means you can't toggle the behaviour at runtime via configuration—it's a compile-time decision. If you need runtime configurability, use a normal attribute combined with a feature flag read at startup.
Key Takeaway
Wrap attribute applications in #if SYMBOL to completely remove them from non-debug builds. This gives zero overhead in release but requires the reading code to handle absent attributes gracefully.

Caching Implementation — Production-Ready Attribute Cache

The single most impactful optimisation for attribute-heavy code is caching with ConcurrentDictionary. Below is a production-ready implementation that handles thread safety, lazy initialisation, and cleanup for scenarios where types can be unloaded (e.g., dynamic assemblies in plugin systems).

The pattern is straightforward: define a static generic cache class that stores Lazy<TAttribute> values keyed by MethodInfo (or Type, PropertyInfo, etc.). The Lazy<T> ensures that even under concurrent first access, the attribute is instantiated exactly once per key. The dictionary is static and shared across the application domain, so all requests benefit from the same cache.

For high-traffic endpoints (10k+ RPS), this pattern reduces attribute lookup cost from ~1µs to ~50ns — a 20x improvement. That's the difference between 10ms and 0.5ms CPU time per second at 10k RPS.

io/thecodeforge/csharp/reflection/AttributeCache.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
using System;
using System.Collections.Concurrent;
using System.Reflection;

/// <summary>
/// Thread-safe cache for attribute lookups.
/// Uses Lazy<T> to ensure attribute is instantiated at most once per key,
/// even under concurrent requests.
/// </summary>
public static class AttributeCache<TAttribute> where TAttribute : Attribute
{
    private static readonly ConcurrentDictionary<MemberInfo, Lazy<TAttribute?>> _cache = new();

    public static TAttribute? Get(MemberInfo member)
    {
        // GetOrAdd is thread-safe: the factory is executed at most once per key.
        return _cache.GetOrAdd(member, mi =>
            new Lazy<TAttribute?>(() => mi.GetCustomAttribute<TAttribute>())
        ).Value;
    }

    /// <summary>
    /// For assemblies that can be unloaded (AssemblyLoadContext),
    /// call this method when the assembly is unloaded to release cached references.
    /// </summary>
    public static void Clear()
    {
        _cache.Clear();
    }
}

// Usage in a middleware or service:
public class RateLimitMiddleware
{
    private static readonly ConcurrentDictionary<MethodInfo, RateLimitAttribute?> _rateLimitCache = new();

    public void ProcessRequest(string endpoint)
    {
        MethodInfo method = GetControllerMethod(endpoint); // hypothetical

        // Using the generic cache:
        var attr = AttributeCache<RateLimitAttribute>.Get(method);
        if (attr != null)
        {
            // Apply rate limiting logic using attr.MaxRequests etc.
        }

        // Alternative direct ConcurrentDictionary (faster for single attribute type):
        var attr2 = _rateLimitCache.GetOrAdd(method, m => m.GetCustomAttribute<RateLimitAttribute>());
    }

    private MethodInfo GetControllerMethod(string endpoint) => typeof(object).GetMethod("ToString")!; // placeholder
}
Cache Key Strategy:
Use MethodInfo, Type, PropertyInfo, etc., as cache keys. These are reference types with efficient hash codes and equality checks. Avoid using strings (method names) as keys — they're slower, don't work for overloaded methods, and don't reflect the actual member identity.
Production Insight
This caching pattern is used by ASP.NET Core internally for its own attribute lookups (routing, validation, filters). The framework caches the results of reflection at startup or on first use. Your custom middleware should do the same. For multi-tenancy scenarios (different assemblies per tenant), key the cache by both the MemberInfo and the AssemblyLoadContext to avoid stale references after tenant assembly unload.
Key Takeaway
A static ConcurrentDictionary<MemberInfo, TAttribute> with Lazy<T> provides thread-safe, once-per-member caching. This is the standard production pattern for eliminating repeated attribute instantiation overhead.

Attributes in the Real World — ASP.NET Core, JSON, and Data Annotations

You've been using attribute-driven frameworks all along — let's pull back the curtain on three you already know.

In ASP.NET Core, [HttpGet], [HttpPost], and [Route] are custom attributes read by the routing middleware at startup. When the app boots, MVC scans all controller types using reflection, finds methods decorated with HTTP verb attributes, and builds an internal route table. Your method doesn't do anything special — the framework does all the work by reading the metadata.

In System.Text.Json, [JsonPropertyName("order_id")] tells the serializer to map the C# property OrderId to the JSON key order_id. The serializer reads this attribute at serialization time and adjusts its output. [JsonIgnore] tells it to skip the property entirely — useful for passwords or computed values you never want to leak into an API response.

Data Annotations like [Required], [StringLength(100)], and [Range(1, 999)] are read by both ASP.NET Core's model binding (to auto-validate incoming request bodies) and Entity Framework Core (to infer database column constraints). One attribute, two completely separate systems reading it for their own purposes. That's the elegance of metadata: you declare intent once, and any system that cares about it can act on it.

Understanding this pattern means you can build your own mini-frameworks — test runners, CLI argument parsers, config binders — using the same approach the big players use.

io/thecodeforge/csharp/reflection/RealWorldAttributes.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;

// A model that uses Data Annotations for validation
// and System.Text.Json attributes for serialization shape.
public class CreateOrderRequest
{
    // [Required] — model binding will reject this if missing.
    // [StringLength] — EF Core will set VARCHAR(50) in the database.
    [Required(ErrorMessage = "Customer ID is mandatory.")]
    [StringLength(50, ErrorMessage = "Customer ID cannot exceed 50 characters.")]
    [JsonPropertyName("customer_id")] // JSON key uses snake_case
    public string CustomerId { get; set; } = string.Empty;

    [Range(1, 10000, ErrorMessage = "Order amount must be between £1 and £10,000.")]
    [JsonPropertyName("amount_gbp")]
    public decimal AmountGbp { get; set; }

    // [JsonIgnore] — this field is set server-side and must NEVER
    // appear in the outbound JSON response.
    [JsonIgnore]
    public string InternalTrackingCode { get; set; } = Guid.NewGuid().ToString();
}

// A simple validator that manually reads Data Annotation attributes.
// ASP.NET Core does this for you automatically — this shows you what's under the hood.
public static class ManualValidator
{
    public static List<string> Validate(object model)
    {
        var errors = new List<string>();
        Type modelType = model.GetType();

        foreach (PropertyInfo property in modelType.GetProperties())
        {
            // Get the actual value of this property on our model instance.
            object? value = property.GetValue(model);

            // Read ALL validation attributes from this property.
            IEnumerable<ValidationAttribute> validationAttrs =
                property.GetCustomAttributes<ValidationAttribute>();

            foreach (ValidationAttribute validator in validationAttrs)
            {
                // IsValid() is defined on ValidationAttribute base class.
                // Each subclass ([Required], [Range], etc.) overrides it.
                if (!validator.IsValid(value))
                {
                    // FormatErrorMessage fills in placeholders like {0} with the property name.
                    errors.Add($"{property.Name}: {validator.FormatErrorMessage(property.Name)}");
                }
            }
        }

        return errors;
    }
}

class Program
{
    static void Main()
    {
        // === Scenario 1: Invalid request — missing CustomerId, amount out of range ===
        var badRequest = new CreateOrderRequest
        {
            CustomerId = "",         // Fails [Required]
            AmountGbp = 99999m,      // Fails [Range]
            InternalTrackingCode = "TRK-001"
        };

        List<string> validationErrors = ManualValidator.Validate(badRequest);
        Console.WriteLine("=== Validation Errors ===");
        foreach (string error in validationErrors)
            Console.WriteLine($"  ✗ {error}");

        // === Scenario 2: Valid request — serialize to JSON ===
        var goodRequest = new CreateOrderRequest
        {
            CustomerId = "CUST-4421",
            AmountGbp = 149.99m,
            InternalTrackingCode = "TRK-SECRET-002" // This will NOT appear in JSON output
        };

        string json = JsonSerializer.Serialize(
            goodRequest,
            new JsonSerializerOptions { WriteIndented = true }
        );

        Console.WriteLine("\n=== Serialized JSON (note: no InternalTrackingCode) ===");
        Console.WriteLine(json);
    }
}
Interview Gold:
When an interviewer asks how ASP.NET Core model validation works, the answer is 'Data Annotation attributes read via reflection by the model binding pipeline.' Being able to demonstrate this by writing a manual validator (like above) shows you understand the mechanism, not just the magic.
Production Insight
ASP.NET Core reads attributes ONCE at startup and caches them in data structures (route tables, validation metadata, JSON contract resolvers).
System.Text.Json's default contract resolver reads attributes every time? No — it caches contract resolution per type. First call builds a contract (reflection), subsequent calls reuse it.
Rule: Let frameworks cache attributes for you. Don't read attributes in your own middleware per request unless you also cache them. Frameworks like ASP.NET Core do the caching already — your custom middleware might not.
Key Takeaway
Frameworks read attributes at startup or on first use, then cache the metadata. This is why they're performant despite using reflection.
Your own code should follow the same pattern: read attributes once, cache them, never reflect in hot paths.
Rule: Attribute-driven frameworks are not magic — they're just well-designed reflection caches.
● Production incidentPOST-MORTEMseverity: high

The 15ms Attribute Lookup That Took Down Black Friday

Symptom
Latency graph showed linear increase from 50ms to 5000ms as traffic rose. CPU was pinned at 100% on all web servers. Profiling showed 35% of CPU time in System.Reflection.RuntimeMethodInfo.GetCustomAttributes. No database queries were slow; no external API calls timed out. The call stack pointed to a RateLimitAttribute in a custom middleware.
Assumption
The team assumed reflection was 'fast enough' because they tested with 100 requests per second in staging. They didn't profile with peak Black Friday traffic (10k RPS). They didn't know that GetCustomAttribute allocates a new attribute instance on every call (constructor runs, properties assigned). They also assumed the attribute would be JIT-compiled after first call — but instantiation cost is per call, not per type.
Root cause
A custom [RateLimit(100)] attribute was read inside a middleware for EVERY request. The middleware did: var attr = methodInfo.GetCustomAttribute<RateLimitAttribute>(); No caching. At 10,000 RPS: 10,000 attribute instantiations per second. Each instantiation called the attribute constructor, assigned properties, and allocated a new object on heap (GC pressure). Reflection also performed type hierarchy walking (inheritance chain) and security checks on each call. The team had no caching layer. The CPU spent 35% on reflection alone, bottlenecking the request pipeline. The rest of the API code (database query, JSON serialization) was fine, but the reflection overhead was enough to saturate the CPU.
Fix
1. Cached attributes in a static ConcurrentDictionary<MethodInfo, RateLimitAttribute> at startup. Lookup changed from O(reflection + allocation) to O(1) dictionary lookup. 2. Used Lazy<T> to compute attribute once per method: static ConcurrentDictionary<MethodInfo, Lazy<RateLimitAttribute>> _cache. 3. For hot paths, switched to source generators (Roslyn) that generate compile-time attribute code, eliminating reflection entirely. 4. Added a performance test that measures attribute lookup overhead at expected peak RPS. 5. Documented the rule: 'Never call GetCustomAttribute inside a loop. Cache at startup.'
Key lesson
  • GetCustomAttribute allocates a new attribute instance on EVERY call. It's not cached by the runtime. Cache it yourself.
  • Reflection is not 'a bit slow' — it's orders of magnitude slower than direct access. At 10k RPS, microseconds become milliseconds.
  • Always profile attribute-heavy code with realistic concurrency. A 1µs lookup becomes 10ms of CPU time at 10k RPS (1µs * 10,000 = 10ms).
  • For ultra-hot paths, use source generators (Roslyn) to completely eliminate runtime reflection. The attribute metadata is baked into generated code at compile time.
Production debug guideSymptom → Action mapping for common attribute failures in production .NET applications.5 entries
Symptom · 01
High CPU usage (30-50% in reflection code) — profiler shows GetCustomAttribute
Fix
You're calling GetCustomAttribute in a hot path (per-request, per-loop). Cache attributes in static ConcurrentDictionary<MethodInfo, T> at startup. For truly hot paths, use source generators to eliminate reflection.
Symptom · 02
Attribute seems to disappear on derived classes — should apply but doesn't
Fix
Check [AttributeUsage] Inherited parameter. Default is true (attribute applies to derived classes). If you set Inherited = false, derived classes won't see the attribute. Also check inherit: true in GetCustomAttribute call.
Symptom · 03
Attribute appears on derived class but shouldn't — EF Core mapping conflict
Fix
Inherited defaults to true. Set Inherited = false on attributes with class-specific data (table mappings, route prefixes, resource identifiers). Example: [AttributeUsage(Inherited = false)]
Symptom · 04
Multiple attributes on same target not working — only first is read
Fix
Check AllowMultiple = false (default). You cannot apply the same attribute twice unless AllowMultiple = true. Change: [AttributeUsage(AllowMultiple = true)]. Also ensure GetCustomAttributes (plural) is used, not GetCustomAttribute (singular).
Symptom · 05
Attribute constructor argument must be constant — compile error
Fix
Attribute arguments must be compile-time constants (typeof, string, int, enum, bool, char). You cannot pass variables, method return values, or new object instances. Use a constant or store a key string and look up real value at runtime.
★ C# Attribute Debug Cheat SheetFast diagnostics for attribute issues in production .NET applications.
High CPU from reflection — GetCustomAttribute in hot path
Immediate action
Cache attribute lookups in static dictionary
Commands
dotnet-trace collect --providers Microsoft-Windows-DotNETRuntime --pid <pid>
dotnet-dump collect -p <pid>; analyze with PerfView
Fix now
Add private static readonly ConcurrentDictionary<MethodInfo, MyAttribute> _cache = new(); then return _cache.GetOrAdd(methodInfo, mi => mi.GetCustomAttribute<MyAttribute>());
Attribute not found on subclass — Inherited issue+
Immediate action
Check AttributeUsage.Inherited setting and GetCustomAttribute inherit parameter
Commands
grep -n 'AttributeUsage' src/**/*.cs | grep Inherited
grep -n 'GetCustomAttribute.*inherit' src/**/*.cs
Fix now
Set [AttributeUsage(Inherited = true)] on attribute definition. For reading, pass inherit: true to GetCustomAttribute<T>(inherit: true).
Multiple attributes of same type not being read+
Immediate action
Check AllowMultiple = true and use GetCustomAttributes (plural)
Commands
grep -n 'AllowMultiple' src/**/*.cs
grep -n 'GetCustomAttribute.*<.*>' src/**/*.cs | grep -v 'GetCustomAttributes'
Fix now
Add [AttributeUsage(AllowMultiple = true)]. Use GetCustomAttributes<T>() (returns IEnumerable<T>) not GetCustomAttribute<T>() (returns single or null).
Compile error 'An attribute argument must be a constant'+
Immediate action
Replace variable argument with constant or typeof()
Commands
grep -n 'new .*Attribute(' src/**/*.cs
grep -n 'static.*const.*=' src/**/*.cs
Fix now
Attribute constructor parameters must be constants (string, int, bool, enum, char, typeof). For dynamic values, store a key string in attribute and look up from config or DI at runtime.
Memory leak — attribute instances accumulating+
Immediate action
Check if attribute instances are being stored beyond their intended lifetime
Commands
dotnet-dump collect -p <pid>
!dumpheap -stat -type Attribute
Fix now
If you're storing attribute instances in static collections, ensure they're not preventing GC. Use weak references or cache only essential data, not the whole attribute object.
Built-in vs Custom Attributes
AspectBuilt-in Attributes ([Obsolete], [Serializable])Custom Attributes ([RequiresAuditLog])
Who reads themThe CLR, compiler, or a specific framework (e.g., ASP.NET Core)Your own code via reflection, or a framework you build
When they're evaluatedCompile time (e.g., [Obsolete]) or framework startup (e.g., [HttpGet])Whenever your reflection code runs — typically at startup or per-request (but should be cached)
Performance costNear-zero — the CLR has optimised paths for its own attributes. Frameworks cache them aggressively.Reflection cost per read — must cache with ConcurrentDictionary or source generators for hot paths
Defining themAlready defined in .NET — just apply themInherit from System.Attribute, decorate with [AttributeUsage]
Use case fitBroad, framework-level concerns (serialization, routing, threading)Domain-specific cross-cutting concerns (auditing, feature flags, ownership tagging)
Compile-time safetyFull — the compiler knows their rulesPartial — [AttributeUsage] enforces valid targets at compile time, but data values are runtime
Inheritance controlVaries per attribute — [Serializable] is not inherited, [Obsolete] warning is, etc.Controlled by [AttributeUsage] Inherited flag — set explicitly

Key takeaways

1
Attributes are just classes that inherit System.Attribute
the square brackets are compiler sugar, and the attribute object isn't instantiated until reflection asks for it.
2
Attributes store metadata in the compiled assembly IL
they have zero runtime cost unless you call GetCustomAttribute(), which is why [JsonIgnore] doesn't slow down your app until the serializer runs.
3
Always set Inherited = false on attributes that carry class-specific data (table names, route prefixes, resource identifiers)
silent inheritance is a subtle, hard-to-debug bug.
4
Cache GetCustomAttribute() results for anything called more than once
store them in a static dictionary at startup, because reflection reads are expensive at scale and this single change can eliminate a common production performance bottleneck.
5
Attribute constructors accept only compile-time constants (typeof, string, int, enum). For dynamic data, store a key string and look up runtime config after attribute instantiation.

Common mistakes to avoid

5 patterns
×

Calling GetCustomAttribute in a tight loop without caching

Symptom
Noticeable performance degradation on high-traffic endpoints, often spotted only in production profiling. Each call uses reflection + allocation, which is significantly slower than direct property access.
Fix
Read the attribute once per type or method at startup and store the result in a static ConcurrentDictionary<MethodInfo, MyAttribute?> keyed on the MethodInfo. The cost drops to a single dictionary lookup on subsequent calls. For ultra-hot paths, use source generators to eliminate reflection entirely.
×

Forgetting that [AttributeUsage] defaults Inherited to true

Symptom
A subclass silently inherits an attribute (e.g., a [DatabaseTable("orders")] mapping) that should NOT apply to it, causing EF Core to try mapping two classes to the same table — runtime error.
Fix
Explicitly set Inherited = false on any attribute that carries class-specific data like table names, queue names, or routing prefixes. Only set Inherited = true when the behaviour genuinely should cascade to subclasses (e.g., [Authorize] for base controller).
×

Trying to use an attribute's value in an attribute constructor (circular dependency)

Symptom
Compile error 'An attribute argument must be a constant expression, typeof expression or array creation expression'. Attributes must be fully resolvable at compile time, so you can't pass a variable, a method return value, or a non-const field as an argument.
Fix
Use only const values, string/number literals, typeof(), or enum values in attribute constructors. If you need dynamic data, store a key string in the attribute and look up the real value at runtime from config or a dictionary (after attribute is instantiated).
×

Using GetCustomAttribute instead of GetCustomAttributes when AllowMultiple = true

Symptom
Only the first attribute instance is returned, the rest are ignored. This leads to missing metadata (e.g., only first [InlineData] used in xUnit, causing tests to run with incomplete data).
Fix
When AllowMultiple = true, always use GetCustomAttributes<T>() (plural), which returns IEnumerable<T>. Iterate over the collection to process all instances. GetCustomAttribute<T>() (singular) returns only the first and makes no guarantee about order.
×

Assuming GetCustomAttribute is thread-safe for caching without synchronisation

Symptom
Multiple threads calling _cache.GetOrAdd simultaneously may cause the attribute to be instantiated multiple times (wasting CPU). Worse, if the attribute constructor has side effects or is expensive, you get duplicate work.
Fix
Use ConcurrentDictionary<TKey, TValue>.GetOrAdd which is thread-safe. For simple caches, use Lazy<T> to ensure the attribute is instantiated at most once: _cache.GetOrAdd(key, k => new Lazy<Attr>(() => k.GetCustomAttribute<Attr>())).Value.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between Inherited = true on [AttributeUsage] and ...
Q02SENIOR
How would you implement a simple method-level caching attribute in C# — ...
Q03SENIOR
If you put [Obsolete] on a method and call that method in code, does it ...
Q04SENIOR
How would you design a system to read attributes from assembly plugins w...
Q01 of 04SENIOR

What is the difference between Inherited = true on [AttributeUsage] and passing inherit: true to GetCustomAttributes()? Can you have a situation where one is true and the other is false, and what happens?

ANSWER
Inherited on [AttributeUsage] is a declaration about the attribute's semantics: does the CLR consider this attribute inheritable at all? If Inherited = false, the attribute's metadata is not marked as inheritable at the assembly level. The inherit parameter on GetCustomAttributes is a query instruction: 'should I walk up the inheritance chain to look for attributes?' If Inherited = false on the attribute definition, walking the chain still won't find it because the attribute's metadata flags indicate it's not inheritable. Both must be true for inherited reading to work. Example: [AttributeUsage(Inherited = false)] on the attribute — even with GetCustomAttributes(inherit: true), the attribute won't appear on subclasses. The CLR respects the attribute's metadata flag. The reverse (Inherited = true on attribute, but GetCustomAttributes(inherit: false)) will not walk the chain, so you won't see attributes from base classes. The two flags are independent gates: the attribute definition's Inherited says 'this attribute CAN be inherited'; the query's inherit says 'PLEASE look for inheritance'. Both gates must be open.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between an attribute and a decorator pattern in C#?
02
Can you put an attribute on a local variable in C#?
03
Are C# attributes the same as Java annotations?
04
How do I make an attribute apply only to specific property types (e.g., only string properties)?
🔥

That's C# Advanced. Mark it forged?

7 min read · try the examples if you haven't

Previous
Reflection in C#
7 / 15 · C# Advanced
Next
Span and Memory in C#