Properties

Understand C# properties, accessors, auto-properties, init, and required, including validation, mutability, and ownership traps.

level intermediate time 10 min at Standard depth
version C# 14 / .NET 10
what

A C# property exposes data with field-like syntax while get, set, or init accessors enforce its read and assignment contract.

trap

init doesn’t provide deep immutability, and required isn’t runtime validation; a collection getter can still leak mutable state.

fix

Use auto-properties for simple storage, implement accessors for validation, and make mutation, nullability, and ownership testable contracts.

What it is and why it exists

A property is a member of a class, struct, or interface. Callers use field-like syntax such as order.Total or order.Status = value, while the declaring type controls reads and assignments through accessors. A property isn’t necessarily a field: it can return stored data, compute a result, or reject an assignment.

Properties resolve the tension between exposing data and retaining control over it. Once a public field becomes part of an API, its declaring type can’t add validation, computation, or stricter write access at the same access point. Paired GetName() and SetName() methods, meanwhile, make simple data access cumbersome. A property puts those control points behind stable member syntax.

You’ll encounter properties in domain objects, configuration types, DTOs, serialization models, and UI binding objects. Similar call syntax doesn’t imply a similar contract: an auto-property usually stores a value, a computed property evaluates on every read, and a custom accessor can run arbitrary code. Review the declaration, not the call syntax, to determine behavior.

A property should describe a characteristic an object has at a particular moment. A network request, database query, long computation, or visible state transition usually belongs in a method because a method call makes cost and action more apparent.

How it works

Accessors define read and write capabilities

A property accessor is the get, set, or init part of a property. Reading executes get, ordinary assignment executes set, and init permits assignment only at valid locations during object construction. The implicit value parameter in set and init is the new value supplied by the caller.

Accessor combinations form the public contract directly. A property with only get is read-only to callers; get plus set permits repeated assignment; get plus init permits construction-time assignment and rejects later ordinary assignment. C# also permits write-only properties, but APIs rarely need a shape whose state callers can’t read back.

A property and its accessors can also have different accessibility. public decimal Balance { get; private set; } lets every caller read while only the declaring type can modify it. A stricter access modifier can appear on only one accessor, and the property must have both a read and a write accessor.

DeclarationExternal readExternal ordinary assignmentObject-initializer assignment
{ get; set; }YesYesYes
{ get; private set; }YesNoNo
{ get; init; }YesNoYes
{ get; }YesNoNo
=> expressionYesNoNo

Stored and computed properties

An auto-property omits accessor bodies, as in public string Name { get; set; }. The compiler creates a hidden backing field and generates accessors that read and write that storage. A property initializer assigns the storage during construction.

When you need control logic, declare a backing field explicitly and reference it from the accessors. This works well for range checks, input normalization, and simple invariants. Validate first and then write the field; otherwise, an exception can leave the object partially updated.

A computed property doesn’t need storage of its own. public decimal Total => UnitPrice * Quantity; evaluates from current state on every read, so no separate cache can become stale. If computation is expensive or requires I/O, use a clearly named method or design a cache with explicit invalidation rules.

C# 14’s field contextual keyword provides a third shape. An accessor can use field to reach the compiler-synthesized backing field while another accessor remains auto-implemented. This removes the boilerplate of declaring a field solely for a small amount of validation logic.

init and required answer different questions

An init accessor restricts where a property can be assigned. Callers can set it in an object initializer, and construction code can establish initial state; after the construction phase, ordinary code can’t assign it again. This restricts property assignment but doesn’t freeze the object referenced by the property.

A required member makes an object-creation expression initialize a field or property unless the chosen constructor declares that it satisfies all required members. required doesn’t decide whether later mutation is allowed, so it can pair with either set or init. It also doesn’t validate a string’s nullness, a number’s range, or relationships between fields.

Non-nullable reference types, required, and runtime validation each handle a different layer. A non-nullable annotation participates in compiler null-state analysis, required checks whether object-creation syntax omitted a member, and an accessor or constructor enforces actual runtime invariants. Robust boundary types often need all three rather than a choice among them.

Field syntax hides method calls

After compilation, reading and writing a property correspond to specially named methods, commonly visible in metadata as get_Name and set_Name. Reflection exposes the property as property metadata and can also retrieve those accessors. Callers still write customer.Name; the compiler binds that syntax to accessor calls.

This is why properties can appear in interfaces and be declared virtual or abstract. An implementation or override supplies access behavior; it doesn’t require every type to have a field with that name. It also explains why a debugger that evaluates a property can execute user code.

Examples

Start with auto-properties

The first example leaves simple state to auto-properties, expresses a derived value as a computed property, and puts inventory mutation in a method. private set prevents callers from bypassing TrySell to replace the stock directly.

ProductInventory.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;

var item = new InventoryItem(openingStock: 3)
{
    Name = "Keyboard",
    UnitPrice = 79.90m
};

Console.WriteLine($"{item.Name}: {item.Stock} units, {item.InventoryValue:F2}");
Console.WriteLine($"sold: {item.TrySell(2)}");
Console.WriteLine($"remaining: {item.Stock}");

public sealed class InventoryItem
{
    public InventoryItem(int openingStock)
    {
        if (openingStock < 0)
            throw new ArgumentOutOfRangeException(nameof(openingStock));
        Stock = openingStock;
    }

    public required string Name { get; init; }
    public decimal UnitPrice { get; init; }
    public int Stock { get; private set; }
    public decimal InventoryValue => UnitPrice * Stock;

    public bool TrySell(int quantity)
    {
        if (quantity <= 0 || quantity > Stock)
            return false;
        Stock -= quantity;
        return true;
    }
}
# not executed here: the .NET SDK and C# compilers are unavailable

Name must appear in the creation expression, and ordinary assignment can’t change it afterward. UnitPrice isn’t required, so omitting it still compiles and produces decimal’s default value, 0. If a real domain rejects a zero price, it needs a construction or validation contract too.

InventoryValue always computes from the current Stock. The stock has a private setter, but TrySell can still use compound assignment inside the type. The method centralizes the quantity range and uses a return value for an expected rejection.

Validate assignments with field

The second example uses C# 14 field-backed properties. Callers still see ordinary properties, while init and a private set normalize the name and protect the points-mutation entry point.

ValidatedProfile.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;

var profile = new CustomerProfile
{
    DisplayName = "  Ada  "
};

profile.AddPoints(25);
Console.WriteLine($"{profile.DisplayName}: {profile.LoyaltyPoints}");

try
{
    profile.AddPoints(-1);
}
catch (ArgumentOutOfRangeException)
{
    Console.WriteLine("negative points rejected");
}

public sealed class CustomerProfile
{
    public required string DisplayName
    {
        get;
        init => field = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Display name is required")
            : value.Trim();
    }

    public int LoyaltyPoints
    {
        get;
        private set => field = value >= 0
            ? value
            : throw new ArgumentOutOfRangeException(nameof(value));
    }

    public void AddPoints(int points) => LoyaltyPoints += points;
}
# not executed here: the .NET SDK and C# compilers are unavailable

required on DisplayName checks that creation code didn’t omit the member. Its init body separately rejects whitespace and stores the trimmed value. Neither constraint replaces the other. Even an explicit assignment of null satisfies the required-member condition, so runtime validation still matters.

AddPoints writes the result back to LoyaltyPoints, so the private setter validates the final sum. This call with a negative value is rejected because the result falls below zero. If the business rule forbids a negative points argument itself, the method should check that separately at entry.

Protect collection ownership

The third example keeps its list inside the object and exposes only a read-only wrapper. A read-only property doesn’t make an object deeply immutable, so the type still controls collection changes through a method.

PurchaseOrder.cs
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;

var order = new PurchaseOrder { Number = "PO-42" };
order.AddLine(new OrderLine("Keyboard", 2, 79.90m));
order.AddLine(new OrderLine("Cable", 1, 12.50m));

Console.WriteLine($"{order.Number}: {order.Lines.Count} lines");
Console.WriteLine($"total: {order.Total:F2}");

public sealed class PurchaseOrder
{
    private readonly List<OrderLine> _lines = [];

    public PurchaseOrder()
    {
        Lines = _lines.AsReadOnly();
    }

    public required string Number { get; init; }
    public ReadOnlyCollection<OrderLine> Lines { get; }
    public decimal Total => _lines.Sum(line => line.Quantity * line.UnitPrice);

    public void AddLine(OrderLine line)
    {
        ArgumentNullException.ThrowIfNull(line);
        if (line.Quantity <= 0 || line.UnitPrice < 0)
            throw new ArgumentOutOfRangeException(nameof(line));
        _lines.Add(line);
    }
}

public sealed record OrderLine(string Product, int Quantity, decimal UnitPrice);
# not executed here: the .NET SDK and C# compilers are unavailable

Lines has no setter, so callers can’t replace the wrapper. ReadOnlyCollection<T> also exposes no add or remove operation. When the internal list changes, the wrapper reflects the latest contents; that is this API’s chosen live-view behavior.

The elements are immutable records, preventing a caller from retrieving a line and quietly changing its quantity. If elements were mutable, a read-only collection would protect only the collection structure, not element state. That ownership decision must be explicit in the type’s contract.

Pitfalls

An accessor recursively calls itself

Fix: write an explicit backing field, or use field in C# 14. Search accessor bodies for the property’s own name and add a minimal read-write test. Don’t treat this failure as an ordinary validation exception that should be caught.

A getter hides I/O or state changes

Fix: use a method such as LoadReportAsync() for work with visible cost, failure modes, or side effects. Keep computed properties predictable. If caching is necessary, state its owner, threading behavior, and invalidation conditions.

init or a private setter is mistaken for deep immutability

Fix: expose immutable collections, read-only wrappers, or snapshots, and control the mutability of the elements themselves. Review the full object graph reachable from a property, not just the setter’s accessibility.

required is mistaken for a validator

Fix: enforce null, format, and cross-field checks inside a trusted boundary. Treat required as a caller-experience and compile-time completeness tool, not as runtime data validation or a security boundary.

State changes before validation finishes

Fix: validate and normalize a local candidate before committing it to a field. Use a method or factory for atomic transitions involving several members, checking the complete candidate state before mutating the object.

Writable properties destabilize hash identity

Fix: define key identity with stable, immutable values, or remove the object before mutation and add it again afterward. For records, also inspect whether properties participating in value equality refer to mutable objects.

Deep The metadata shape of a property

The metadata shape of a property

C# source presents a property as one member, but the Common Language Runtime executes it through accessor methods. A normal instance getter takes no parameters and returns the property type; a setter takes one parameter of that type and returns void. Property metadata associates those methods so reflection, serializers, and binding frameworks can treat them as one logical property.

An auto-property’s backing field is an implementation detail. Its name, attributes, and existence shouldn’t become an application contract. Reflection code that relies on a compiler-generated field name can easily break when an auto-property becomes an explicitly backed property. Bind persistence and transport to public properties or explicit contracts rather than scanning compiler-generated fields.

Property calls also participate in ordinary member rules. A static property has no instance receiver, a virtual property dispatches according to runtime type, and an interface property requires an implementation to supply compatible accessors. Fields have none of that polymorphic behavior, so describing a property as mere “syntax sugar for a public field” misses important semantics.

Compound assignment still reads and writes

account.Balance += amount isn’t a direct mutation of a public storage location. Conceptually, it invokes the getter for the old value, computes a new value, and then invokes the setter to write it back. Logic in either accessor can run and throw.

That is especially risky with side-effecting accessors. One apparently single compound assignment triggers both a read and a write, and threads can interleave between those steps. Property access itself provides no atomicity. Shared counters need a lock, Interlocked, or a higher-level synchronization contract rather than reliance on the brevity of Count++.

A write-only property can’t participate in compound assignment because the operation must read the old value first. A get-only property can’t receive the final write. The compiler checks these accessor capabilities at the call site.

Field-backed properties in C# 14

field is a contextual keyword inside a property accessor and denotes the compiler-synthesized backing field for that property. It lets one accessor remain auto-implemented while another adds validation. For example, the getter can be a semicolon while init normalizes input before writing field.

field doesn’t make several properties share storage; every field-backed property has its own synthesized field. Nor is it a public name that can be passed to another API. Callers still go through the property. An explicit field is often clearer when several properties coordinate around one value or maintain a cross-member invariant.

Existing members can genuinely be named field. Inside an accessor scope, write @field to refer to such a member, or the new keyword may change name resolution. Generated code containing that name deserves a focused check after an upgrade to C# 14.

Field-backed properties remove boilerplate without changing accessor-design rules. Validation should still finish before mutation, getters still shouldn’t hide expensive work, and exception and threading behavior remain part of the API contract. Shorter syntax doesn’t replace behavioral review.

The boundaries of initialization contracts

The compiler enforces init at assignment sites. It provides a shallow assignment restriction: a referenced list, dictionary, or ordinary class remains mutable through its own API. A truly immutable object graph needs immutable member types, or defensive copies on input and immutable views on output.

required is also primarily a compile-time protocol. A C# creation expression must set all visible required members, but explicitly assigning a default value still counts as setting one. Nullability analysis may separately warn about null; the two diagnostics describe different problems.

A constructor marked SetsRequiredMembers tells the compiler that it initializes every required member. The compiler doesn’t verify that promise, making the attribute an escape hatch that needs review. When generated code adds it mechanically, omitted members disappear from diagnostics at call sites.

Reflection and some object-construction infrastructure don’t necessarily use ordinary C# creation expressions, so required isn’t a deserialization guarantee. Run end-to-end tests against the serializer and configuration actually in use, and validate invariants as data enters the trusted model. Framework support changes with versions and options; a type design can’t infer binding behavior from one keyword.

Inheritance expands the required-member set

A derived type inherits required members from its base type and can add its own. Code creating the derived instance must satisfy the complete set for the final type. Overriding a required property can’t remove its required status.

This affects factories and generic code with a new() constraint. A type with required members can’t be used directly as the type argument when generic code relies on parameterless new() because the generic creation site can’t supply an object initializer. A factory must accept initialization data or the type must provide a construction path that establishes the contract explicitly.

Adding required during API evolution also creates new work for callers that recompile. It might not alter an existing accessor’s binary signature, but it changes source-level construction requirements. Public libraries should evaluate it as a caller-contract change.

Properties and object invariants

Single-property validation can see only one assignment. If Start must precede End, exposing two separate setters makes assignment order determine the temporary state and can make either order fail first. A constructor, factory, or ChangeWindow(start, end) method can validate the entire candidate pair before committing both values.

Normalization is part of the contract too. A setter that trims a name or changes its case can return text different from what the caller assigned. That may be correct, but it needs tests and documentation. Silently normalizing passwords, tokens, or signature inputs commonly corrupts data, so domain requirements must decide the policy.

After a property throws, the object should generally retain its old state. Compute and validate a local candidate before updating the backing field to give a single-property change a clear commit point. When several fields change, establish the complete new state first, then perform the assignments that can’t fail.

Observable invariants also include equality, hashing, and ordering. If those behaviors depend on writable properties, mutation affects collection placement, cache keys, or deduplication. Identity properties should normally remain stable after construction, while state properties shouldn’t quietly participate in identity.

Accessibility and overrides preserve contracts

Accessor accessibility determines what callers can do; it isn’t just a documentation hint. A private set on a public property isn’t an external assignment candidate, so caller code fails to compile. That is more reliable than a “do not modify” comment, but the declaring type still owns every internal write path.

An override of a virtual property must preserve the accessor shape allowed by the base contract. When code reads through a base-class reference, runtime dispatch invokes the derived implementation. A derived getter therefore shouldn’t unexpectedly add I/O, side effects, or weaker invariants that violate base-class expectations. Review polymorphic properties with the same substitutability care as polymorphic methods.

Hiding an inherited property differs from overriding it. After a derived type declares a same-named property with new, access through the base static type still selects the base property, while access through the derived static type selects the new one. Presenting two sets of state on one object depending on the reference type is usually harder to maintain than a deliberate override or a different name.

An interface specifies an accessor shape, not a backing field. One implementation can store a value while another computes it, but both should satisfy the interface’s semantic expectations for cost, failure, and mutability. When callers depend on those expectations, document them on the interface and cover them with contract tests.

Attributes can target different artifacts

An attribute on a property declaration targets the property metadata by default; it doesn’t automatically target the compiler-generated backing field. Use the field: attribute target when an auto-property’s storage needs the annotation. Choose the corresponding target when an accessor’s return value or method needs it instead.

This distinction affects serializers, validators, and reflection tools because different tools inspect different metadata locations. Before adding an attribute, confirm whether its consumer reads properties, fields, or accessors. Source-code proximity alone doesn’t determine the target.

An integration test can have the real consumer read a minimal model and confirm that the attribute landed on the intended metadata target.

Further reading

checkpoint

4 questions · 1 predict-the-output · 2 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?