The Serialization lesson (Module 5) already used [JsonPropertyName("full_name")] and
[JsonPolymorphic]/[JsonDerivedType] to customize how System.Text.Json converts objects to
and from JSON, without explaining at the time what exactly those square brackets were. This
lesson does that: an attribute is a metadata annotation attached to a class, a property, a
method..., which some code — usually a framework, not the program itself during normal runtime
execution — can later read through reflection (the previous lesson) to decide how to behave.
After looking at predefined attributes in detail, this lesson teaches you to create your own
attribute for BiblioTech and read it through reflection to validate data.
Contents
- What an attribute is and how it's applied
- Predefined attributes already seen:
[JsonPropertyName]and[JsonPolymorphic]in detail - Creating a custom attribute by inheriting from
Attribute - Parameters on a custom attribute:
[RequiresRole("Librarian")] - Restricting where an attribute can be applied:
[AttributeUsage] - Reading custom attributes through reflection
[RequiredField]: generic validation for BiblioTech's domain
- What an attribute is and how it's applied
An attribute is a special class (ultimately derived from System.Attribute) whose instances
aren't created with new like the rest of the program's objects, but instead get attached to
a piece of code — a class, a method, a property, a parameter — by writing it in square brackets
right above it:
[Obsolete(...)] is a predefined .NET attribute: it marks a member as "don't use this anymore,"
and the compiler itself reads it to show a warning wherever LendBook gets called. This example
already reveals the central idea behind attributes: they don't change the method's behavior by
themselves during normal execution — LendBook keeps doing exactly the same thing if it runs —
it's the compiler, or some other code that decides to read it (through reflection, as you'll see
in section 6), that gives it meaning.
- Predefined attributes already seen:
[JsonPropertyName] and [JsonPolymorphic] in detail
[JsonPropertyName] and [JsonPolymorphic] in detailNow that you know what an attribute is, it's worth revisiting the ones already used without dwelling on them, in the Serialization lesson:
class MemberJson
{
[JsonPropertyName("full_name")]
public string Name { get; set; } = string.Empty;
}[JsonPropertyName("full_name")] is an attribute that JsonSerializer reads through
reflection on the Name property before serializing or deserializing: on finding it, it uses
the given string ("full_name") as the JSON key instead of the property's real C# name. Without
that attribute, JsonSerializer still uses reflection — to discover that Name exists and is a
public property — just without any extra instruction on what to call it in the resulting JSON.
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(Book), "book")]
[JsonDerivedType(typeof(Magazine), "magazine")]
abstract class LibraryItem : ILendable, ISearchable
{
// ...
}[JsonPolymorphic] and [JsonDerivedType] (several attributes can be stacked on the same
element, each on its own line) tell JsonSerializer, also by reading through reflection at
startup, how to tell Book and Magazine apart within a mixed list of LibraryItem: exactly the
same mechanism as a custom attribute, only [JsonPolymorphic] already comes built into
System.Text.Json.Serialization.
| Attribute | Where it applies | Who reads it, and when |
|---|---|---|
[JsonPropertyName("key")] |
Property | JsonSerializer, when serializing/deserializing |
[JsonPolymorphic] / [JsonDerivedType] |
Class | JsonSerializer, when serializing/deserializing types with inheritance |
[Obsolete("message")] |
Any member | The compiler, when compiling code that uses it |
[RequiredField] (section 7, custom) |
Property | A custom validation function, through reflection, when invoked |
- Creating a custom attribute by inheriting from
Attribute
AttributeDefining your own attribute is a matter of creating a class that inherits from
System.Attribute, by convention with the Attribute suffix in its name (although that suffix
gets omitted when using it in square brackets):
class AuditableAttribute : Attribute defines the attribute; [Auditable] is how it gets applied
on Loan — the compiler automatically recognizes that Auditable refers to AuditableAttribute,
first looking for the exact name and, failing that, the same name with the Attribute suffix
added. As it stands, this attribute does nothing by itself: since Loan in BiblioTech doesn't
carry a real auditing system, it stands as a minimal example of the mechanism; the rest of the
lesson builds one with more practical use.
- Parameters on a custom attribute:
[RequiresRole("Librarian")]
[RequiresRole("Librarian")]An attribute can take parameters just like any other class, through its constructor, storing them in properties so that whoever reads it later (through reflection) can query them:
using System;
class RequiresRoleAttribute : Attribute
{
public string Role { get; }
public RequiresRoleAttribute(string role)
{
Role = role;
}
}class Library
{
[RequiresRole("Librarian")]
public void RemoveItem(LibraryItem item)
{
// ... removal logic ...
}
}[RequiresRole("Librarian")] documents, readably both for a person and for code that reads it
through reflection, that RemoveItem requires a specific role to run. Just like with
[Auditable], the attribute alone doesn't stop anyone from calling RemoveItem: it takes
explicit code that reads it and acts accordingly — the same pattern followed, for instance, by an
authorization framework in ASP.NET Core (Module 7), which does implement that check automatically
over attributes similar to this one.
- Restricting where an attribute can be applied:
[AttributeUsage]
[AttributeUsage]By default, a custom attribute can be applied to almost any piece of code (classes, methods,
properties...). [AttributeUsage] — an attribute applied to the attribute's own definition —
restricts where it makes sense to use it, and the compiler enforces that restriction:
using System;
[AttributeUsage(AttributeTargets.Property)]
class RequiredFieldAttribute : Attribute
{
}class LibraryItem
{
[RequiredField]
public string Title { get; set; }
// [RequiredField]
// public void Lend() { } // Compilation error: RequiredField is only valid on properties
}AttributeTargets.Property indicates that [RequiredField] only makes semantic sense on a
property; trying to apply it to a method (Lend()) would be a compilation error, not a silent
error discovered later at runtime. AttributeTargets is an enumeration with combinable flags
(Class, Method, Property, Field...) via the | operator, to allow several places of use
at once if the attribute needs it.
- Reading custom attributes through reflection
An attribute applied to a piece of code becomes available, at runtime, through the reflection
seen in the previous lesson: GetCustomAttribute<T>() (or GetCustomAttributes(), plural, if
several can be present) on the corresponding PropertyInfo, MethodInfo, or Type:
using System.Reflection;
PropertyInfo? titleProperty = typeof(Book).GetProperty("Title");
RequiredFieldAttribute? attribute =
titleProperty?.GetCustomAttribute<RequiredFieldAttribute>();
Console.WriteLine(attribute is not null); // True: Title carries the RequiredField attributeGetCustomAttribute<T>() returns the attribute instance if it's present on that PropertyInfo,
or null if it isn't — the same "look it up through reflection and check for null" pattern
already seen with GetProperty. With parameters (like RequiresRoleAttribute.Role), the returned
object exposes those properties normally: attribute.Role would be accessible after checking it
isn't null.
[RequiredField]: generic validation for BiblioTech's domain
[RequiredField]: generic validation for BiblioTech's domainPutting all of the above together, you can write a generic validation function: it walks any
object's properties through reflection (like ShowProperties from the previous lesson) and, for
each one marked with [RequiredField], checks that it isn't empty.
using System;
using System.Reflection;
[AttributeUsage(AttributeTargets.Property)]
class RequiredFieldAttribute : Attribute
{
}abstract class LibraryItem : ILendable, ISearchable
{
[RequiredField]
public string Title { get; set; }
[RequiredField]
public string Author { get; set; }
public bool Available { get; private set; } = true;
// ... constructor, Lend(), Return(), ShowDetails(), Describe(), Matches() unchanged ...
}static List<string> ValidateRequiredFields(object obj)
{
List<string> errors = new List<string>();
Type type = obj.GetType();
foreach (PropertyInfo property in type.GetProperties())
{
bool isRequired = property.GetCustomAttribute<RequiredFieldAttribute>() is not null;
if (!isRequired)
{
continue;
}
object? value = property.GetValue(obj);
if (value is null || (value is string text && string.IsNullOrWhiteSpace(text)))
{
errors.Add($"Field '{property.Name}' is required and is empty.");
}
}
return errors;
}Book incompleteBook = new Book("", "Julio Cortazar", "978-84-376-0495-4");
List<string> errors = ValidateRequiredFields(incompleteBook);
foreach (string error in errors)
{
Console.WriteLine(error);
}
// Field 'Title' is required and is empty.ValidateRequiredFields doesn't know Book, Magazine, or Member in advance: it works with
any class that uses [RequiredField] on some of its properties, because it discovers, at
runtime through reflection, both the list of properties and which of them carry the attribute.
This combination — reflection plus attributes — is exactly the pattern real validation frameworks
use (more complete than this example, with attributes like [Required], [Range], etc., common
in ASP.NET Core, Module 7): the rules are declared declaratively next to the data, and a single
generic function applies them without repeating validation logic for every class.
flowchart TD
A["ValidateRequiredFields(obj)"] --> B["obj.GetType().GetProperties()"]
B --> C{"Has RequiredFieldAttribute?"}
C -->|No| B
C -->|Yes| D["GetValue(obj)"]
D --> E{"Empty or null?"}
E -->|Yes| F["Add error"]
E -->|No| B
Common Mistakes and Tips
- Expecting an attribute to change behavior by itself:
[RequiredField]or[RequiresRole], applied to a property or a method, do absolutely nothing until some explicit code reads them through reflection and acts on them; they're declarative metadata, not executable logic. - Forgetting
[AttributeUsage]on an attribute meant for a single context: without that restriction, nothing stops[RequiredField]from being mistakenly applied to a method or an entire class, a use that wouldn't make sense and that the validation function would silently ignore. - Not checking for
nullwhen reading an attribute withGetCustomAttribute<T>(): if the element doesn't carry that attribute, the result isnull; treating it as always present throws aNullReferenceExceptionas soon as any of its properties are accessed. - Overusing custom attributes for logic that would fit better as regular code: attributes
shine for declarative metadata read by generic infrastructure (validation, serialization,
authorization); BiblioTech-specific business logic (like
Loan.RegisterReturn()) should keep living as regular code, not as attributes. - Tip: when designing a custom attribute, think first about who's going to read it and how (which reflection method, on what kind of member); an attribute with no reader attached is, in practice, just a comment with stricter syntax.
Exercises
-
Define the
RequiresRoleAttributeattribute from this lesson, with[AttributeUsage(AttributeTargets.Method)]to restrict it to methods. Apply it with[RequiresRole("Librarian")]on a fictitiousRemoveItemmethod ofLibrary. -
Write a function
string? GetRequiredRole(MethodInfo method)that usesGetCustomAttribute<RequiresRoleAttribute>()on the givenMethodInfoand returnsattribute.Roleif the attribute is present, ornullif it isn't. Test it by getting theMethodInfoforRemoveItemwithtypeof(Library).GetMethod("RemoveItem"). -
Define
RequiredFieldAttributeand theValidateRequiredFields(object obj)function from this lesson. Apply[RequiredField]toTitleandAuthoronLibraryItem, and validate aBookwith an emptyAuthor, checking that the returned list of errors contains exactly one message aboutAuthor.
Solutions
[AttributeUsage(AttributeTargets.Method)]
class RequiresRoleAttribute : Attribute
{
public string Role { get; }
public RequiresRoleAttribute(string role)
{
Role = role;
}
}
class Library
{
[RequiresRole("Librarian")]
public void RemoveItem(LibraryItem item)
{
// ...
}
}
static string? GetRequiredRole(MethodInfo method)
{
RequiresRoleAttribute? attribute = method.GetCustomAttribute<RequiresRoleAttribute>();
return attribute?.Role;
}
MethodInfo? removeMethod = typeof(Library).GetMethod("RemoveItem");
if (removeMethod is not null)
{
Console.WriteLine(GetRequiredRole(removeMethod)); // "Librarian"
}
Book bookWithoutAuthor = new Book("Hopscotch", "", "978-84-376-0495-4");
List<string> errors = ValidateRequiredFields(bookWithoutAuthor);
Console.WriteLine(errors.Count); // 1
Console.WriteLine(errors[0]); // Field 'Author' is required and is empty.
Conclusion
In this lesson you've learned what an attribute is and how [JsonPropertyName] and
[JsonPolymorphic], already used in Module 5, really work: declarative metadata that a framework
reads through reflection. You've also created your own custom attributes ([Auditable],
[RequiresRole], [RequiredField]), restricted where they can be applied with
[AttributeUsage], and built a generic validation function that reads [RequiredField] through
reflection on any class in BiblioTech's domain, without coupling itself to Book, Magazine, or
Member in particular.
The next lesson, Dynamic Programming, changes topic within the same Module 6: instead of
inspecting types known at runtime (reflection) or annotating them with metadata (attributes), it
introduces the dynamic type, which gives up compile-time type checking entirely. You'll see why
that trade-off rarely pays off in a typed domain like BiblioTech, and in which specific scenarios
it actually does make sense.
C# Programming Course
Module 1: Introduction to C#
- Introduction to C#
- Setting Up the Development Environment
- Hello World Program
- Basic Syntax and Structure
- Variables and Data Types
- Arrays and Strings
Module 2: Control Structures
Module 3: Object-Oriented Programming
- Classes and Objects
- Methods
- Constructors and Destructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Structs and Records: Value Types and Reference Types
Module 4: Advanced C# Concepts
- Interfaces
- Delegates and Events
- Pattern Matching and Modern C# Features
- Generics
- Collections
- LINQ (Language Integrated Query)
- Asynchronous Programming
Module 5: Working with Data
- File I/O
- Serialization
- Database Connectivity
- Entity Framework
- Working with JSON and Consuming REST APIs
Module 6: Advanced Topics
- Reflection
- Attributes
- Dynamic Programming
- Memory Management and Garbage Collection
- Multithreading and Parallel Programming
Module 7: Building Applications
Module 8: Best Practices and Design Patterns
- Coding Standards and Best Practices
- Design Patterns
- Dependency Injection and Inversion of Control
- Unit Testing
- Code Review and Refactoring
