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

  1. What an attribute is and how it's applied
  2. Predefined attributes already seen: [JsonPropertyName] and [JsonPolymorphic] in detail
  3. Creating a custom attribute by inheriting from Attribute
  4. Parameters on a custom attribute: [RequiresRole("Librarian")]
  5. Restricting where an attribute can be applied: [AttributeUsage]
  6. Reading custom attributes through reflection
  7. [RequiredField]: generic validation for BiblioTech's domain

  1. 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("Use LendBookAsync instead")]
void LendBook(Book book)
{
    // ...
}

[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.

  1. Predefined attributes already seen: [JsonPropertyName] and [JsonPolymorphic] in detail

Now 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

  1. Creating a custom attribute by inheriting from Attribute

Defining 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):

using System;

class AuditableAttribute : Attribute
{
}
[Auditable]
class Loan
{
    // ...
}

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.

  1. Parameters on a custom attribute: [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.

  1. Restricting where an attribute can be applied: [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.

  1. 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 attribute

GetCustomAttribute<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.

  1. [RequiredField]: generic validation for BiblioTech's domain

Putting 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 null when reading an attribute with GetCustomAttribute<T>(): if the element doesn't carry that attribute, the result is null; treating it as always present throws a NullReferenceException as 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

  1. Define the RequiresRoleAttribute attribute from this lesson, with [AttributeUsage(AttributeTargets.Method)] to restrict it to methods. Apply it with [RequiresRole("Librarian")] on a fictitious RemoveItem method of Library.

  2. Write a function string? GetRequiredRole(MethodInfo method) that uses GetCustomAttribute<RequiresRoleAttribute>() on the given MethodInfo and returns attribute.Role if the attribute is present, or null if it isn't. Test it by getting the MethodInfo for RemoveItem with typeof(Library).GetMethod("RemoveItem").

  3. Define RequiredFieldAttribute and the ValidateRequiredFields(object obj) function from this lesson. Apply [RequiredField] to Title and Author on LibraryItem, and validate a Book with an empty Author, checking that the returned list of errors contains exactly one message about Author.

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.

© Copyright 2026. All rights reserved