C# Attributes Explained — Custom Metadata, Reflection and Real-World Patterns
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 of this article 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.
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}"); } } }
Warning — method is obsolete: Use CalculateTotalWithTax() instead. This method ignores VAT.
Building a Custom Attribute From Scratch — A Real Validation Use Case
The real power of attributes kicks in when you write your own. Let's say you're building an internal API and you want to mark certain service methods as requiring audit logging — every call should be written to a log with who called it and when. You could add a logging call to each method by hand, but that's fragile and easy to forget.
Instead, you define a [RequiresAuditLog] attribute, decorate the methods that need it, and then your middleware or a base class reads the attribute at runtime and handles the logging automatically. The method's author just sticks a tag on it — they don't need to know anything about how logging works.
To create a custom attribute you need three things: inherit from System.Attribute, add [AttributeUsage] to declare where it's allowed, and define whatever properties carry the data you need. Then wire up reflection to read it somewhere central — a base class, a middleware pipeline, a unit test runner.
This pattern is the foundation of every major .NET framework feature you use daily: ASP.NET Core's [HttpGet]/[HttpPost] routing, Entity Framework's [Key]/[Required] column mapping, xUnit's [Fact]/[Theory] test discovery — all custom attributes with reflection-powered engines reading them.
using System; using System.Reflection; // Step 1: Define AttributeUsage. // AttributeTargets.Method means you can ONLY put this attribute on methods. // AllowMultiple = false means you can't stack two [RequiresAuditLog] on one method. // Inherited = true means if a subclass overrides the method, it inherits the attribute. [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] public sealed class RequiresAuditLogAttribute : Attribute { // The reason for auditing — stored as metadata. public string Reason { get; } // AuditLevel lets callers declare how important this audit is. public AuditLevel Level { get; set; } = AuditLevel.Standard; // The constructor takes mandatory data — you must always provide a reason. public RequiresAuditLogAttribute(string reason) { Reason = reason; } } public enum AuditLevel { Standard, Critical } // Step 2: Use the attribute on real service methods. public class PaymentService { [RequiresAuditLog("Financial transaction initiated", Level = AuditLevel.Critical)] public void ProcessPayment(decimal amount, string customerId) { // Real payment logic would go here. Console.WriteLine($"Processing £{amount} for customer {customerId}"); } // No attribute — this method does NOT need auditing. public decimal GetAccountBalance(string customerId) { return 1500.00m; } } // Step 3: An "interceptor" that reads the attribute before invoking a method. // In production this would live in middleware or a proxy, not Main(). public static class AuditInterceptor { public static void Invoke(object serviceInstance, string methodName, object[] args) { Type serviceType = serviceInstance.GetType(); MethodInfo? method = serviceType.GetMethod(methodName); if (method == null) throw new InvalidOperationException($"Method '{methodName}' not found."); // Read the attribute — returns null if not present. RequiresAuditLogAttribute? auditAttr = method.GetCustomAttribute<RequiresAuditLogAttribute>(); if (auditAttr != null) { // Write the audit entry BEFORE the method runs. Console.WriteLine( $"[AUDIT | {auditAttr.Level.ToString().ToUpper()}] " + $"Method: {methodName} | Reason: {auditAttr.Reason} | " + $"Time: {DateTime.UtcNow:HH:mm:ss}" ); } // Now actually call the method. method.Invoke(serviceInstance, args); } } class Program { static void Main() { var paymentService = new PaymentService(); // Call through the interceptor instead of directly. // In a real app, a DI container or proxy would do this automatically. AuditInterceptor.Invoke( paymentService, nameof(PaymentService.ProcessPayment), new object[] { 250.00m, "CUST-8821" } ); Console.WriteLine(); // This method has no audit attribute — no log entry is written. AuditInterceptor.Invoke( paymentService, nameof(PaymentService.GetAccountBalance), new object[] { "CUST-8821" } ); } }
Processing £250 for customer CUST-8821
Processing complete — no audit required for GetAccountBalance.
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.
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}"); } } }
Team: Payments | Channel: #payments-team
Team: Platform | Channel: #platform-infra
=== PartialRefundProcessor owners (expect 0): 0 ===
=== IssueRefund method owners ===
Team: Payments
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.
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); } }
✗ CustomerId: Customer ID is mandatory.
✗ AmountGbp: Order amount must be between £1 and £10,000.
=== Serialized JSON (note: no InternalTrackingCode) ===
{
"customer_id": "CUST-4421",
"amount_gbp": 149.99
}
| 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 |
| Performance cost | Near-zero — the CLR has optimised paths for its own attributes | Reflection cost per read — cache with a Dictionary |
| Defining them | Already defined in .NET — just apply them | Inherit from System.Attribute, decorate with [AttributeUsage] |
| Use case fit | Broad, framework-level concerns (serialization, routing, threading) | Domain-specific cross-cutting concerns (auditing, feature flags, ownership tagging) |
| Compile-time safety | Full — the compiler knows their rules | Partial — [AttributeUsage] enforces valid targets at compile time, but data values are runtime |
🎯 Key Takeaways
- 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(), which is why [JsonIgnore] doesn't slow down your app until the serializer runs.
- 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 and this single change can eliminate a common production performance bottleneck.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: 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 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 keyed on the MethodInfo. The cost drops to a single dictionary lookup on subsequent calls. - ✕Mistake 2: 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. 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.
- ✕Mistake 3: 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.
Interview Questions on This Topic
- QWhat 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?
- QHow 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?
- QIf 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?
Frequently Asked Questions
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.
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.
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.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.