C# properties: how they compile, init and required, the C# 14 field keyword, validation, computed properties, binary compatibility and property-versus-method guidelines.
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.
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.
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.
get, set and initget — 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.
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.
field keywordPreviously, 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.)
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.
| 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.
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.
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.
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]).
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).
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).get; auto-implemented with a custom set => field = ....[field: ...].field like other fields; you may need initialisers or required for non-nullable reference types.get => field ??= ComputeExpensive(); (not thread-safe — use Lazy<T> when it matters).required mechanicsThe 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.
System.Text.Json serialises public properties (fields only with IncludeFields); supports init, required, private setters with [JsonInclude]._name or <Name>k__BackingField; configurable with HasField and UsePropertyAccessMode). EF can bypass setters to avoid triggering validation when materialising.INotifyPropertyChanged), which source generators now implement via partial properties (C# 13).init).this[int index] compiles to get_Item/set_Item methods — same machinery with parameters.
init property and find the backing field and IsExternalInit marker.field keyword.required property and observe the exception.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);
}
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.
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.
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.
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.
required on essential DTO propertiesWhat 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.
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.
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.
What happens: properties call .Result on async methods.
Why it is wrong: blocking, deadlocks and thread-pool starvation.
Do instead: expose async methods.
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.
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.
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.
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.
Last updated 30 Oct 2026.