[Obsolete] never runs a single line of code. It just sits there, as data, until something else — the compiler, or your own reflection code — decides to go looking for it.
You've written [Obsolete("Use CalculateTotal instead")] above a method, and the compiler dutifully warned every caller. You've probably seen [Required] on a model property in an ASP.NET Core project, and somehow a form submission without that field gets rejected before your controller code even runs. Here's the thing that makes attributes genuinely strange the first time you think carefully about them: an attribute is not code that executes. [Obsolete] doesn't "do" anything by itself, in the sense of running instructions. It's a piece of data, permanently attached to a method, a class, a property — baked into the compiled assembly's metadata, sitting there inertly until some other piece of code (the compiler, a validation library, your own code) goes looking for it and decides what to do about it.
That's exactly why this lesson follows reflection directly: reflection is how something goes looking for an attribute. The two are a matched pair — attributes are the declarative metadata; reflection is the mechanism that reads it back.
In this lesson: what attributes actually are, familiar built-in ones you've already used, writing your own custom attribute, restricting where it can apply with AttributeUsage, and a full worked example reading a custom attribute back via reflection to enforce a real rule.
An attribute is a labeled sticky note you attach to a class, method, property, or parameter in your source code, written in square brackets: [Obsolete], [Serializable], [Required]. The note itself does nothing on its own — it just sits there, permanently attached, as part of the compiled type's metadata. What gives it meaning is entirely up to whoever later reads that note and decides to act on it.
An attribute is an instance of a class deriving (directly or indirectly) from System.Attribute, applied to a code element — an assembly, type, method, property, field, or parameter — using [AttributeName(...)] syntax. The C# compiler stores the attribute and its constructor arguments as part of the target element's metadata in the compiled assembly. Nothing about applying an attribute causes any code to run at the point of application; consuming its meaning is entirely the responsibility of whatever tool later reads it back — most commonly via reflection, though the C# compiler itself also reads certain well-known attributes directly (like [Obsolete], to emit a warning).
logger.Log("started");[Obsolete("...")] void OldMethod() { }Some information about a type or member is genuinely useful to record, but doesn't belong in the type's actual logic. "This method is deprecated." "This property is required for a form to be valid." "This class can be converted to a stream of bytes." "This method is a unit test the runner should discover and execute." None of that is behavior the type performs itself — it's metadata about the type, meant for some external tool to act on. Before attributes, expressing this kind of information meant separate configuration files, naming conventions the tool had to guess at, or — worse — no standard way to express it at all, leaving every framework to invent its own bespoke convention.
Developers needed a standard, language-level way to attach structured, typed metadata directly to the code element it describes — kept right next to the code (rather than in a separate file that inevitably drifts out of sync), discoverable uniformly by any tool via reflection, and validated by the compiler like any other C# code (real types, real constructors, real compile errors for a typo).
Attributes. A single, uniform mechanism — an ordinary class deriving from Attribute, applied with bracket syntax — that the entire .NET ecosystem shares. The compiler itself is just the first, most privileged consumer: it specifically knows how to react to a handful of attributes like [Obsolete]. Everything else — validation, serialization, dependency injection, test discovery — is built by libraries reading attributes back through the exact reflection mechanism from the previous lesson.
[Required] public string Email { get; set; } — you write the attribute (compile time)[Required], checks Email isn't empty — runtime, and entirely up to that librarypublic class RangeAttribute : Attribute
{
public int Min { get; }
public int Max { get; }
public RangeAttribute(int min, int max) { Min = min; Max = max; }
}
Attribute — but you use it in brackets without that suffix: [Range(...)], not [RangeAttribute(...)]. The compiler recognizes both forms; the shortened one is idiomatic.[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
public class RangeAttribute : Attribute
{
public int Min { get; }
public int Max { get; }
public RangeAttribute(int min, int max) { Min = min; Max = max; }
}
AttributeTargets is a flags enum — Property | Field means "only properties and fields," so applying [Range(...)] to a class or method becomes a compile error, not a silent no-op.AllowMultiple = false (the default) means applying the attribute twice to the same element is also a compile error — set it true only if stacking multiple instances genuinely makes sense for your attribute.public class Product
{
[Range(0, 1000)]
public int Quantity { get; set; }
}
PropertyInfo prop = typeof(Product).GetProperty("Quantity")!;
RangeAttribute? range = prop.GetCustomAttribute<RangeAttribute>();
if (range is not null)
{
Console.WriteLine($"Quantity must be between {range.Min} and {range.Max}");
}
GetCustomAttribute<T>() (and its plural, GetCustomAttributes) is the reflection API specifically for reading attributes back — it returns null (or an empty collection) if the attribute isn't present, exactly like the member lookups from the previous lesson.Built-in attributes you've very likely already written, now seen for what they actually are:
[Obsolete("Use CalculateTotal(Order) instead — this overload ignores tax.")]
public decimal CalculateTotal(decimal subtotal) => subtotal;
// The COMPILER itself reads this attribute back and emits a warning at every call site —
// no reflection involved for this specific case; the compiler is a privileged, built-in consumer.
[Serializable]
public class LegacySettings { public string Theme = "Dark"; }
// A marker for the (legacy) BinaryFormatter serialization infrastructure to check via reflection
// before attempting to serialize an instance of this type.
Meaning: Two completely different consumers reading the exact same kind of thing — a compile-time consumer (the C# compiler, for [Obsolete]) and a runtime consumer (reflection-based serialization infrastructure, for [Serializable]). The attribute mechanism itself doesn't care which one reads it; that's the point of it being a general-purpose, uniform system.
A full, working validation attribute — genuinely close to how ASP.NET Core's own data-annotation attributes ([Required], [Range], [StringLength]) work internally: a custom attribute class, applied to model properties, enforced entirely through reflection at runtime.
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class RangeAttribute : Attribute
{
public int Min { get; }
public int Max { get; }
public RangeAttribute(int min, int max) { Min = min; Max = max; }
}
public class Product
{
public string Name { get; set; } = "";
[Range(0, 1000)]
public int Quantity { get; set; }
[Range(1, 5)]
public int Rating { get; set; }
}
public static class Validator
{
public static List<string> Validate(object instance)
{
var errors = new List<string>();
Type type = instance.GetType();
foreach (PropertyInfo prop in type.GetProperties())
{
var range = prop.GetCustomAttribute<RangeAttribute>();
if (range is null) continue; // this property has no [Range] attribute — nothing to check
var value = prop.GetValue(instance);
if (value is int intValue && (intValue < range.Min || intValue > range.Max))
{
errors.Add($"{prop.Name} must be between {range.Min} and {range.Max}, but was {intValue}.");
}
}
return errors;
}
}
var product = new Product { Name = "Widget", Quantity = 1500, Rating = 3 };
var errors = Validator.Validate(product);
// errors: ["Quantity must be between 0 and 1000, but was 1500."]
// Rating (3) is within [1, 5], so it produces no error — and Name has no [Range] attribute at all, so it's skipped entirely
Notice what Validator does not contain: any mention of Product, Quantity, or Rating anywhere in its code. It's entirely generic — it would validate any class with any number of [Range]-attributed int properties, unchanged. This is the exact combination the whole lesson has been building toward: attributes supply the declarative "what the rule is," and reflection supplies the generic "go find and enforce it."
An attribute is like a printed warning label on a bottle: "Keep refrigerated." The label itself doesn't refrigerate anything — it's just information, permanently attached to the bottle, sitting there whether or not anyone ever reads it. It only becomes meaningful when someone — a store employee stocking the shelf — reads the label and decides to act on it by putting the bottle in the fridge. A different reader (a customer at home) might read the exact same label and act on it differently (put it in their own fridge). The label doesn't dictate the action; it just carries the fact, and whoever reads it supplies the behavior.
Reflection is the act of reading the label. Without something choosing to look, [Range(0, 1000)] is just as inert as an unread warning sticker.
[Range(0, 1000)] on a property, it doesn't generate any IL that "runs" at that location. Instead, it records — in the assembly's metadata tables, alongside the property's own definition — a reference to the RangeAttribute type and the exact constructor arguments (0 and 1000) used to construct it.Type/PropertyInfo/etc. objects read from, described in the previous lesson — attributes and reflection share the exact same underlying storage mechanism, which is precisely why GetCustomAttribute<T>() is able to work at all.GetCustomAttribute<T>() does the CLR actually construct a real RangeAttribute instance from that stored metadata (calling the recorded constructor with the recorded arguments) and hand it back to you as an ordinary object — which is why attribute construction, like every reflection operation, happens at runtime and carries the same kind of overhead described in the previous lesson, not compile time, despite the attribute's declaration being fully known and validated at compile time.This is the single most important thing to internalize: applying [Range(0, 1000)] has zero runtime effect until something else — a validator, the compiler, a serializer — deliberately goes looking for it. Writing an attribute with no consumer anywhere in the program compiles perfectly fine and does, quite literally, nothing.
[Range(0, 1000)] uses positional constructor arguments (matching RangeAttribute's constructor exactly, in order). Some attributes also support [SomeAttribute(SomeProperty = value)] syntax — setting a public property on the attribute instance after construction, by name — which is why you'll sometimes see attribute usages mixing both styles, like [Range(0, 1000, ErrorMessage = "Out of range")]. Both are compiled the same way: a constructor call, optionally followed by property assignments.
You can't write [Range(GetMinFromConfig(), 1000)] — attribute constructor arguments must be values the compiler can fully resolve at compile time (literals, const fields, typeof() expressions, and a few similar cases), never the result of calling an arbitrary method. This follows directly from attributes being baked into static metadata rather than executed — there's no "runtime" at the point an attribute is applied to compute a dynamic value from.
Applying [Range(0, 1000)] to a property and being surprised when an out-of-range value doesn't get rejected anywhere. An attribute is inert without something reading it — either use a framework that already knows how to consume it (data-annotation attributes with ASP.NET Core model binding, for instance), or write the consuming reflection code yourself, as shown in the Real-World Example.
[AttributeUsage] and letting the attribute be applied in nonsensical places A validation attribute meant only for properties, with no [AttributeUsage] restriction, accidentally applied to a class or a method — compiling cleanly, then failing to do anything meaningful (or worse, throwing at runtime when a consumer assumes it's always on a property).
Always specify [AttributeUsage(AttributeTargets.X)] deliberately — it turns a class of misuse into a compile error instead of a silent or runtime surprise.
GetCustomAttribute<T>() repeatedly in a hot path without cachingReflecting for the same attribute on the same member on every request, in a loop that could reasonably cache the result once. This is exactly the reflection performance trap from the previous lesson, now specifically about attribute lookups (which construct a new attribute instance from metadata each time, per "Under the Hood").
Look attributes up once — typically at startup, or the first time a given type is encountered — and cache the result for reuse, exactly as the reflection lesson recommended for member lookups generally.
[Obsolete], the data-annotation validation attributes, [Required]-style ones — rather than reinventing an equivalent.[Obsolete] and [Serializable] are ordinary attributes; the difference is only who consumes them (the compiler for one, reflection-based serialization infrastructure for the other).Attribute; [AttributeUsage] restricts where it can legally be applied, catching misuse at compile time.GetCustomAttribute<T>() reconstructs the attribute instance from metadata at runtime, which is exactly how the worked [Range] validator example enforces its rule.You've seen what attributes are, how to write and restrict a custom one, and how reflection is what gives an attribute meaning. Let's check your understanding.
1. You define a custom [Range(0, 1000)] attribute and apply it to a property, but write no code anywhere that reads it back. What happens at runtime when that property is set to an out-of-range value?
Correct: B
Why B is correct: This is the central point of the lesson — an attribute never enforces anything on its own. Without a consumer (validation code, a framework) reflecting over it and acting on what it finds, [Range(0, 1000)] is exactly as inert as an unread label.
Why A is incorrect: Attributes never execute code automatically, regardless of what they're named or what they seem to imply — "validation attribute" describes intent, not automatic behavior.
Why C is incorrect: The compiler has no built-in understanding of a custom RangeAttribute you defined yourself — it only reacts specially to a small set of attributes it has explicit knowledge of, like [Obsolete].
Why D is incorrect: Nothing about applying an attribute changes how a property's setter behaves — the property still just stores whatever value is assigned to it.
Reinforcement: Always ask "who reads this attribute back?" — the answer determines whether it does anything at all.
2. What does [AttributeUsage(AttributeTargets.Property)] on a custom attribute class actually accomplish?
Correct: B
Why B is correct: AttributeUsage declares valid application targets, and the compiler enforces that restriction — applying the attribute somewhere not listed becomes a compile-time error, turning a category of misuse into an immediate, caught mistake.
Why A is incorrect: This is the same misconception as question 1 — AttributeUsage only restricts where the attribute can be applied, it doesn't give the attribute any enforcement behavior of its own.
Why C is incorrect: AttributeUsage has no effect on reflection performance — it's a compile-time placement restriction, unrelated to runtime lookup speed.
Why D is incorrect: AttributeUsage is optional; without it, a custom attribute defaults to being applicable almost everywhere, which is exactly the "Common Mistakes" scenario this lesson warns against, not a compile failure.
Reinforcement: AttributeUsage is a compile-time placement guardrail — it says nothing about runtime behavior, which always depends entirely on the consumer.
3. Why can't you write [Range(GetMinValue(), 1000)], calling a method to compute the first argument?
Correct: B
Why B is correct: As covered in "Common Confusion" and "Under the Hood," attribute arguments are embedded directly into compiled metadata — they must be values the compiler can fully determine at compile time, which rules out calling an arbitrary method whose result is only known at runtime.
Why A is incorrect: This restriction applies to every attribute, not something specific to a particular attribute class's design.
Why C is incorrect: The restriction isn't about the return type at all — even a method returning int can't be used, because the restriction is about compile-time-constant-ness, not type.
Why D is incorrect: This genuinely fails to compile — it's a real, well-known C# constraint on attribute arguments.
Reinforcement: Attribute arguments must be compile-time constants because attributes are compiled metadata, not executed code — this is a direct, logical consequence of everything else this lesson establishes.
4. In the worked Validator.Validate(object instance) example, why does the method work correctly for any class, not just Product?
Correct: B
Why B is correct: Validate never mentions Product, Quantity, or Rating — it reflects over whatever type it's given, checks each property for the attribute, and only validates the ones that have it. That genericity is exactly the payoff of combining attributes with reflection.
Why A is incorrect: There's no Product-specific code anywhere in Validator — it's entirely generic, which is precisely the point being tested.
Why C is incorrect: Attributes never apply themselves automatically — each property in Product was explicitly marked (or not marked) with [Range] in source code.
Why D is incorrect: There's no inheritance relationship between Validator and Product in this example — Validator works through reflection on an arbitrary object parameter, not through any type relationship.
Reinforcement: This is the practical payoff of the whole lesson — attributes supply declarative "what to check," and reflection supplies the generic "how to find and act on it," together enabling code that works correctly on types it was never written against.
You now understand that attributes are pure metadata — and exactly how reflection is what gives them any real effect at all. Next up: source generators, a compile-time alternative to the reflection-at-runtime pattern you've just built.
dotnetmadeeasy.com — Learn C# and .NET, the right way.