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
✦ Definition~90s read
What is Attributes in C#?
C# attributes are metadata annotations baked into your assembly's IL at compile time. They're not runtime objects — they're blobs of data stored in the module's metadata tables, keyed by the target element (class, method, property, etc.). When you slap [Obsolete] or [Route] on something, the compiler serializes the attribute constructor arguments and named parameters into a binary format in the .text section.
★
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.
At runtime, reflection deserializes those blobs into actual Attribute instances on every call to GetCustomAttributes(). That deserialization is expensive: it involves type loading, constructor invocation, and argument parsing — and by default, nothing caches it.
If you're calling GetCustomAttributes() in a hot path, you're paying that cost per invocation, which directly tanks your requests per second (RPS).
Attributes solve the problem of attaching declarative metadata to code without polluting the implementation — think routing, validation, serialization, or AOP markers. Alternatives include XML configuration (fragile, no compile-time checking), code generators (more powerful but heavier), or plain interfaces/abstract classes (runtime polymorphism, but no declarative syntax).
Don't use attributes for runtime state or logic that changes frequently — they're compile-time constants. The real trap is that most developers assume attribute access is cheap because it looks like a simple property read. It's not. The reflection API does a full metadata walk, instantiates objects, and returns arrays — all unoptimized.
Production systems at scale (e.g., ASP.NET Core routing, Entity Framework model building) have internal caches precisely because raw attribute access destroys throughput.
The caching gap is the silent killer: your [Authorize] or [Display(Name="...")] attribute reads in middleware or serialization loops are likely uncached, meaning every request triggers a metadata parse. A single GetCustomAttribute<MyAttribute>() can cost 1-5 microseconds — sounds tiny until you multiply by 10 attributes × 1000 RPS = 50ms of pure reflection overhead per second.
The fix is a concurrent dictionary or lazy-initialized cache keyed on (Type, MemberInfo, AttributeType), which drops that cost to ~50 nanoseconds per lookup. If you're not caching attribute reads in hot paths, you're leaving RPS on the table.
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.
usingSystem;
usingSystem.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]
publicclassCustomerOrder
{
publicintOrderId { 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.")]
publicdecimalCalculateTotal()
{
return99.99m;
}
publicdecimalCalculateTotalWithTax(decimal vatRate)
{
return99.99m * (1 + vatRate);
}
}
classProgram
{
staticvoidMain()
{
var order = newCustomerOrder { 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.
thecodeforge.io
Attributes Csharp
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.
usingSystem;
usingSystem.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
)]
publicsealedclassOwnedByTeamAttribute : Attribute
{
publicstringTeamName { get; }
publicstringSlackChannel { get; set; } = "#engineering";
publicOwnedByTeamAttribute(string teamName)
{
TeamName = teamName;
}
}
// Two attributes on the same class — allowed because AllowMultiple = true.
[OwnedByTeam("Payments", SlackChannel = "#payments-team")]
[OwnedByTeam("Platform", SlackChannel = "#platform-infra")]
publicclassRefundProcessor
{
// A single attribute on a method.
[OwnedByTeam("Payments")]
publicvoidIssueRefund(string orderId, decimal amount)
{
Console.WriteLine($"Refund of £{amount} issued for order {orderId}");
}
publicvoidLogRefundAttempt(string orderId)
{
Console.WriteLine($"Logging refund attempt for {orderId}");
}
}
// Subclass — does NOT inherit the [OwnedByTeam] attributes// because Inherited = false on the attribute definition.publicclassPartialRefundProcessor : RefundProcessor { }
classProgram
{
staticvoidMain()
{
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.
Use 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.
usingSystem;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property)]
publicclassSensitiveDataAttribute : Attribute { }
[SensitiveData] // on class is validpublicclassUserCredentials
{
[SensitiveData] // on property is validpublicstringPassword { 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.
thecodeforge.io
Attributes Csharp
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.
Attribute Read Pipeline with Caching
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.
usingSystem;
usingSystem.Diagnostics;
usingSystem.Reflection;
publicclassDebugOnlyAttribute : Attribute
{
publicstringReason { get; }
publicDebugOnlyAttribute(string reason) => Reason = reason;
}
publicclassRequestHandler
{
// This attribute only exists in DEBUG builds.// In RELEASE, the compiler sees nothing above this method.
#ifDEBUG
[DebugOnly("Tracing all requests for debugging")]
#endif
publicvoidHandleRequest()
{
Console.WriteLine("Request handled.");
}
}
classProgram
{
staticvoidMain()
{
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.
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.
usingSystem;
usingSystem.Collections.Concurrent;
usingSystem.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>publicstaticclassAttributeCache<TAttribute> where TAttribute : Attribute
{
privatestaticreadonlyConcurrentDictionary<MemberInfo, Lazy<TAttribute?>> _cache = new();
publicstaticTAttribute? Get(MemberInfo member)
{
// GetOrAdd is thread-safe: the factory is executed at most once per key.return _cache.GetOrAdd(member, mi =>
newLazy<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>publicstaticvoidClear()
{
_cache.Clear();
}
}
// Usage in a middleware or service:publicclassRateLimitMiddleware
{
privatestaticreadonlyConcurrentDictionary<MethodInfo, RateLimitAttribute?> _rateLimitCache = new();
publicvoidProcessRequest(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 MethodInfoGetControllerMethod(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.
usingSystem;
usingSystem.Collections.Generic;
usingSystem.ComponentModel.DataAnnotations;
usingSystem.Reflection;
usingSystem.Text.Json;
usingSystem.Text.Json.Serialization;
// A model that uses Data Annotations for validation// and System.Text.Json attributes for serialization shape.publicclassCreateOrderRequest
{
// [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_casepublicstringCustomerId { get; set; } = string.Empty;
[Range(1, 10000, ErrorMessage = "Order amount must be between £1 and £10,000.")]
[JsonPropertyName("amount_gbp")]
publicdecimalAmountGbp { get; set; }
// [JsonIgnore] — this field is set server-side and must NEVER// appear in the outbound JSON response.
[JsonIgnore]
publicstringInternalTrackingCode { 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.publicstaticclassManualValidator
{
publicstaticList<string> Validate(object model)
{
var errors = newList<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;
}
}
classProgram
{
staticvoidMain()
{
// === Scenario 1: Invalid request — missing CustomerId, amount out of range ===var badRequest = newCreateOrderRequest
{
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 = newCreateOrderRequest
{
CustomerId = "CUST-4421",
AmountGbp = 149.99m,
InternalTrackingCode = "TRK-SECRET-002" // This will NOT appear in JSON output
};
string json = JsonSerializer.Serialize(
goodRequest,
newJsonSerializerOptions { 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.
Attribute Classes — The Compiler's Contract You Must Not Break
Every attribute is just a class that inherits from System.Attribute. That's it. The compiler enforces a rigid contract on these classes: they must have a public constructor, and any public read-write properties become named parameters. Positional parameters are constructor arguments.
Why this matters: The compiler serialises your attribute arguments into the assembly metadata at compile time. If you pass a type that isn't in the allowed parameter type list (bool, byte, char, short, int, long, float, double, string, Type, enum, object, or arrays of those), it silently fails or throws a runtime exception when reflection tries to instantiate the attribute. I've seen production pipelines crash because a junior passed a DateTime as a named parameter thinking it would work — it compiles, then throws at runtime.
Another trap: AttributeUsage controls where your attribute can be applied. If you don't specify it, the compiler assumes all targets. That sounds fine until you accidentally slap a routing attribute on a field. Always explicit. Always include AllowMultiple and Inherited — the defaults (false, true) have bitten teams in microservice configs where base class attributes suddenly vanish or duplicate.
Compiles successfully. No runtime output — attribute is metadata-only until read via reflection.
⚠ Production Trap: Runtime Instantiation Failure
Passing an unsupported type (like DateTime) as a named parameter compiles silently but throws a 'Cannot create an instance of type' exception when GetCustomAttribute is called. Always validate your attribute parameter types against the C# spec — not IntelliSense.
🎯 Key Takeaway
Attribute classes are just classes with a compiler-enforced parameter type whitelist. Know the type list or your reflection code explodes at runtime.
Compilation of Attributes — How the Metadata Pipeline Really Works
When the compiler sees an attribute, it doesn't magically inject code. It serialises the attribute specification into the assembly's metadata stream as a blob of bytes. The constructor arguments and named parameters are baked in — not evaluated at runtime. This is critical for understanding why you can't pass dynamic values or runtime expressions in attribute arguments.
The compiler takes your attribute class, resolves the constructor overload, evaluates all constant expressions, and encodes everything into a custom attribute blob. At runtime, when you call GetCustomAttribute, the CLR deserialises that blob by calling the constructor with the stored positional args, then setting the named properties. If the deserialisation fails (bad type, missing constructor parameter), you get a CustomAttributeFormatException.
This also means that if you change an attribute class's constructor signature after shipping, all assemblies compiled against the old signature will fail at runtime. I've debugged a NuGet version mismatch where an internal attribute added a required parameter — every plugin in the pipeline blew up with an opaque TypeLoadException. Always treat attribute classes as immutable public API once published.
For the caching junkies: The CLR already caches attribute instances per type/method/property in its own internal metadata tables. Your custom 'production-ready cache' only helps if you're scanning thousands of assemblies on cold start. Otherwise, you're just adding complexity.
MetadataBlobBreakdown.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
// io.thecodeforge — csharp tutorialusingSystem;
usingSystem.Reflection;
[AttributeUsage(AttributeTargets.Class)]
publicsealedclassApiVersionAttribute : Attribute
{
publicintMajor { get; }
publicstring? DeprecatedMessage { get; set; }
publicApiVersionAttribute(int major) => Major = major;
}
[ApiVersion(2, DeprecatedMessage = "Use v3 endpoint")]
publicclassLegacyController { }
publicclassMetadataInspector
{
publicstaticvoidInspect()
{
var attr = typeof(LegacyController)
.GetCustomAttribute<ApiVersionAttribute>();
Console.WriteLine($"Version: {attr.Major}");
Console.WriteLine($"Deprecation: {attr.DeprecatedMessage ?? "none"}");
// What the JIT actually saw in metadata:// Constructor args: [2]// Named args: [DeprecatedMessage = "Use v3 endpoint"]// All serialized as const blobs at compile time
}
}
// Output:// Version: 2// Deprecation: Use v3 endpoint
Output
Version: 2
Deprecation: Use v3 endpoint
🔥Senior Shortcut: Treat Attribute Classes as Frozen API
Never add required positional parameters after shipping. Add optional named parameters. Renaming properties breaks reflection callers. Use ObsoleteAttribute on old constructors before removing them — that buys you one major version of migration time.
🎯 Key Takeaway
Attributes are compile-time constants serialised into metadata. Change a constructor signature in a shipped library and every consumer blows up at runtime.
Coding the [PropDisplayName] Attribute — Full Implementation Lifecycle
Most tutorials stop at declaring an attribute class. In production, you need three parts: the attribute itself, a reflection reader, and a caching layer. Start with a sealed class inheriting from Attribute. The constructor captures the display name; the property exposes it. Mark it with [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] to prevent duplicate decorations on the same property. For reading, build a generic extension method that uses Expression Trees to extract the PropertyInfo from a lambda, then checks for the attribute via GetCustomAttribute. Cache the result in a ConcurrentDictionary<PropertyInfo, string> to avoid repeated reflection. This pattern is used in MVVM frameworks and DTO mapping libraries. It frees your UI layer from hard-coded labels and makes localization trivial—just inject a resource manager into the attribute constructor. The key decision: cache on PropertyInfo, not on type, to handle inheritance correctly.
// if [PropDisplayName("Email Address")] is declared.
⚠ Production Trap:
Never cache on Type alone—properties with the same name on different types (e.g., User.Name vs Product.Name) will collide. Always key on PropertyInfo, which includes the declaring type.
🎯 Key Takeaway
A custom attribute is useless without a reflection reader and a cache. Build all three together.
Keyword Differences for IL — How C# Attributes Translate to Metadata Tokens
C# attributes are syntactic sugar over raw CLI metadata. When you write [Obsolete], the C# compiler emits a .custom instance record in the assembly's metadata tables, not executable IL. The attribute class itself becomes a token in the TypeDef table; its constructor arguments are serialized into a blob in the #Blob heap. The distinction between 'attribute' and 'modifier' in IL is critical: custom attributes are attached to metadata tokens (methods, fields, parameters) via the CustomAttribute table. 'Required modifiers' (modreq) and 'optional modifiers' (modopt) are different—they alter method signatures at the CLR level but are invisible to reflection unless you use GetOptionalCustomModifiers. The C# compiler uses modopt for the is* pattern (e.g., is null) and modreq for caller-info attributes like CallerMemberName. Understanding this separation prevents bugs: a modreq cannot be read by GetCustomAttribute, and a custom attribute cannot enforce calling convention. The CLI spec (ECMA-335) Partitions II and III define these tables.
ModifierDemo.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — csharp tutorialusingSystem.Runtime.CompilerServices;
usingSystem.Reflection;
publicclassModifierCheck
{
publicvoidShow([CallerMemberName] string caller = "")
{
var param = MethodBase.GetCurrentMethod().GetParameters()[0];
// modreq is NOT a custom attributevar mods = param.GetRequiredCustomModifiers();
Console.WriteLine(mods.Contains(typeof(IsConst))); // False for CallerMemberName
}
}
Output
Output: False — CallerMemberName is a custom attribute blob, not a modreq modifier. Use GetCustomAttribute to read it.
⚠ Production Trap:
Do not confuse CustomAttributeData with modopt/modreq. They live in different metadata tables and are read through separate Reflection APIs. Mixing them causes silent failures in AOT scenarios.
🎯 Key Takeaway
CLI metadata separates custom attributes (read via GetCustomAttribute) from modifiers (read via GetRequiredCustomModifiers). They serve different purposes.
23.1 General — Why Attribute Semantics Matter
Attributes in C# are more than metadata decorations; they are compiler-enforced contracts that control runtime behavior through reflection. Section 23.1 of the language spec establishes that attributes are classes inheriting from System.Attribute, but their real power lies in how the compiler binds them to targets like assemblies, types, methods, or parameters. The CLSCompliantAttribute exemplifies this: when applied to an assembly, it forces all public members to adhere to Common Language Specification rules or the compiler emits warnings. Skipping this foundational understanding leads to subtle bugs where attributes silently fail to apply because of target mismatches or inheritance misunderstandings. Always verify your attribute's AttributeUsage to match the intended target; otherwise, the compiler will silently ignore it.
Compilation warning CS3003 (if <CLSCompliant(true)> is on assembly)
⚠ Production Trap:
CLSCompliantAttribute only warns; ignoring it can break interop with other .NET languages. Apply it early in library projects.
🎯 Key Takeaway
Always pair CLSCompliantAttribute with explicit assembly-level scanning to catch non-compliant members before shipping.
23.5.5 & 23.5.8 — AsyncMethodBuilder and UnscopedRef Attributes
The AsyncMethodBuilder attribute (23.5.5) lets custom async builders replace the default Task-based infrastructure—critical for high-performance scenarios like pooling or zero-allocation async. For example, ValueTask uses a custom builder internally. Meanwhile, the UnscopedRef attribute (23.5.8) is a safety valve for ref struct safety rules: it marks a ref parameter as intentionally escaping its scope, enabling advanced patterns like returning refs from methods without compiler errors. Both attributes are advanced tools: AsyncMethodBuilder reduces allocations in hot paths, while UnscopedRef relaxes constraints when the developer guarantees lifetime correctness. Misuse of UnscopedRef can create dangling references; only use it when the caller's scope strictly outlives the ref.
23.5.6 & 23.5.9-10 — Caller Info, EnumeratorCancellation, and ModuleInitializer
Caller-info attributes (23.5.6)—CallerFilePath, CallerLineNumber, CallerMemberName—inject compile-time context into parameters for logging or debugging without runtime overhead. The EnumeratorCancellation attribute (23.5.9) enables cooperative cancellation in async streams by binding a CancellationToken to the enumerator's await foreach loop. ModuleInitializer (23.5.10) runs static code once per assembly load—ideal for eager validation or one-time setup like registering serializer options. These attributes solve specific problems: Caller info replaces boilerplate, EnumeratorCancellation simplifies cancellation in async iteration, and ModuleInitializer replaces old reflection-based initialization hacks. Use them judiciously; ModuleInitializer, for instance, cannot be debugged easily if it throws.
Log output like 'Main: started'; 'Assembly loaded' printed once at module load.
🔥Insight:
Caller-info attributes are resolved at compile time, not runtime, making them zero-cost and immune to obfuscation.
🎯 Key Takeaway
Use CallerMemberName for INotifyPropertyChanged, EnumeratorCancellation in async streams, and ModuleInitializer for eager assembly-level setup.
● 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−
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
Aspect
Built-in Attributes ([Obsolete], [Serializable])
Custom Attributes ([RequiresAuditLog])
Who reads them
The CLR, compiler, or a specific framework (e.g., ASP.NET Core)
Your own code via reflection, or a framework you build
When they're evaluated
Compile 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 cost
Near-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 them
Already defined in .NET — just apply them
Inherit from System.Attribute, decorate with [AttributeUsage]
public static void Log(string msg, [CallerMemberName] string member = "")
23.5.6 & 23.5.9-10
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.
Q02 of 04SENIOR
How would you implement a simple method-level caching attribute in C# — what are the limitations of doing this purely with attributes and reflection versus using a proxy/AOP framework like Castle DynamicProxy?
ANSWER
A caching attribute would mark methods whose results should be cached: [CacheResult(TTL = 60)]. Implementation: a middleware/interceptor that reads the attribute via reflection, checks the cache before invoking the method, and stores results after invocation. Limitations of pure attributes+reflection: (1) You cannot intercept method calls without modifying call sites. The attribute itself does nothing — you'd need to wrap every call with reflection Invoke, which is slow and loses compile-time type safety. (2) You must manually apply the interceptor — no automatic weaving. (3) No support for async methods without extra complexity (Task vs T). (4) Memory overhead: caching attribute instances per method. Proxy/AOP frameworks (DynamicProxy) generate dynamic subclasses or interfaces that intercept calls automatically. The attribute is still used as metadata, but the proxy handles invocation without manual reflection per call. DynamicProxy also supports async methods seamlessly. However, DynamicProxy requires virtual methods or interfaces, adds runtime generation overhead, and can be complex to debug. For simple scenarios, a manual interceptor is fine; for system-wide caching, consider a more robust AOP framework or middleware at the web API level (caching responses).
Q03 of 04SENIOR
If you put [Obsolete] on a method and call that method in code, does it throw an exception at runtime? Why or why not — and how would you make it throw?
ANSWER
No, [Obsolete] does NOT throw at runtime. It's a compiler-only attribute. The compiler reads it and emits a warning (or error if [Obsolete("msg", true)]). The compiled IL contains no special exception-throwing code at the method call site. The runtime never checks for ObsoleteAttribute. To make it throw, you would need to check for the attribute at runtime: if (GetType().GetMethod("ObsoleteMethod").GetCustomAttribute<ObsoleteAttribute>() != null) throw new NotSupportedException("Method is obsolete."); inside the method body. Or you could use a PostSharp aspect or Fody weaver to inject exception-throwing code at compile time. The default behaviour supports phased deprecation: mark obsolete (warning) in version 1, remove in version 2. No runtime breakage in version 1.
Q04 of 04SENIOR
How would you design a system to read attributes from assembly plugins without paying reflection cost per method call?
ANSWER
Read all attributes at plugin load time, not on each method call. Steps: (1) When assembly is loaded, use assembly.GetTypes() to iterate through all types. (2) For each type, use type.GetMethods() to get methods. (3) Use GetCustomAttribute<T>() to read attributes and store them in a Dictionary<MethodInfo, MyAttribute> at startup. (4) The cost of reflection is paid once per method per assembly load. At runtime, the interceptor does a dictionary lookup _cache.TryGetValue(method, out var attr). This is O(1) and avoids reflection on each call. (5) For unloadable plugins (AssemblyLoadContext), store the cache per context and dispose when unloading. (6) Use Lazy<Dictionary<...>> to defer initialisation until first use of the plugin, but still only once per plugin. This pattern is used by ASP.NET Core's MVC controller discovery: it scans assemblies once at startup, builds route tables, never reflects on each request.
01
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?
SENIOR
02
How would you implement a simple method-level caching attribute in C# — what are the limitations of doing this purely with attributes and reflection versus using a proxy/AOP framework like Castle DynamicProxy?
SENIOR
03
If you put [Obsolete] on a method and call that method in code, does it throw an exception at runtime? Why or why not — and how would you make it throw?
SENIOR
04
How would you design a system to read attributes from assembly plugins without paying reflection cost per method call?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
What is the difference between an attribute and a decorator pattern in C#?
An attribute is passive metadata stored in the assembly — it doesn't change method behaviour on its own. The decorator pattern is an active structural pattern where you wrap an object to add behaviour at runtime. Attributes are often used to DRIVE decorator-like behaviour (a logging interceptor reads an attribute and decides to wrap the call), but the attribute itself is just the tag, not the wrapper.
Was this helpful?
02
Can you put an attribute on a local variable in C#?
Technically yes — AttributeTargets.Parameter covers method parameters, and in C# 10+ you can apply attributes to local functions and lambdas. However, local variable attributes have very limited runtime utility because they're not reachable via standard reflection paths. They're mainly used by Roslyn analysers and nullable reference type annotations like [NotNull] from System.Diagnostics.CodeAnalysis.
Was this helpful?
03
Are C# attributes the same as Java annotations?
They serve the same purpose — attaching metadata to code elements — and work in a very similar way. The key differences are syntax ([Attribute] vs @Annotation), the retention model (Java has SOURCE/CLASS/RUNTIME retention policies, C# attributes are always in the assembly and readable at runtime), and that C# attribute classes must inherit System.Attribute while Java annotation types use a special @interface declaration.
Was this helpful?
04
How do I make an attribute apply only to specific property types (e.g., only string properties)?
You can't enforce this at compile time with attributes alone (AttributeUsage doesn't have a 'PropertyType' filter). You must check at runtime when reading the attribute: if (property.PropertyType != typeof(string)) throw new InvalidOperationException($"[MyAttribute] can only be applied to string properties, not {property.PropertyType}");. Or write a Roslyn analyzer for compile-time enforcement.