Skip to main content
ANVISoftware Solutions
Lesson 59 of 62Advanced16 min

Attributes

By the end of this lesson

Attach metadata to code and read it back.

An attribute is a note attached to a piece of code. It does nothing on its own. It is stored in the assembly's metadata alongside the thing it is attached to, and it waits there until something reads it.

That last part is the idea people usually miss. Writing [Required] above a property does not make anything required. It records the fact that this property is meant to be required. Something else — a validator, a framework, a tool — has to read that note and act on it, and if nothing does, the attribute has no effect whatsoever.

You have been using them since your first ASP.NET Core controller. Attributes are how a method becomes a route, a class becomes a test fixture, and a property becomes a database column with a maximum length.

Attributes you have probably already met, and who reads each one:

[Obsolete("Use ChargeAsync instead")]
Read by the compiler. It produces a warning at every call site, and can be made an error. One of very few attributes with an effect at build time rather than run time.
[HttpGet("orders/{id}")]
Read by ASP.NET Core when it builds its routing table at start-up. The framework discovers your controllers and turns these notes into routes.
[Required] and [MaxLength(50)]
Read by the model validation system on each request, and by EF Core when it works out your schema. The same attribute can be read by more than one consumer.
[Fact] or [Test]
Read by the test runner, which scans your assembly for methods carrying it and invokes them. Your tests are found, not registered.
[JsonPropertyName("order_ref")]
Read by System.Text.Json to map a C# property name onto a different name in the JSON document.
[Serializable], [Flags], [DebuggerDisplay]
Read by the runtime, by Enum.ToString, and by the debugger respectively. Attributes are a general mechanism, not a validation feature.
Declaring an attribute and putting it to use
C#
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class AmountRangeAttribute : Attribute
{
    public AmountRangeAttribute(double minimum, double maximum)
    {
        Minimum = (decimal)minimum;
        Maximum = (decimal)maximum;
    }

    public decimal Minimum { get; }

    public decimal Maximum { get; }

    // Optional named argument: callers may set it, or leave the default.
    public string Message { get; init; } = "Value is outside the permitted range.";
}

public class PurchaseOrder
{
    public string Reference { get; set; } = "";

    [AmountRange(0.01, 50_000, Message = "Line total must be between 1p and 50,000.")]
    public decimal LineTotal { get; set; }

    [AmountRange(0, 100)]
    public decimal DiscountPercent { get; set; }
}
  • An attribute is a class deriving from Attribute. The suffix Attribute is a convention the compiler knows about, which is why the usage reads [AmountRange] rather than [AmountRangeAttribute].
  • [AttributeUsage] on the declaration says where this attribute may be applied. Restricting it to properties means putting it on a class becomes a compile error rather than a note nobody reads.
  • Constructor parameters become positional arguments at the usage site. Properties with a setter become optional named arguments, which is what Message = "..." is doing.
  • The argument types are constrained by the language: attribute arguments must be compile-time constants, so decimal is not allowed. Taking double and converting is the usual workaround, and it is worth knowing about before you design an attribute around a decimal.
  • sealed is conventional. Attribute inheritance complicates the lookup rules and is rarely what you want.
  • Nothing in this code validates anything. PurchaseOrder can hold a LineTotal of minus five million and neither attribute will object, because an attribute is a note and no one has read it yet.
The half that does the work
C#
public record ValidationFailure(string Property, string Message);

public static class AmountValidator
{
    public static List<ValidationFailure> Validate(object instance)
    {
        List<ValidationFailure> failures = new();

        PropertyInfo[] properties = instance.GetType()
            .GetProperties(BindingFlags.Public | BindingFlags.Instance);

        foreach (PropertyInfo property in properties)
        {
            AmountRangeAttribute? range =
                property.GetCustomAttribute<AmountRangeAttribute>();

            if (range is null)
            {
                continue;   // No note on this property, nothing to check.
            }

            if (property.GetValue(instance) is not decimal value)
            {
                continue;
            }

            if (value < range.Minimum || value > range.Maximum)
            {
                failures.Add(new ValidationFailure(property.Name, range.Message));
            }
        }

        return failures;
    }
}

PurchaseOrder order = new() { Reference = "PO-3318", LineTotal = -5m, DiscountPercent = 140m };

foreach (ValidationFailure failure in AmountValidator.Validate(order))
{
    Console.WriteLine($"{failure.Property}: {failure.Message}");
}
  • This is the consumer. Reflection walks the properties, and GetCustomAttribute asks each one whether it carries the note.
  • GetCustomAttribute returns null when the attribute is absent, which is the signal to skip. Most attribute-driven code has exactly this shape: find members carrying an attribute, ignore the rest.
  • The returned object is a real instance of AmountRangeAttribute, constructed from the arguments written at the usage site. range.Minimum and range.Message are the values from the declaration.
  • The pattern is not decimal value skips properties of other types. A stricter validator would report a mistake here, because an [AmountRange] on a string is a bug — but the choice of skipping or complaining is yours, since the attribute itself cannot enforce anything.
  • The output shows the two failures, including the custom message on LineTotal and the default one on DiscountPercent. The default came from the attribute's property initialiser.
  • Notice how the two halves relate. The attribute declares intent next to the thing it describes; the validator holds the logic in one place. Neither knows about PurchaseOrder, and PurchaseOrder knows nothing about validation.

Summary

  • An attribute stores metadata next to the code it describes and has no behaviour of its own
  • Something must read it with reflection before anything happens — the reader is half the feature
  • Declare one as a class deriving from Attribute, with [AttributeUsage] limiting where it can go
  • Arguments must be compile-time constants, which rules out values that come from configuration or vary at run time
  • ASP.NET Core model validation is this pattern at scale: attributes on a model, discovered and executed by the framework before your action runs

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

Design an attribute that marks a property as needing masking in exported files — card numbers, bank details, personal identifiers. Then write the code that applies it during export.

Decide whether the attribute carries how many characters to leave visible, and be able to justify the choice.

Show solution

Putting the visible count on the attribute is the better choice here, and the reason is that different fields want different treatment. A card number is conventionally shown as its last four digits; a national insurance number should show nothing. A single fixed rule in the exporter cannot express both, and a lookup table of property names somewhere else would drift away from the properties themselves.

The argument type is int, which is allowed because it is a compile-time constant. If the requirement had been "mask according to the customer's country rules", an attribute would be the wrong mechanism entirely, since the rule is not known when the code is compiled.

[AttributeUsage] limits it to properties. That matters more than it looks: the exporter only inspects properties, so allowing the attribute on a class would create a note that is silently ignored — the most annoying kind of bug, because the code looks correct.

GetCustomAttribute is called during discovery and its result kept with the property, rather than being called per row. On a 200,000-row export, asking each property for its attributes on every row is work repeated for an answer that cannot change.

The default is worth noting: a property with no attribute is exported in full. That is the right default for an exporter, but it means each new sensitive field depends on someone remembering the attribute. If the data were highly sensitive, the safer design would invert it — export nothing unless a property is marked as safe.

C#
[AttributeUsage(AttributeTargets.Property)]
public sealed class MaskedAttribute : Attribute
{
    public MaskedAttribute(int visibleTrailingCharacters = 0) =>
        VisibleTrailingCharacters = visibleTrailingCharacters;

    public int VisibleTrailingCharacters { get; }
}

public class CustomerExportRow
{
    public string Name { get; set; } = "";

    [Masked(4)]
    public string CardNumber { get; set; } = "";

    [Masked]
    public string NationalInsuranceNumber { get; set; } = "";
}

public static class MaskingExporter
{
    public static List<string> ToLines<T>(IEnumerable<T> rows)
    {
        // Discover once: property plus its masking rule, if any.
        var plan = typeof(T)
            .GetProperties(BindingFlags.Public | BindingFlags.Instance)
            .Where(p => p.CanRead)
            .Select(p => (Property: p, Mask: p.GetCustomAttribute<MaskedAttribute>()))
            .ToList();

        List<string> lines = new();

        foreach (T row in rows)
        {
            IEnumerable<string> cells = plan.Select(entry =>
            {
                string text = entry.Property.GetValue(row)?.ToString() ?? "";

                return entry.Mask is null
                    ? text
                    : Mask(text, entry.Mask.VisibleTrailingCharacters);
            });

            lines.Add(string.Join(",", cells));
        }

        return lines;
    }

    private static string Mask(string text, int visible)
    {
        if (text.Length <= visible)
        {
            return new string('*', text.Length);
        }

        return new string('*', text.Length - visible) + text[^visible..];
    }
}

Think about it

Think about it

A team adds [AmountRange(0, 1000)] to a property on an internal class and reports that the attribute is broken — values outside the range are being saved without complaint.

Nothing is wrong with the attribute. What three things would you check, and what does this tell you about how attributes should be introduced to a codebase?

Show solution

First, whether anything reads it. A custom attribute needs a consumer. If the validator was written for request models bound by ASP.NET Core, it never sees an internal class that the framework does not bind.

Second, where in the flow the reading happens. ASP.NET Core validates the model it binds from the request body. An object constructed inside a service, or loaded from the database, passes through no binding step and so through no validation.

Third, whether the attribute is even reachable. If AttributeUsage restricts it to properties and someone applied it to a field, or if the reader looks at public instance properties and this one is internal, the note exists but the reader will not find it.

The general lesson is that an attribute is half a feature. The half that is easy to write is the declaration, and the half that decides whether anything happens is the consumer — including exactly when it runs and what it looks at.

Practically, that means introducing an attribute and its reader together, and testing them as a pair. A test that constructs the object directly and asserts the validator's output would have caught this immediately, because it makes the consumer explicit instead of assuming a framework will call it.

Saved in this browser only.