The previous two lessons covered what attributes are and walked through the built-in catalog. The real power shows up when you define your own. A custom attribute lets you tag a class, property, method, or parameter with declarative metadata that some other piece of code (validation, serialization, routing, authorization, test discovery) reads at runtime and acts on. This lesson covers how to define a custom attribute, what data it can carry, where it can be applied, and how to read it back with just enough reflection to make the pattern useful.
The built-in attributes solve a fixed set of problems: marking obsolete code, flagging conditional compilation, controlling serialization. Plenty of problems aren't in that set. Most line-of-business code eventually grows some version of "mark these properties as required" or "this field has a max length" or "this method needs a particular role to call it." You can solve each of those with a method signature, a base class, a configuration file, or a switch statement, but each of those couples the rule to the place that enforces it.
A custom attribute moves the rule onto the thing it's about. [Required] on a property says "this field must be supplied" right next to the field's declaration. A single Validator somewhere else reads the attribute and does the actual checking. The class doesn't have to know who reads its attributes, and the reader doesn't have to know about every class. The attribute is the contract between them.
The diagram is the whole mental model. Attributes are baked into the assembly's metadata at compile time, and reflection pulls them out at runtime. Nothing magical sits in between.
The pattern shows up everywhere in the .NET ecosystem. ASP.NET Core uses custom-style attributes for routing ([HttpGet("/orders")]) and authorization ([Authorize(Roles = "Admin")]). Entity Framework Core uses them for column mapping ([Column("order_total")]). xUnit uses them to mark test methods ([Fact], [Theory]). Every one of those started life as the same kind of plain class you're about to learn to write.
A custom attribute is just a class that inherits from System.Attribute. That's the entire requirement.
Two things are worth pointing at. First, the class is named RequiredAttribute but it's applied as [Required]. The compiler strips the Attribute suffix at the call site. You can write [RequiredAttribute] and it works the same way, but the convention is to omit the suffix because it reads more naturally.
Second, this attribute carries no data. It's a flag. Its presence on a member says something, and its absence says nothing. Plenty of useful attributes are exactly this simple. The next sections add data.
A flag-style attribute only goes so far. Most real attributes carry data: a max length, a role name, a route template, a column name. The two ways to attach data to an attribute are constructor parameters (for required data) and writable properties (for optional data).
Constructor parameters are positional. Whatever you pass in [MaxLength(50)] becomes the constructor argument. The attribute usually stores it in a public read-only property so a reader can pull it out.
The pattern is small but worth slowing down on. The constructor parameter is what the caller writes at the use site (MaxLength(100)). The read-only property is how a reader gets the value out later (attr.Length). Read-only is the right default because attributes are conceptually immutable: once [MaxLength(100)] is on a property in source, the 100 doesn't change at runtime.
Constructors can take multiple parameters. The [ColumnName(table, column)] pattern is a common shape.
Constructor arguments are required. There's no way to apply [Column] without supplying both the table and the column name. That's how you say "this data is mandatory for this attribute to make sense."
Some attributes have data that only sometimes applies. You don't want to force every caller to pass a value for it. The fix is a writable property on the attribute class, set at the use site with a Name = value syntax called a named argument.
The first [Required] has no message. The second sets ErrorMessage through the named-argument syntax. The compiler turns that into new RequiredAttribute { ErrorMessage = "..." }, which is why the property must be settable. Read-only properties can't appear as named arguments.
You can mix positional and named arguments in the same usage. Positional ones come first; named ones follow after a comma.
The 100 is positional (it's a constructor argument). The ErrorMessage = "..." is named (it sets a property). The convention is to use the constructor for data that's genuinely required and properties for data that's optional or has a sensible default.
You can't put just anything into an attribute argument. The compiler restricts attribute arguments to a small set of types that can be encoded into metadata at compile time.
| Allowed argument type | Examples |
|---|---|
| Primitive types | int, long, short, byte, bool, char, float, double |
string | "orders", "customer_email" |
decimal | 19.99m (with caveats on some older compilers) |
System.Type via typeof(...) | typeof(Product) |
enum values | AttributeTargets.Property |
| Single-dimensional arrays of any of the above | new[] { "admin", "manager" } |
Anything else won't compile as an attribute argument. You can't pass a List<string>, you can't pass a custom class instance, you can't pass a lambda, and you can't pass a value computed at runtime. The rule is: if the compiler can't write the literal into the assembly's metadata at compile time, it isn't allowed.
The Methods = new[] { "GET", "POST" } works because it's an array of strings, all known at compile time. Handler = typeof(OrderHandler) works because typeof produces a Type constant. Anything fancier than that, like Handler = OrderHandler.Default, would be a compile error.
Attribute arguments are encoded into the assembly's metadata at compile time, so they cost nothing at the use site. The cost shows up when reading them back through reflection, which is covered below.
This restriction is occasionally inconvenient, but it's not arbitrary. Attribute data has to survive being written to a binary on disk and read back by any reader, including tools that don't have access to your runtime state. Forcing the data to be compile-time constants is what makes that possible.
[AttributeUsage])Without any extra annotation, your custom attribute can be applied to anything: classes, methods, properties, fields, parameters, return values, even other attributes. That's usually too permissive. A [MaxLength] attribute makes sense on a property; it doesn't make sense on a class. An attribute meant for class-level validation rules shouldn't accidentally get stuck on a parameter.
The fix is [AttributeUsage], a meta-attribute you apply to your attribute class. It takes one or more AttributeTargets flags that say where the attribute is allowed.
The commented-out line would produce CS0592: Attribute 'MaxLength' is not valid on this declaration type. The compiler enforces AttributeTargets at the use site, so misuse fails fast rather than silently doing nothing.
The full list of AttributeTargets values covers every declaration site in C#.
AttributeTargets value | Where the attribute can be applied |
|---|---|
Class | Class declarations |
Struct | Struct declarations |
Interface | Interface declarations |
Enum | Enum declarations |
Method | Methods (including static methods) |
Property | Properties (the property itself, not its accessors) |
Field | Fields |
Parameter | Method or constructor parameters |
ReturnValue | Return value of a method |
Constructor | Constructors |
Event | Events |
Delegate | Delegate type declarations |
Assembly | The assembly as a whole |
Module | The compiled module |
GenericParameter | A generic type parameter |
All | Anywhere an attribute can go |
You can combine flags with the bitwise OR operator (|) to allow an attribute on more than one kind of target.
[Validated] is now allowed on classes and properties, but applying it to a method or field is still a compile error. This is the most common pattern: pick the precise set of targets the attribute makes sense on, and let the compiler reject anything else.
If you skip [AttributeUsage] entirely, the default is AttributeTargets.All, which is rarely what you want. Add the attribute usage explicitly on every custom attribute you define.
[AttributeUsage] has two more parameters: AllowMultiple and Inherited. Both are named arguments with bool defaults.
AllowMultiple = false is the default. It means the same attribute can be applied to a target only once. AllowMultiple = true lets it be applied multiple times, which is useful for attributes that represent a list of things (roles, tags, routes).
Without AllowMultiple = true, the second [Role] would be a compile error. With it, both attributes attach to the method, and a reader can collect all of them.
Inherited = true is the other default, but it's subtler than it sounds. When Inherited = true, an attribute applied to a base class or virtual member is visible on derived classes and overriding members when reflection asks for inherited attributes. When Inherited = false, only the direct member shows the attribute.
A reflection call that walks PriorityOrder and asks for inherited attributes will see [Auditable] (inherited) but not [InternalUse] (not inherited). That distinction matters when an attribute encodes a policy that should propagate down a hierarchy (auditing, logging, security) versus one that should not (internal-only flags, deprecated markers that apply to a specific class only).
The two settings combine. AllowMultiple = true, Inherited = true means a derived class can collect both its own copies of the attribute and any from the base. The GetCustomAttributes reflection API surfaces them all in declaration order from base to derived.
Custom attribute classes should be sealed by convention. Two reasons matter.
First, attribute lookup uses the attribute's type as the key. If RoleAttribute is unsealed and someone derives AdminRoleAttribute from it, asking for [Role] on a method may or may not match the derived attribute depending on how the call is written. That's a source of subtle bugs that goes away when the class is sealed: there are no derived attribute types to confuse the lookup.
Second, the JIT and runtime can optimize attribute reads more aggressively when the type is known to be a leaf. The savings are small, but they're free.
There are real cases where unsealed attributes are right (a base attribute that exposes a virtual method the derived classes override), but they're rare. Default to sealed and only loosen it when you have a concrete reason.
An attribute that nobody reads is just decoration. Reading attributes is the reflection side of the contract: you point reflection at a class, property, or method, ask for the attributes on it, and either get them back or learn there were none.
Three APIs cover the common cases.
Three things are happening. GetCustomAttribute<T>() returns the single attribute instance if present (or null if not). IsDefined(typeof(T)) is the cheap version that returns a bool without constructing the attribute instance. And GetProperty(name) is a reflection lookup that returns a PropertyInfo, the metadata object that represents that property at runtime.
For attributes declared with AllowMultiple = true, use the plural form to get all of them back.
Reading attributes is reflection, and reflection has a real cost. GetCustomAttribute<T>() walks metadata tables and allocates an attribute instance every time it's called. In hot paths, cache the result. A common shape is to read attributes once per type at startup, store the result in a Dictionary<Type, Metadata>, and reuse it. Source generators sidestep the cost entirely by computing the same data at compile time.
The reflection API is much bigger than these three methods; this lesson only uses what's needed to read your own attribute.
Putting the pieces together: a [Required] attribute, a [MaxLength] attribute, and a small Validator class that reads both and reports violations on any object you pass in.
Five things are worth pointing at. The Validator knows nothing specific about Customer. Adding a Product class with the same attributes would work without changing Validator at all. The check uses IsDefined for the presence question and GetCustomAttribute<T>() only when the data on the attribute matters. The error messages fall back to a generated default when the caller didn't supply one. The GetProperties() call returns every public property, and the loop visits them in declaration order. And the whole thing fits in 30 lines because the attributes carry the policy, not the validator.
The flow looks like this:
This is roughly the shape every "attribute reader" in .NET has. ASP.NET Core's model binding, EF Core's column mapping, FluentValidation's attribute-based variant, and the System.ComponentModel.DataAnnotations namespace all follow this same pattern of "walk the type's members, ask for attributes, do something with the result."
The Validator above calls reflection every time. In a hot path (a web server validating every request), cache the per-type metadata: walk the properties and their attributes once, build a list of validation rules, and reuse that list per request. The metadata never changes for a given type, so reading it more than once is wasted work.
Custom attributes solve a specific problem well, but they're not the right answer to every "I want some declarative thing here" question. A few signals say "use something else."
The data changes at runtime. Attribute arguments must be compile-time constants. If the max length comes from a configuration file or a database, it doesn't belong on an attribute. Use a method parameter, a property on a service, or a configuration object instead.
The logic is complex. Attributes are good for "this property is required" or "this column maps to that name." They're a bad fit for "validate this against the inventory service if the customer is in tier B and it's a Tuesday." Anything that needs branching, lookups, or external services should be a method, not an attribute.
The reading cost matters more than the writing convenience. Every attribute read is reflection, and reflection isn't free. In hot paths, an explicit registration call (validator.AddRule<Customer>(c => c.Name).Required().MaxLength(100)) avoids the runtime metadata walk entirely. Fluent builders trade the declarative readability of attributes for predictable performance and easier testing.
You only have one or two cases. If only Customer and Order need validation rules, defining an attribute and a reader is overhead compared to two hand-written Validate methods. The attribute pattern fits when many types share the rule and the reader is generic.
Source generators are the modern alternative for "I want declarative metadata, but I don't want to pay for reflection at runtime." A source generator reads your attributes at compile time and emits the validation code directly into your assembly, so by the time your app runs there's no reflection to perform. The trade-off is more upfront work to write the generator.
For most "tag and read" use cases, custom attributes still fit, especially when reflection cost is small (startup-time scanning) or when caching the metadata once per type makes the per-call cost negligible.
9 quizzes