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

📚 Interview question

How do properties work in C#? Explain auto-properties and backing fields, get/set/init accessors, required members (C# 11), the C# 14 field keyword, computed properties, and when to use a property, a field or a method.

C# properties: how they compile, init and required, the C# 14 field keyword, validation, computed properties, binary compatibility and property-versus-method guidelines.

Mid-Level Theory Intermediate csharp csharp-14 field-keyword init oop properties required

🎯 Key points

  • Properties compile to get_X/set_X methods; auto-properties have a hidden backing field
  • Changing a public field to a property is a binary breaking change
  • init (C# 9) allows setting only during creation, including object initialisers and with-expressions
  • required (C# 11) forces callers to set members; [SetsRequiredMembers] exempts constructors that set them
  • The C# 14 field keyword gives accessors access to the generated backing field for validation
  • Getters should be cheap, side-effect free and rarely throw; debuggers and serialisers call them
  • Use methods for slow work, I/O or values that differ on each call
  • Expose collections as read-only views and change them through methods

⏱ 30-second answer

A property looks like a field to callers but compiles to get_X/set_X methods, so you can add validation, change storage or compute values without changing callers. Auto-properties (public string Name { get; set; }) get a compiler-generated private backing field. init (C# 9) allows setting only during object creation; required (C# 11) forces callers to set the member in the object initialiser. The C# 14 field keyword lets an accessor use the generated backing field, so set => field = value.Trim(); no longer needs a hand-written field. Use properties for public state, private fields for internal storage, and methods for work that is slow, has side effects or may fail.

🗣 Full answer (senior)

How I would answer this in an interview

A property looks like a field to callers but is implemented as accessor methods (get, set, init). That lets a type expose data while keeping control over validation, computation, change notification and representation — and lets you change the implementation later without breaking callers. I'd cover auto-properties and backing fields, the accessors, required, the C# 14 field keyword, computed properties, and the property/field/method decision.

1. Auto-properties and backing fields

public string Name { get; set; }                 // auto-property: compiler-generated private backing field
public Guid Id { get; } = Guid.NewGuid();        // get-only: assignable only in constructor/initialiser
public decimal Balance { get; private set; }     // public read, private write

private string _email = "";                      // explicit backing field
public string Email
{
    get => _email;
    set => _email = value?.Trim().ToLowerInvariant() ?? throw new ArgumentNullException(nameof(value));
}

Auto-properties generate a hidden field (<Name>k__BackingField) and trivial accessors. The JIT inlines trivial accessors, so there's no runtime cost versus a field.

2. get, set and init

  • get — returns the value.
  • set — can be called any time (by those with access).
  • init (C# 9) — can be called only during object creation: constructors, object initialisers, with expressions. Then the property is effectively read-only:
public sealed class Address { public required string Street { get; init; } public string? Unit { get; init; } }
var a = new Address { Street = "1 George St" };
// a.Street = "x";   // CS8852: init-only

Accessors can have different accessibility (public get; private set;), but only one accessor can be more restrictive than the property.

3. required members (C# 11)

required forces callers to set the member in an object initialiser (or a constructor marked [SetsRequiredMembers]). It moves "you forgot to set Street" from a runtime null bug to a compile error, and System.Text.Json enforces it during deserialisation (missing JSON property → exception). Great for DTOs and options classes.

4. The C# 14 field keyword

Previously, adding any logic to an accessor meant writing an explicit backing field. C# 14's contextual field keyword refers to the compiler-generated backing field:

public string Email
{
    get;
    set => field = value?.Trim().ToLowerInvariant() ?? throw new ArgumentNullException(nameof(value));
}

public decimal Percentage
{
    get;
    init => field = value is >= 0 and <= 100 ? value : throw new ArgumentOutOfRangeException(nameof(value));
}

You keep the conciseness of auto-properties while adding validation, normalisation or lazy initialisation (get => field ??= Load();). (If a type already has a member named field, use @field or this.field to disambiguate.)

5. Computed properties

public decimal Total => Lines.Sum(l => l.Amount);   // computed on every access, no storage
public bool IsOverdue => DueDate < _clock.GetUtcNow();

They derive from other state, so they can never be inconsistent. Keep them cheap — callers (and debuggers, serialisers and data-binding) read properties freely and repeatedly.

6. Property vs field vs method

Use When
Field (private) Internal state. Public fields only for constants (const/static readonly) or simple performance-critical structs
Property Exposing state or a cheap derived value; may validate on set; behaves like data; idempotent; fast
Method The operation is expensive (I/O, large computation), has side effects, returns a new value each time (GetNextId()), may throw for normal reasons, needs parameters, or the result varies between calls (DateTime.Now is a historic exception people regret)

Framework Design Guidelines: properties shouldn't be slow, shouldn't have surprising side effects, and getters shouldn't throw. Count is a property; CalculateTax() and LoadOrdersAsync() are methods. Note there are no async properties — anything needing await is a method.

Also: changing a public field to a property later is a binary breaking change (different IL access), and fields can't be in interfaces or work with data binding — so public data should be properties from the start.

7. A production example

A view model had public decimal Total { get { return _orderService.CalculateTotal(_order); } } which called the database. A grid bound to 200 rows triggered 200+ queries on every render, and the debugger's watch window triggered more. Changing it to a CalculateTotalAsync() method called once per load, with the result stored in a simple property, made the page fast and the cost obvious to the next reader.

🔍 What the interviewer is checking

Checks knowledge of property mechanics and modern features plus API judgement.

Green flags: properties compile to methods; field-to-property is binary breaking; init, required, SetsRequiredMembers; C# 14 field keyword; property-versus-method guidelines; read-only collection exposure.

Red flags: public fields everywhere; I/O in getters; unaware of init/required; thinks properties are slower than fields.

📚 Learn in depth

1. Compiled form

public string Name { get; set; }

compiles to:

[CompilerGenerated] private string <Name>k__BackingField;
public string get_Name() => <Name>k__BackingField;
public void set_Name(string value) => <Name>k__BackingField = value;
// plus property metadata linking get_Name/set_Name

init accessors compile to a setter with a modreq(IsExternalInit) marker, which older compilers can't call — enforcing init-only semantics across languages. Because properties are methods, they can be virtual, abstract, part of interfaces, and have attributes on the property, accessors or backing field ([field: JsonIgnore]).

2. Inlining and performance

Trivial accessors are small enough that the JIT inlines them, producing the same machine code as a field access. Non-trivial accessors (validation, logging) may not inline. Virtual properties follow virtual-call rules (devirtualised when the type is sealed or known).

3. field keyword details (C# 14)

  • field is only a keyword inside property accessors; a backing field is synthesised only when field is used (or the accessor is auto-implemented).
  • You can mix: get; auto-implemented with a custom set => field = ....
  • Attributes can target the synthesised field with [field: ...].
  • Nullable analysis treats field like other fields; you may need initialisers or required for non-nullable reference types.
  • Lazy patterns: get => field ??= ComputeExpensive(); (not thread-safe — use Lazy<T> when it matters).

4. required mechanics

The compiler emits RequiredMemberAttribute on the type and members, and marks constructors (other than those with [SetsRequiredMembers]) with CompilerFeatureRequiredAttribute/ObsoleteAttribute tricks so older compilers can't bypass the requirement. Reflection-based creators (DI, serialisers) don't automatically enforce required unless they're aware of it — System.Text.Json (.NET 7+) is.

5. Properties and frameworks

  • Serialisation: System.Text.Json serialises public properties (fields only with IncludeFields); supports init, required, private setters with [JsonInclude].
  • EF Core: maps properties (and can use backing fields — by convention it finds _name or <Name>k__BackingField; configurable with HasField and UsePropertyAccessMode). EF can bypass setters to avoid triggering validation when materialising.
  • Data binding / MVVM: needs properties with change notification (INotifyPropertyChanged), which source generators now implement via partial properties (C# 13).
  • Options pattern: binds configuration to properties with public setters (or init).

6. Indexers are parameterised properties

this[int index] compiles to get_Item/set_Item methods — same machinery with parameters.

Try it yourself

  1. Decompile an auto-property and an init property and find the backing field and IsExternalInit marker.
  2. Rewrite a property with an explicit backing field using the C# 14 field keyword.
  3. Deserialise JSON missing a required property and observe the exception.
  4. Find a property in your code that does I/O and convert it to a method.

💻 Code

public sealed class Product
{
    // C# 14: validation without a hand-written backing field
    public required string Sku
    {
        get;
        init => field = value is { Length: > 0 } ? value.ToUpperInvariant()
                                                 : throw new ArgumentException("SKU required");
    }

    public decimal Price
    {
        get;
        set => field = value >= 0 ? decimal.Round(value, 2)
                                  : throw new ArgumentOutOfRangeException(nameof(value));
    }

    private readonly List<string> _tags = [];
    public IReadOnlyList<string> Tags => _tags;               // read-only view
    public bool IsFree => Price == 0;                         // computed property
    public Guid Id { get; } = Guid.CreateVersion7();          // get-only, set at creation

    public void AddTag(string tag) => _tags.Add(tag.Trim().ToLowerInvariant());
}

// Options class with required + init (works with configuration binding and JSON)
public sealed class SmtpOptions
{
    public required string Host { get; init; }
    public int Port { get; init; } = 587;
    public required string FromAddress { get; init; }
}

// Method, not property: slow and has I/O
public sealed class Invoice(IInvoiceRepository repo)
{
    public Guid Id { get; init; }
    public Task<decimal> CalculateTotalAsync(CancellationToken ct) => repo.SumLinesAsync(Id, ct);
}

⚠ Common mistakes

1. Expensive or I/O-heavy property getters

What happens: getters call databases, APIs or heavy computations. Why it is wrong: callers, serialisers and debuggers read properties repeatedly, causing hidden N+1 calls and slowness. Do instead: use methods (async where needed) for expensive work.

2. Getters with side effects

What happens: reading a property increments a counter or changes state. Why it is wrong: surprising behaviour, especially when the debugger evaluates properties. Do instead: keep getters side-effect free.

3. Public fields for data

What happens: public string Name; on public types. Why it is wrong: can't add validation later without a binary break; not supported by interfaces, data binding and some serialisers. Do instead: use auto-properties.

4. Public setters on everything

What happens: domain types with { get; set; } for all properties. Why it is wrong: invariants can be bypassed. Do instead: use private set, init, or methods for state changes.

5. Forgetting required on essential DTO properties

What happens: requests deserialise with missing values as null or default. Why it is wrong: bugs surface later, far from the cause. Do instead: mark essential members required.

6. Throwing from getters

What happens: getters throw for normal conditions. Why it is wrong: breaks debugging, serialisation and data binding. Do instead: throw from setters or methods; make getters safe.

7. Non-thread-safe lazy properties in shared objects

What happens: get => field ??= Load(); in a singleton. Why it is wrong: concurrent callers may run Load multiple times or see partial state. Do instead: use Lazy<T> or initialise eagerly.

8. Async work hidden in properties

What happens: properties call .Result on async methods. Why it is wrong: blocking, deadlocks and thread-pool starvation. Do instead: expose async methods.

🏛 Senior insight: trade-offs

Seniors treat properties as part of the type's contract, with performance expectations attached.

Situation Senior choice
DTO, options, message required + init properties, or a record
Entity state with rules Private setter or C# 14 field-based validation, plus behaviour methods
Expensive or I/O-bound value Method, usually async
Collection exposure IReadOnlyList<T> property, mutation through methods
UI binding Properties with change notification from a source generator

They also know that required is enforced only by the compiler: reflection-based creators, and serialisers unless configured, can still create objects without it, so domain invariants still belong in constructors or factories.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate uses private setters for invariants Why: Checks encapsulation beyond property syntax
    How to answer

    Encapsulation means an object owns its state and only lets it change through behaviour that keeps it valid. In C# you keep fields private, expose read access through properties (often get-only or private set), and offer methods like Withdraw(amount) that check rules before changing state, instead of a public setter on Balance. Collections are exposed as IReadOnlyList<T> with Add... methods. The benefit is that invariants ("balance never goes below the overdraft limit") are enforced in one place, so callers cannot put the object into an invalid state, and the internals can change without breaking callers.

  2. When: Candidate mentions init and with-expressions Why: Checks records and non-destructive mutation
    How to answer

    A record is a type whose identity is its data. public record Money(decimal Amount, string Currency); makes the compiler generate init-only properties, a constructor, Deconstruct, value-based Equals/GetHashCode/==, a readable ToString, and a hidden clone method used by with expressions for non-destructive mutation. record (or record class, C# 9) is a reference type; record struct (C# 10) is a value type whose positional properties are mutable unless you write readonly record struct. Equality includes an EqualityContract so a base and derived record are never equal. Watch out: with is a shallow copy, collection members compare by reference, and records are a poor fit for EF Core entities, which need identity-based equality.

🛠 Real-world problems: how you handle them

Database load spike from an expensive property getter
Situation
An invoicing service had `Invoice.Total` implemented as a getter that queried SQL Server to sum the lines.
Signals
After a logging change that serialised invoices at debug level, database CPU rose by about [30%] and query count per request jumped from [3] to [20+].
Investigation
SQL Server Query Store showed the sum query running far more than invoices were loaded; a stack trace showed it called from the JSON serialiser through the `Total` getter.
Resolution
Replaced the property with `CalculateTotalAsync()` and added a stored `Total` property updated by `AddLine` and `RemoveLine`. Logging stopped triggering queries.
Prevention
Review rule and an analyser check: no I/O in property getters; integration test that counts queries for an invoice request.

How to tell it (STAR)

**Situation:** database CPU jumped by about [30%] after an innocent logging change. **Task:** find the extra queries and stop them. **Action:** Query Store and stack traces showed the JSON serialiser calling `Invoice.Total`, a getter that hit the database. I turned it into an async method, kept a cheap stored total, and added a query-count test. **Result:** query count per request went back to [3], CPU returned to normal, and the team adopted a no-I/O-in-getters rule.

↩ Usually asked after

More in C# OOP Features

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me