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

📚 Interview question

Review this class's debugging attributes. How do DebuggerDisplay, DebuggerBrowsable, DebuggerTypeProxy and DebuggerStepThrough help, and what would you change?

A code review of debugger customisation in C#: cheap, side-effect-free DebuggerDisplay, hiding noise with DebuggerBrowsable, collection proxies, step-through attributes, and removing leftover Debugger calls and personal data from debug views.

Intermediate CodeReview Intermediate attributes code-review csharp debugger-display debugging

🎯 Key points

  • DebuggerDisplay controls the one-line summary in Watch, Locals and DataTips; it is evaluated by the debugger, not at run time
  • Keep the display expression cheap and side-effect free; a private DebuggerDisplay property with nq is the usual pattern
  • Do not show secrets or personal data in debug views
  • DebuggerBrowsable(Never) hides noisy members; RootHidden flattens a collection
  • DebuggerTypeProxy gives a type a custom debugger view
  • DebuggerStepThrough and DebuggerHidden skip trivial code when stepping, but can hide bugs if overused
  • Remove leftover Debugger.Launch/Debugger.Break calls and side-effecting properties before merging

⏱ 30-second answer

These attributes change only what the debugger shows; they don't change runtime behaviour. [DebuggerDisplay] sets the one-line summary in Watch and DataTips, so it should be cheap and side-effect free. The usual pattern is a private DebuggerDisplay property with nq. [DebuggerBrowsable(Never)] hides noisy fields, and [DebuggerTypeProxy] gives a custom view. [DebuggerStepThrough] skips trivial code when stepping, but overused it hides bugs. In this class I'd remove the email and password hash from the display, replace the display expression that calls the database, make the side-effecting property a method, and delete the leftover Debugger.Launch().

🗣 Full answer (senior)

How I would answer this in an interview

"These attributes only change what the debugger shows; they don't change runtime behaviour. [DebuggerDisplay] sets the one-line summary in Watch, Locals and DataTips, and the debugger evaluates it in the paused process, the same function evaluation the Watch window uses, so it must be cheap and side-effect free; my usual pattern is a private DebuggerDisplay property with nq. [DebuggerBrowsable(Never)] hides noisy members and RootHidden flattens a collection, [DebuggerTypeProxy] gives a custom view, and [DebuggerStepThrough] or [DebuggerHidden] skip trivial code when stepping, which ties into Just My Code. In a review I check what each display runs, what it shows, and what stepping it hides.

For example, in [a customer portal] a [DebuggerDisplay] that included customer email and [Medicare number] appeared in a shared screen recording posted to a team channel, and it was reported as a privacy incident. I had to stop it happening again across the codebase. I replaced the displays with ids and status only, hid sensitive members, and added an analyser rule that flags sensitive property names in display strings. [About 40] types were cleaned up, and the rule now flags new cases automatically.

In this class I'd remove the email and password hash from the display, replace the expression that calls the database, turn the Orders getter into GetOrdersAsync, take [DebuggerStepThrough] off ApplyDiscount because it has real logic, and delete the leftover Debugger.Launch(). The mistake I see is getters that do I/O, because the debugger, serialisers and loggers all call them at surprising times. My rule is simple: getters are cheap and side-effect free, and I/O is an async method."


What each attribute does

Attribute Effect in the debugger
[DebuggerDisplay("...")] The summary text in Locals, Watch and DataTips; {expr} placeholders are evaluated by the debugger, nq removes quotes
[DebuggerBrowsable(DebuggerBrowsableState.Never)] Hides a field or property when the object is expanded
[DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] Shows a collection's items directly instead of the collection node
[DebuggerTypeProxy(typeof(...))] Replaces the expanded view with a proxy type's members
[DebuggerStepThrough] Step Into skips the method instead of stopping inside it
[DebuggerHidden] The debugger does not stop in the method, even on a breakpoint, and steps over it
[DebuggerNonUserCode] Marks code as not yours for Just My Code

None of these change what the program does when no debugger is attached.

Review of the class

# Finding Severity Fix
1 DebuggerDisplay shows Email and PasswordHash High Show an id and non-sensitive state only; personal data in debug views ends up in screenshots and recordings
2 Display expression calls LoadOrderCount(), which queries the database High Display expressions run on every hover and step; use cheap fields
3 Orders property hits the database in its getter High Make it an explicit async method (GetOrdersAsync); getters should be cheap and side-effect free, because the debugger and serialisers call them
4 Debugger.Launch() left in the constructor High Remove; in some environments it prompts to attach a debugger or blocks
5 [DebuggerStepThrough] on ApplyDiscount, which contains real logic Medium Remove; reserve it for trivial wrappers
6 Raw _auditTrail list clutters the view Low [DebuggerBrowsable(Never)], or expose a summary in the display

Production example

In a customer portal, a [DebuggerDisplay] that included customer email and Medicare number appeared in a shared screen recording of a debugging session that was posted to a team channel. It was reported as a privacy incident. The team replaced displays across the codebase with ids and status only and added an analyser rule to flag sensitive property names in DebuggerDisplay strings.

🔍 What the interviewer is checking

The interviewer checks whether you understand that debugger tooling runs your code, and whether you spot privacy and design issues in review.

Green flags

  • Explains what each attribute does and that it only affects the debugger.
  • Flags personal data, expensive display expressions and side-effecting getters.
  • Uses the private DebuggerDisplay property pattern.

Red flags

  • Sees nothing wrong with I/O in a getter.
  • Thinks showing email and password hash in the debugger is harmless.
  • Leaves Debugger.Launch().

📚 Learn in depth

1. Metadata the debugger reads

These attributes are compiled into the assembly as metadata. Nothing in your program reads them at run time; only a debugger does.

 Customer.cs
   [DebuggerDisplay("{DebuggerDisplay,nq}")]
   [DebuggerTypeProxy(typeof(CustomerDebugView))]
        |
        v  compiled into Portal.Domain.dll metadata
        |
  no debugger attached --> ignored completely, zero runtime cost
        |
  debugger attached, you hover a Customer
        v
  debugger reads the attributes
  --> evaluates the display expression IN your paused process
      (function evaluation: same rules and risks as the Watch window)
  --> shows "Customer 42 (Active, 3 audit entries)"

The last step is the important one: the display text costs nothing in production, but it runs your code every time a developer hovers, steps or expands a Watch.

2. Every attribute and when to use it

Attribute Applies to Effect Use it for
[DebuggerDisplay("...")] Types, fields, properties The one-line summary in Locals, Watch and DataTips Every domain type you inspect often
[DebuggerBrowsable(Never)] Fields, properties Hidden when the object is expanded Backing fields, sensitive values, noisy internals
[DebuggerBrowsable(RootHidden)] A collection member Shows its items directly instead of a collection node Wrapper types around a list
[DebuggerBrowsable(Collapsed)] Fields, properties Shown but collapsed (the default) Rarely needed
[DebuggerTypeProxy(typeof(...))] Types Replaces the expanded view with a proxy's members Complex internals, custom collections
[DebuggerStepThrough] Methods, types Step Into skips it; breakpoints inside still hit Trivial wrappers, guard helpers, generated code
[DebuggerHidden] Methods Debugger never stops inside, even on breakpoints Very thin throw helpers
[DebuggerNonUserCode] Methods, types Treated as not yours by Just My Code Generated code, framework-like infrastructure
[DebuggerStepperBoundary] Methods Stops stepping from crossing into it Rare; infrastructure with callbacks
[DebuggerVisualizer] Types A custom visualiser window (Visual Studio) Images, large data structures

Visual Studio, Rider and VS Code with C# Dev Kit all honour DebuggerDisplay, DebuggerBrowsable and DebuggerTypeProxy. Support for the rarer attributes varies.

3. DebuggerDisplay syntax

[DebuggerDisplay("Order {Number,nq} total={Total}", Name = "{Number,nq}", Type = "Order")]
Part Meaning
{expression} Evaluated by the debugger's expression evaluator, not compiled by C#
,nq No quotes around strings
Name = "..." Text in the Name column when the object is a field or collection item
Type = "..." Text in the Type column

Because the string isn't compiled, a renamed property silently breaks the display, and a typo shows an error only when someone hovers. That is why the usual pattern is:

[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class Customer
{
    private string DebuggerDisplay => $"Customer {Id} ({Status})";
}

The private property is compiled, refactor-safe and testable, and the attribute string never changes.

4. What a good display expression looks like

Good Bad
Reads fields and simple properties Calls the database, an HTTP service or the file system
Constant time Enumerates a large collection or runs LINQ
No side effects Increments counters, caches values, starts timers
Ids, status, counts Email, names, Medicare or tax file numbers, tokens, password hashes
Never throws Dereferences something that may be null

An expensive display slows every step for everyone on the team, and a side-effecting one changes the program they are debugging.

5. DebuggerTypeProxy in practice

[DebuggerTypeProxy(typeof(OrderBookDebugView))]
public sealed class OrderBook { /* complex internal structure */ }

internal sealed class OrderBookDebugView(OrderBook book)
{
    public int Count => book.Count;

    [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
    public Order[] Items => book.ToArray();
}

The proxy's constructor takes the real object, and its public members become the expanded view. The original fields are still available under Raw View, or with the , raw format specifier. The BCL uses this pattern for List<T>, Dictionary<TKey, TValue> and many other collections.

6. Step-through attributes and their risk

 you press F11 on: var price = Guard.NotNegative(ApplyDiscount(amount));

 [DebuggerStepThrough] Guard.NotNegative  --> skipped (good: trivial)
 [DebuggerStepThrough] ApplyDiscount      --> skipped (bad: the bug is in here)

Put them only on code that is trivially correct. On real logic they create bugs that nobody can step into. [DebuggerHidden] is stronger: breakpoints inside are ignored too, so use it even more sparingly.

7. Getters must be cheap, for everyone

The debugger isn't the only thing that reads properties:

Reader When
Debugger Every hover, step and Watch refresh
System.Text.Json and other serialisers Every serialisation
Logging with structured objects Every log call that captures the object
Data binding (WPF, MAUI, Blazor) Every render
Exception messages and ToString() Whenever an error is formatted

A getter that does I/O or changes state is called at surprising times by all of them. Make expensive or effectful operations explicit methods, preferably async, such as GetOrdersAsync(cancellationToken).

8. Privacy

Debug views leak through screen sharing, recordings of pairing sessions, screenshots in tickets and conversations with contractors. The Australian Privacy Principles cover accidental disclosure as well as deliberate disclosure. Keep personal information, tokens and secrets out of DebuggerDisplay and ToString(), and hide sensitive members with [DebuggerBrowsable(Never)]. Hiding a member doesn't make it secret (it is still in memory and in Raw View), but it stops it appearing in casual views.

9. .NET 11 awareness

These attributes have been stable for many releases. .NET 9 added [DebuggerDisableUserUnhandledExceptions] for framework code that catches exceptions thrown by user callbacks; check the .NET 11 release notes for any further additions.

10. Try it yourself

  1. Paste the "before" class into a console app with a fake repository and hover over an instance.
  2. Put a breakpoint inside LoadOrderCount and watch it hit when you hover.
  3. Apply the "after" version and compare the Watch window.
  4. Rename IsActive without updating a hard-coded DebuggerDisplay("{IsActive}") string and see the display break, then compare with the private property pattern.
  5. Add a DebuggerTypeProxy that shows only a summary of the audit trail, and find the original fields under Raw View.
  6. Put [DebuggerStepThrough] on ApplyDiscount, set a breakpoint inside it, and see that Step Into skips it but the breakpoint still hits.

💻 Code

The class under review (do not copy):

using System.Diagnostics;

namespace Portal.Domain;

public interface IOrderRepository
{
    int CountOrders(int customerId);
    IReadOnlyList<string> LoadOrders(int customerId);
}

[DebuggerDisplay("{Email} {PasswordHash} orders={LoadOrderCount()}")]
public class Customer
{
    private readonly IOrderRepository _orders;
    private readonly List<string> _auditTrail = [];

    public Customer(int id, string email, string passwordHash, IOrderRepository orders)
    {
        Debugger.Launch();
        Id = id;
        Email = email;
        PasswordHash = passwordHash;
        _orders = orders;
    }

    public int Id { get; }
    public string Email { get; }
    public string PasswordHash { get; }
    public bool IsActive { get; private set; } = true;

    public IReadOnlyList<string> Orders => _orders.LoadOrders(Id); // database call in a getter

    public int LoadOrderCount() => _orders.CountOrders(Id);

    [DebuggerStepThrough]
    public decimal ApplyDiscount(decimal amount) =>
        IsActive && amount > 500m ? Math.Round(amount * 0.95m, 2) : amount;
}

The reviewed version:

using System.Diagnostics;

namespace Portal.Domain;

public interface IOrderRepository
{
    Task<IReadOnlyList<string>> LoadOrdersAsync(int customerId, CancellationToken cancellationToken);
}

[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class Customer(int id, string email, string passwordHash)
{
    [DebuggerBrowsable(DebuggerBrowsableState.Never)]
    private readonly List<string> _auditTrail = [];

    public int Id { get; } = id;

    [DebuggerBrowsable(DebuggerBrowsableState.Never)]
    public string Email { get; } = email;

    [DebuggerBrowsable(DebuggerBrowsableState.Never)]
    public string PasswordHash { get; } = passwordHash;

    public bool IsActive { get; private set; } = true;

    public int AuditEntryCount => _auditTrail.Count;

    // Explicit, async, and never called by the debugger.
    public Task<IReadOnlyList<string>> GetOrdersAsync(IOrderRepository orders, CancellationToken cancellationToken) =>
        orders.LoadOrdersAsync(Id, cancellationToken);

    public decimal ApplyDiscount(decimal amount) =>
        IsActive && amount > 500m ? Math.Round(amount * 0.95m, 2) : amount;

    // Cheap, side-effect free, and contains no personal data.
    private string DebuggerDisplay => $"Customer {Id} ({(IsActive ? "Active" : "Inactive")}, {AuditEntryCount} audit entries)";
}

⚠ Common mistakes

  • Mistake: expensive or effectful DebuggerDisplay expressions. The expression runs on every hover and step, so a database call slows every debugging session and can change data, and a counter increment changes the program being debugged. Instead: read fields and simple properties only, through a private DebuggerDisplay property that is constant time and side-effect free.

  • Mistake: hard-coding member names in the attribute string. The string isn't compiled, so renaming a property silently breaks the display until someone hovers. Instead: use [DebuggerDisplay("{DebuggerDisplay,nq}")] with a private compiled property.

  • Mistake: personal data or secrets in DebuggerDisplay or ToString(). Debug views end up in screen shares, recordings and screenshots in tickets, which is a privacy incident. Instead: show ids, status and counts. Hide sensitive members with [DebuggerBrowsable(Never)].

  • Mistake: getters that do I/O. The debugger, serialisers, loggers and data binding all call getters unexpectedly, causing queries, slowness and surprising side effects. Instead: keep getters cheap and pure, and make I/O an explicit async method such as GetOrdersAsync.

  • Mistake: [DebuggerStepThrough] or [DebuggerHidden] on real logic. Step Into skips the method, so the bug inside it can't be stepped into, and [DebuggerHidden] ignores breakpoints too. Instead: reserve them for trivial wrappers, guard helpers and generated code.

  • Mistake: leaving Debugger.Launch() or Debugger.Break() in committed code. On a server or CI agent it can prompt to attach a debugger, stop a test run or end the process. Instead: remove them before merging. An analyser rule or a review checklist item catches them.

  • Mistake: thinking these attributes affect production behaviour or performance. They are metadata read only by a debugger; adding them doesn't slow production, and removing them doesn't fix a slow getter. Instead: judge them by what they cost a developer while debugging, and fix the underlying properties separately.

  • Mistake: believing [DebuggerBrowsable(Never)] protects a value. It only hides the member in the default view. The value is still in memory, in Raw View and in dumps. Instead: use it to reduce casual exposure, and protect real secrets by not keeping them in the object at all.

🏛 Senior insight: trade-offs

Debugger attributes are part of a type's developer experience, and a senior engineer reviews them like any other code: they make common types instantly readable (ids, status, counts), keep them cheap and pure, and keep sensitive data out because debug views leak through screenshares and recordings. The bigger lesson in this review is about properties: anything the debugger evaluates should be safe to evaluate at any time, which is also what serialisers, loggers and data binding assume. Teams that adopt a simple rule ("getters are cheap and side-effect free; I/O is an async method") avoid a whole class of debugging and production surprises.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate says the display expression runs code. Why: DebuggerDisplay is evaluated by the same function evaluation as Watch.
    How to answer

    At a breakpoint, Locals shows the variables in scope, Autos shows the ones used around the current line, and Watch shows expressions I pick and keep across steps. The Immediate window, which is the Debug Console in VS Code, lets me evaluate expressions, call methods and even assign new values to test an idea. The catch is that evaluating an expression runs real code in the paused process. A property getter with side effects, a LINQ query or a lazy-loaded EF Core navigation can change state or hit the database. When that matters I add the nse (no side effects) format specifier, as in customer.Orders, nse, or read backing fields directly. I also remember evaluation can time out because other threads are frozen.

  2. When: The candidate mentions stepping behaviour or Just My Code. Why: DebuggerStepThrough and DebuggerNonUserCode interact with Just My Code.
    How to answer

    A PDB file maps compiled IL back to source lines and variable names; without it the debugger can't show source or bind breakpoints. Just My Code tells the debugger to step only through my code and treat everything else as external. To step into ASP.NET Core or a NuGet package, I turn Just My Code off, enable the Microsoft and NuGet.org symbol servers so the debugger can download PDBs, and enable Source Link. Source Link stores the repository URL and commit in the PDB, so the debugger fetches the exact source file that was compiled. Library authors make this work by publishing .snupkg symbol packages from deterministic CI builds.

↩ Usually asked after

More in Debugging Tools

Last updated 1 Nov 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me