📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do attributes work in C#? Explain what the compiler stores in metadata, when attribute instances are actually created, AttributeUsage (targets, AllowMultiple, Inherited), allowed argument types, generic attributes (C# 11), attributes the compiler itself acts on, and caller information attributes including CallerArgumentExpression.

Attributes as metadata, custom attribute design, generic attributes, compiler-interpreted attributes and caller info attributes.

Mid-Level Theory Intermediate attributes caller-info generic-attributes metadata reflection

🎯 Key points

  • Attributes are metadata blobs; nothing runs until a compiler, generator or reflection reads them
  • GetCustomAttribute constructs a new instance on every call, so cache results
  • AttributeUsage sets valid targets, AllowMultiple and Inherited
  • Arguments must be constants, typeof, enums or one-dimensional arrays of those
  • C# 11 generic attributes give compile-time type checking
  • The compiler itself acts on Obsolete, Conditional, nullable analysis and SkipLocalsInit attributes
  • Caller info attributes, including C# 10 CallerArgumentExpression, are filled in at the call site at compile time
  • Source generators read marker attributes at build time instead of reflection at run time

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. What the compiler stores

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

2. When attribute instances are created

Only when code calls reflection APIs like GetCustomAttributes, GetCustomAttribute<T>(), Attribute.GetCustomAttributes(...):

  • The runtime deserialises the blob, runs the attribute constructor, and sets named properties — every call creates new instances (attributes are mutable objects, so they aren't shared).
  • That's why you should cache results, and use IsDefined(typeof(T)) when you just need to know if an attribute exists (cheaper, no instance).
  • Exceptions in an attribute constructor surface at reflection time, not at load time.

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.

3. 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).

4. Allowed argument types

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.

5. Generic attributes (C# 11)

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

6. Attributes the compiler itself acts on

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.
  • Nullable analysis attributes: [NotNullWhen], [MemberNotNull], [DoesNotReturn]...
  • [MethodImpl(MethodImplOptions.AggressiveInlining)] (JIT hint), [SkipLocalsInit], [StructLayout], [FieldOffset] (layout), [ModuleInitializer], [InterpolatedStringHandler], [OverloadResolutionPriority] (C# 13), [Experimental] (C# 12).
  • Source generators are often triggered by attributes ([JsonSerializable], [LoggerMessage], [GeneratedRegex]).

7. Caller information attributes

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"
  • The compiler substitutes constants at the call site — zero runtime cost, no stack walking.
  • [CallerArgumentExpression] (C# 10) powers ArgumentNullException.ThrowIfNull(x) (parameter name "x") and assertion libraries.
  • [CallerMemberName] is used for INotifyPropertyChanged (OnPropertyChanged() gets the property name).

8. A production example

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.

🔍 What the interviewer is checking

The interviewer checks that you see attributes as metadata and understand their lifecycle, design rules and modern uses.

Green flags

  • Says attributes do nothing until read, and instances are created per read.
  • Knows AttributeUsage options and argument type limits.
  • Mentions generic attributes and caller info including CallerArgumentExpression.
  • Names compiler-interpreted attributes and links attributes to source generators.

Red flags

  • Believes attributes intercept method calls by themselves.
  • Reads attributes in hot paths without caching.
  • Cannot explain [Conditional] versus #if DEBUG.

📚 Learn in depth

1. Metadata encoding

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.

2. Pseudo-custom attributes

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.

3. Reading attributes efficiently

  • 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).
  • Cache per type/member in static dictionaries or generic static caches.

4. Attributes and trimming/AOT

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.

5. [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")]).

6. Caller info details

  • Caller attributes require optional parameters with defaults.
  • [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).

Try it yourself

  1. Inspect a compiled attribute with ILSpy or GetCustomAttributesData().
  2. Call GetCustomAttribute<T>() twice and compare references.
  3. Try [MyAttr(1.5m)] with a decimal parameter and read the error.
  4. Write a Require helper with [CallerArgumentExpression].

💻 Code

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
}

⚠ Common mistakes

1. Reading attributes on every request

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.

2. Using GetCustomAttributes just to check presence

What happens: type.GetCustomAttributes(typeof(T)).Any(). Why it is wrong: instantiates attributes unnecessarily. Do instead: IsDefined.

3. Logic in attribute constructors

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.

4. Expecting Inherited to work for interface implementations

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

5. Non-constant attribute arguments

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.

6. typeof arguments instead of generic attributes

What 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).

7. Overusing attributes for configuration

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.

8. Leaking build paths via [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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate reads attributes at run time. Why: Attribute reads go through reflection, with its costs.
    How to answer

    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.

  2. When: Candidate mentions JsonSerializable or LoggerMessage. Why: Marker attributes drive the built-in source generators.
    How to answer

    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.

  3. When: Candidate mentions NotNullWhen or MemberNotNull. Why: Nullable analysis attributes are compiler-interpreted.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Sensitive-data redaction filter slowed every log call
Situation
A customer API redacted properties marked [Sensitive] before structured logging, across [40] request types, at around [1,500] requests per second.
Signals
p99 latency rose by [30 ms] after the redaction feature shipped, and allocation rate in dotnet-counters doubled.
Investigation
A memory profile showed thousands of SensitiveAttribute instances per second; the filter called GetProperties and GetCustomAttribute for each property of each logged object.
Resolution
Built a static per-type cache of sensitive property getters on first use, then later replaced it with a source generator that emits a Redact method per type.
Prevention
Added a benchmark for the redaction path and a unit test that every DTO with personal data has [Sensitive] on the right properties.

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.

↩ Usually asked after

More in Advanced Language Features

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me