Attributes as metadata, custom attribute design, generic attributes, compiler-interpreted attributes and caller info attributes.
An attribute is declarative metadata: the compiler serialises the constructor arguments and named properties into the assembly's metadata, and nothing runs until someone reads it. An instance is created only when code calls GetCustomAttribute(s), and a new instance is created on every call. [AttributeUsage] controls valid targets, AllowMultiple and Inherited. Arguments must be compile-time constants, typeof, enums or one-dimensional arrays of those. C# 11 added generic attributes ([Validator<OrderValidator>]). Some attributes are read by the compiler itself ([Obsolete], [Conditional], nullable attributes such as [NotNullWhen]), and caller info attributes ([CallerMemberName], [CallerFilePath], [CallerLineNumber], C# 10's [CallerArgumentExpression]) make the compiler fill optional parameters at the call site.
Attributes attach declarative metadata to code elements (assemblies, types, members, parameters, return values). The compiler serialises attribute constructor arguments and named properties into the assembly's metadata; at run time, attribute objects are only created when someone asks for them via reflection. Some attributes are consumed by the compiler itself rather than at run time. I'd cover storage, instantiation, AttributeUsage, allowed argument types, generic attributes, compiler-recognised attributes and caller information attributes.
[Audited("orders", Level = AuditLevel.Full)]
public sealed class OrderService { ... }
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class AuditedAttribute(string category) : Attribute
{
public string Category { get; } = category;
public AuditLevel Level { get; init; } = AuditLevel.Basic;
}
In metadata, the CustomAttribute table stores: the target, the attribute constructor, and a blob encoding the constructor arguments ("orders") and named arguments (Level = Full). No object exists yet — it's just data in the DLL.
Only when code calls reflection APIs like GetCustomAttributes, GetCustomAttribute<T>(), Attribute.GetCustomAttributes(...):
IsDefined(typeof(T)) when you just need to know if an attribute exists (cheaper, no instance).Frameworks (ASP.NET Core routing/auth, EF Core configuration, xUnit, System.Text.Json) read attributes at start-up or first use and cache the results.
AttributeUsage| Setting | Meaning |
|---|---|
ValidOn (AttributeTargets) |
Where it can be applied: Class, Method, Property, Parameter, ReturnValue, Assembly, Field, GenericParameter, All... |
AllowMultiple |
Can it appear more than once on the same target (default false) |
Inherited |
Is it inherited by derived classes / overriding members when queried with inherit: true (default true) |
Note: Inherited affects classes and overridden methods/properties; it doesn't apply to interface implementations or to properties when using GetCustomAttributes on PropertyInfo (a long-standing quirk — use Attribute.GetCustomAttributes(memberInfo, inherit: true) for properties/events).
Attribute constructor and named arguments must be compile-time constants of these types: bool, byte, char, short, int, long, float, double, string, System.Type, enums, object (holding one of these), and single-dimensional arrays of them. Not allowed: decimal, DateTime, Guid, arbitrary classes, nullable value types, lambdas. That's why you see [Range(typeof(decimal), "0", "100")]-style string workarounds.
public sealed class ValidatorAttribute<TValidator> : Attribute where TValidator : IValidator { }
[Validator<CreateOrderValidator>]
public sealed record CreateOrder(...);
Before C# 11 you'd write [Validator(typeof(CreateOrderValidator))] and lose compile-time checks of the type. Generic attributes give type safety and constraints. Type arguments must be fully known (no open type parameters from the enclosing context).
Some attributes change compilation, not runtime behaviour:
[Obsolete("Use X", error: true, DiagnosticId = "ABC001")] — warnings/errors at call sites.[Conditional("DEBUG")] — calls to the method are removed when the symbol isn't defined (including argument evaluation).[CallerMemberName], [CallerFilePath], [CallerLineNumber], [CallerArgumentExpression] — compiler fills in arguments.[NotNullWhen], [MemberNotNull], [DoesNotReturn]...[MethodImpl(MethodImplOptions.AggressiveInlining)] (JIT hint), [SkipLocalsInit], [StructLayout], [FieldOffset] (layout), [ModuleInitializer], [InterpolatedStringHandler], [OverloadResolutionPriority] (C# 13), [Experimental] (C# 12).[JsonSerializable], [LoggerMessage], [GeneratedRegex]).public static void Require(bool condition, [CallerArgumentExpression(nameof(condition))] string? expr = null,
[CallerMemberName] string member = "", [CallerLineNumber] int line = 0)
{
if (!condition) throw new InvalidOperationException($"Requirement failed: {expr} in {member} at line {line}");
}
Require(order.Lines.Count > 0); // message: "Requirement failed: order.Lines.Count > 0 in Submit at line 42"
[CallerArgumentExpression] (C# 10) powers ArgumentNullException.ThrowIfNull(x) (parameter name "x") and assertion libraries.[CallerMemberName] is used for INotifyPropertyChanged (OnPropertyChanged() gets the property name).An authorisation layer read custom [RequiresPermission("orders:write")] attributes on every request via GetCustomAttributes, allocating attribute instances per call and showing up in allocation profiles. Caching permissions per endpoint at start-up (via endpoint metadata in ASP.NET Core, which already caches attributes) removed the overhead. We also switched to a generic [RequiresPolicy<OrdersWritePolicy>] attribute so policy types were checked at compile time.
The interviewer checks that you see attributes as metadata and understand their lifecycle, design rules and modern uses.
Green flags
AttributeUsage options and argument type limits.CallerArgumentExpression.Red flags
[Conditional] versus #if DEBUG.The custom attribute blob starts with a prolog (0x0001), followed by constructor arguments serialised by type (strings as length-prefixed UTF-8, Type as assembly-qualified type name strings, enums as underlying values), then named arguments (field/property kind, type, name, value). This is why only certain types are allowed — they must be encodable in this format.
Some attributes aren't stored as custom attributes at all but as metadata flags: [Serializable], [DllImport], [StructLayout], [FieldOffset], [MarshalAs], [In], [Out], [Optional], [MethodImpl]. Reflection synthesises them for compatibility.
MemberInfo.IsDefined(typeof(T), inherit) — no instantiation.GetCustomAttributesData() — returns CustomAttributeData describing constructor arguments without instantiating (useful for tools and when you can't load dependencies).MetadataLoadContext — inspect assemblies (and their attributes) without loading them for execution (build tools, analysers).Attributes read via reflection are generally preserved, but the attribute types and the members they reference via typeof must be kept; trimming analysis understands many patterns. Source generators reading attributes at compile time avoid runtime reflection entirely — the modern preferred approach.
[Conditional] semantics[Conditional("TRACE")] methods must return void. Calls are removed at compile time of the caller based on the caller's defined symbols, along with argument expressions. Debug.Assert and Debug.WriteLine rely on this ([Conditional("DEBUG")]).
[CallerFilePath] embeds the build machine path — deterministic builds with PathMap replace it; consider privacy/security when exposing it.[CallerArgumentExpression] captures source text exactly as written (including whitespace/comments normalised).GetCustomAttributesData().GetCustomAttribute<T>() twice and compare references.[MyAttr(1.5m)] with a decimal parameter and read the error.Require helper with [CallerArgumentExpression].using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class SensitiveAttribute(string reason) : Attribute
{
public string Reason { get; } = reason; // positional argument
public bool Mask { get; set; } = true; // named argument
}
public interface IValidator { bool IsValid(object value); }
public sealed class CustomerValidator : IValidator
{
public bool IsValid(object value) => value is Customer { Email.Length: > 3 };
}
// C# 11 generic attribute: the compiler checks the constraint
[AttributeUsage(AttributeTargets.Class)]
public sealed class ValidatorAttribute<TValidator> : Attribute where TValidator : IValidator, new()
{
public IValidator Create() => new TValidator();
}
[Validator<CustomerValidator>]
public sealed class Customer
{
public required string Name { get; init; }
[Sensitive("PII")] public required string Email { get; init; }
[Sensitive("Tax", Mask = false)] public string? Tfn { get; init; }
}
public static class Guard
{
// C# 10: the compiler passes the caller's source text for 'value'
public static T NotNull<T>(T? value, [CallerArgumentExpression(nameof(value))] string? expression = null)
where T : class
=> value ?? throw new ArgumentNullException(expression);
}
public static class Program
{
public static void Main()
{
var customer = new Customer { Name = "Mia", Email = "[email protected]", Tfn = "123456782" };
foreach (PropertyInfo p in typeof(Customer).GetProperties())
{
SensitiveAttribute? s = p.GetCustomAttribute<SensitiveAttribute>(); // constructs a new instance
Console.WriteLine($"{p.Name}: {(s is null ? "plain" : $"{s.Reason}, mask={s.Mask}")}");
}
var validator = typeof(Customer).GetCustomAttribute<ValidatorAttribute<CustomerValidator>>()!.Create();
Console.WriteLine($"Valid: {validator.IsValid(customer)}");
Log("Customer checked");
DebugOnly("removed from Release builds");
try { Guard.NotNull(FindManager(customer)); }
catch (ArgumentNullException ex) { Console.WriteLine(ex.ParamName); } // FindManager(customer)
}
static Customer? FindManager(Customer _) => null;
static void Log(string message,
[CallerMemberName] string member = "",
[CallerFilePath] string file = "",
[CallerLineNumber] int line = 0)
=> Console.WriteLine($"{Path.GetFileName(file)}:{line} {member}: {message}");
[Conditional("DEBUG")]
static void DebugOnly(string message) => Console.WriteLine(message); // call sites vanish without DEBUG
}
What happens: GetCustomAttributes in hot paths.
Why it is wrong: creates new attribute instances each time.
Do instead: cache results or use framework metadata caches.
GetCustomAttributes just to check presenceWhat happens: type.GetCustomAttributes(typeof(T)).Any().
Why it is wrong: instantiates attributes unnecessarily.
Do instead: IsDefined.
What happens: constructors that validate configuration or call services. Why it is wrong: runs at reflection time, possibly repeatedly, with confusing failures. Do instead: keep attributes as plain data.
Inherited to work for interface implementationsWhat happens: attributes on interface methods expected on implementing methods. Why it is wrong: attributes aren't inherited from interfaces. Do instead: read interface members explicitly or apply attributes on implementations.
What happens: trying to pass decimal, DateTime or computed values.
Why it is wrong: compile errors.
Do instead: use strings/ints and convert, or move configuration out of attributes.
typeof arguments instead of generic attributesWhat happens: [Validator(typeof(X))] with no type safety.
Why it is wrong: wrong types fail at run time.
Do instead: generic attributes with constraints (C# 11).
What happens: complex behaviour configured by stacks of attributes. Why it is wrong: hard to discover and test; requires recompilation. Do instead: fluent configuration or options where appropriate.
[CallerFilePath]What happens: file paths included in client-facing errors.
Why it is wrong: exposes internal structure.
Do instead: use PathMap or avoid exposing caller paths.
Attributes are how C# expresses configuration at the declaration site, and the senior question is always who reads it and when.
| Reader | When | Cost | Example |
|---|---|---|---|
| Compiler | build | none at run time | [Obsolete], [Conditional], nullable attributes |
| Source generator | build | none at run time, AOT safe | [JsonSerializable], [LoggerMessage] |
| Framework reflection at startup | once | startup time, trimming risk | MVC routing, DI scanning |
| Reflection per call | every call | allocation and CPU | naive validation or redaction |
The trend in .NET 8 to 10 is to push attribute reading from the last row to the first two, because it makes apps faster to start and trim-safe. A senior engineer also watches for attribute soup: when a class carries eight attributes from four frameworks, the configuration is scattered and hard to test; a fluent configuration in one place can be clearer.
Reflection reads the metadata the compiler wrote into an assembly at run time: typeof(T) or obj.GetType() gives a Type, and from it you get PropertyInfo, MethodInfo, FieldInfo and so on, filtered by BindingFlags. It is flexible but slow on hot paths: lookups search metadata, Invoke takes an object[], boxes value types, checks arguments and wraps exceptions in TargetInvocationException. Make it fast by looking up once and caching, turning a MethodInfo into a typed delegate, using MethodInvoker (.NET 8) when you only have a MethodInfo, or [UnsafeAccessor] (.NET 8) to reach a private member with no reflection at all. Reflection also hides dependencies from the trimmer, so in trimmed or Native AOT apps you must annotate with [DynamicallyAccessedMembers] or move the work to a source generator.
A source generator is a compiler plug-in that reads your code (usually a marker attribute on a partial declaration) and adds new C# files to the compilation; it can never change or delete your code. .NET ships several: System.Text.Json ([JsonSerializable] on a JsonSerializerContext) for reflection-free serialisation, [GeneratedRegex] for regex compiled to C# at build time, [LoggerMessage] for allocation-free structured logging, [LibraryImport] for P/Invoke marshalling, and in ASP.NET Core the configuration binding and Request Delegate generators that replace reflection in Bind/Get<T> and minimal APIs. They improve startup, cut allocations and make code trim and Native AOT safe. Inspect output under Dependencies, Analyzers in the IDE, or set EmitCompilerGeneratedFiles to write the files to obj.
Nullable reference types make nullability part of the declared type for static analysis: string means "should never be null" and string? means "may be null". The compiler tracks a null-state for every expression and warns when a maybe-null value is dereferenced or assigned to a non-nullable. It is compile-time only: there are no run-time checks, and ! merely silences a warning. Attributes like [NotNullWhen(true)] on TryGet methods or [MemberNotNull] on initialisers tell the analyser about contracts it cannot infer. Nulls can still arrive from deserialisation, arrays, default and unannotated code, so I still validate at the boundaries.
How to tell it (STAR)
**Situation:** we added PII redaction to logs in our [customer API] and p99 latency jumped by [30 ms]. **Task:** I had to keep redaction, which was a compliance requirement, but remove the overhead. **Action:** I used dotnet-counters and a memory profiler and found we created a new attribute instance per property per log call. I cached the attribute lookups per type, and later proposed a source generator so nothing was read at run time. **Result:** latency returned to baseline, allocations fell by [60%], and the redaction rules became testable at build time.
Last updated 30 Oct 2026.