Properties wrap a field with getter/setter methods for controlled access.
Auto-properties compile to a hidden backing field; full properties expose it explicitly.
Use full properties when you need validation, logging, or change notification.
Init-only setters (C# 9) allow object initializer syntax but lock after construction.
Property getters can be computed — no backing field, always in sync.
Properties give binary compatibility; public fields lock you in forever.
Plain-English First
Imagine a hotel safe. You can put money in (set) and take money out (get), but the safe itself controls the rules — it won't let you enter a negative amount or withdraw more than you deposited. A C# property is exactly that safe: it looks like a simple field from the outside, but it secretly runs your rules every time someone reads or writes a value.
Every non-trivial C# codebase lives or dies by how well it protects its data. When a junior dev exposes a raw public field on a class, they're handing anyone a master key to the hotel safe — no logging, no validation, no way to add rules later without breaking every caller. Properties are the mechanism C# gives you to keep that door locked while still looking like an open shelf to the outside world.
The problem raw fields create is subtle at first and catastrophic later. Ship a public field today and six months from now you cannot add a validation rule, raise a change notification, or even set a breakpoint when the value changes — not without rewriting every caller. Properties solve this by wrapping the field in a controlled gateway. You get full binary compatibility even if you later add complex logic inside the getter or setter.
By the end of this article you'll understand why properties exist at a design level, know the difference between auto-properties and full backing-field properties, be able to write computed and init-only properties, validate input in setters, and spot the common property gotchas that trip up experienced developers in code reviews and interviews.
Full Properties vs Auto-Properties — Know What's Actually Happening
Before auto-properties existed (pre-C# 3.0), every property needed a manually declared private backing field. You wrote the field, wrote the getter, wrote the setter — three pieces of code to expose one value. Auto-properties collapsed that ceremony into a single line, but the compiler still generates the backing field for you behind the scenes. You just never see it.
The critical thing to understand is that auto-properties and full properties are not different features — they're the same feature at different levels of abstraction. Use an auto-property when the getter and setter have no logic. The moment you need to validate, log, clamp a range, or fire an event, you graduate to a full property with an explicit backing field.
One subtle trap: you cannot set a breakpoint inside an auto-property getter or setter because there's no body to break on. The moment you need to debug exactly when a property changes, you need the full form. That's a practical signal you've outgrown the auto-property for that field.
BankAccount.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
usingSystem;
publicclassBankAccount
{
// AUTO-PROPERTY: compiler generates a hidden backing field automatically.// Use this when you need no logic — just store and retrieve.publicstringAccountHolder { get; set; }
// FULL PROPERTY with explicit backing field.// We need this because depositing a negative amount must be rejected.
private decimal _balance; // backing field — private, never touched directly from outsidepublicdecimalBalance
{
get
{
// getter runs every time someone reads Balancereturn _balance;
}
set
{
// setter runs every time someone assigns to Balanceif (value < 0)
{
// reject bad data here rather than letting corruption spreadthrownewArgumentOutOfRangeException(
nameof(value),
"Balance cannot be negative. Use Withdraw() instead.");
}
_balance = value; // only store when the value is valid
}
}
publicBankAccount(string accountHolder, decimal openingBalance)
{
AccountHolder = accountHolder; // hits the auto-property setterBalance = openingBalance; // hits our validated setter — good from day one
}
}
classProgram
{
staticvoidMain()
{
var account = newBankAccount("Alice Chen", 500m);
Console.WriteLine($"Account: {account.AccountHolder}");
Console.WriteLine($"Balance: {account.Balance:C}");
account.Balance = 750m; // valid — setter stores the valueConsole.WriteLine($"After deposit adjustment: {account.Balance:C}");
try
{
account.Balance = -100m; // invalid — setter throws immediately
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine($"Caught: {ex.Message}");
}
}
}
Output
Account: Alice Chen
Balance: $500.00
After deposit adjustment: $750.00
Caught: Balance cannot be negative. Use Withdraw() instead. (Parameter 'value')
Pro Tip:
Name your backing field with a leading underscore (_balance) and the same camelCase as the property. This is the widely accepted C# convention and makes it instantly obvious in a large class which fields are backing stores vs standalone state.
Production Insight
In production, we once had a team member write '_balance = value' directly in a method, bypassing validation.
The bug was silent for weeks because the UI never showed negative balances, but reports pulled the raw field data.
Rule: always enforce property usage even inside the class — consider analyzers to detect direct field access.
Key Takeaway
Auto-property = zero logic, zero ceremony.
Full property = control, at the cost of verbosity.
Switch to full when you need breakpoints, validation, or events.
Computed Properties and Read-Only Properties — When There's No Backing Field at All
Not every property needs to store data. A computed property derives its value from other state every time it's read — there's no field, no storage, just a calculation. This is powerful because the value is always consistent with the underlying data; there's no risk of it going stale.
A read-only property (getter only, no setter) is your primary tool for exposing values that must never be changed from outside the class. You set the value in the constructor or field initializer and it stays there. This is fundamentally different from a public field marked readonly — a property can still have complex getter logic.
C# 9 introduced init-only setters, which are a middle ground: callers can set the value during object initialization (including object initializer syntax) but never again afterward. This is perfect for immutable data transfer objects where you still want the readable object-initializer syntax.
The expression-bodied syntax (using =>) is just shorthand for a getter-only property with a one-line body. It reads cleanly and is idiomatic modern C#. Use it freely for computed properties.
OrderSummary.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
usingSystem;
usingSystem.Collections.Generic;
publicclassOrderLine
{
// INIT-ONLY PROPERTY (C# 9+): can be set at object creation, never changed after.// Perfect for immutable value objects — keeps object-initializer syntax working.publicstringProductName { get; init; }
publicdecimalUnitPrice { get; init; }
publicintQuantity { get; init; }
// COMPUTED PROPERTY — no backing field. Calculated fresh on every read.// The 'get' is implicit with the '=>' expression-body syntax.publicdecimalLineTotal => UnitPrice * Quantity;
}
publicclassOrder
{
// GETTER-ONLY AUTO-PROPERTY: set in constructor, never exposed for external mutation.publicstringOrderId { get; }
publicDateTimePlacedAt { get; }
privatereadonlyList<OrderLine> _lines = new();
// READ-ONLY property exposing the collection safely.// We return IReadOnlyList so callers can iterate but not Add/Remove.publicIReadOnlyList<OrderLine> Lines => _lines;
// COMPUTED PROPERTY derived from child data — always in sync, zero maintenance.publicdecimalGrandTotal
{
get
{
decimal total = 0m;
foreach (var line in _lines)
total += line.LineTotal; // each LineTotal is itself computedreturn total;
}
}
publicOrder(string orderId)
{
OrderId = orderId; // only assignment allowed for getter-only propsPlacedAt = DateTime.UtcNow;
}
publicvoidAddLine(OrderLine line) => _lines.Add(line);
}
classProgram
{
staticvoidMain()
{
var order = newOrder("ORD-2024-001");
// Object initializer syntax works because of 'init' setters
order.AddLine(newOrderLine { ProductName = "Mechanical Keyboard", UnitPrice = 129.99m, Quantity = 1 });
order.AddLine(newOrderLine { ProductName = "USB-C Cable", UnitPrice = 12.49m, Quantity = 3 });
Console.WriteLine($"Order: {order.OrderId}");
Console.WriteLine($"Placed: {order.PlacedAt:u}");
Console.WriteLine();
foreach (var line in order.Lines)
{
// LineTotal computed on the fly — no stored value to go staleConsole.WriteLine($" {line.ProductName,-22} x{line.Quantity} = {line.LineTotal:C}");
}
Console.WriteLine($"\n Grand Total: {order.GrandTotal:C}");
// This would be a compile error — init-only cannot be changed after construction:// order.Lines[0].ProductName = "Mouse"; // CS8852
}
}
Output
Order: ORD-2024-001
Placed: 2024-07-15 09:23:41Z
Mechanical Keyboard x1 = $129.99
USB-C Cable x3 = $37.47
Grand Total: $167.46
Interview Gold:
Interviewers love asking 'What's the difference between a read-only property and a read-only field?' The answer: a read-only property can be overridden in a derived class and can contain computed logic; a read-only field is a raw memory slot that's fixed after the constructor. Properties also participate in interfaces and data binding — fields don't.
Production Insight
We once had a computed GrandTotal that iterated over a list that was being modified concurrently.
The getter returned inconsistent totals because it read a mix of old and new line items.
Rule: if a computed property depends on mutable state, synchronise access or recalculate atomically.
Key Takeaway
Computed properties = always consistent, never stale.
Init-only properties = immutable after construction, readable initializer syntax.
Use read-only properties for IDs and creation timestamps.
Property Access Modifiers and INotifyPropertyChanged — Real Patterns You'll Write at Work
Properties can have different access levels on their getter and setter independently. The most useful pattern is public get; private set; — advertise the value to the world, but reserve mutation for the class itself. This enforces encapsulation without hiding the data.
In UI frameworks (WPF, MAUI, Blazor) you'll constantly implement INotifyPropertyChanged. This interface requires you to fire a PropertyChanged event every time a property value changes so the UI can refresh. This is impossible to do cleanly with raw fields or auto-properties — you need a full property with a backing field so you can fire the event in the setter.
The pattern is worth memorizing because it illustrates exactly why properties exist: you're slipping extra behavior (event notification) into what looks like a simple assignment from the outside. The caller writes customer.Name = "Bob" and has no idea that the screen just updated. That transparency is the whole point.
CustomerViewModel.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
usingSystem;
using System.ComponentModel; // INotifyPropertyChanged lives hereusingSystem.Runtime.CompilerServices;
// A reusable base class — write this once, inherit everywhere in your MVVM layerpublicabstractclassObservableBase : INotifyPropertyChanged
{
publiceventPropertyChangedEventHandler? PropertyChanged;
// [CallerMemberName] automatically fills in the calling property's name.// Callers write: SetField(ref _name, value) — no magic strings needed.protectedboolSetField<T>(ref T backingField, T newValue,
[CallerMemberName] string propertyName = "")
{
// Skip the event if the value hasn't actually changed — avoids UI thrashif (EqualityComparer<T>.Default.Equals(backingField, newValue))
returnfalse;
backingField = newValue; // update the backing field firstPropertyChanged?.Invoke(this, newPropertyChangedEventArgs(propertyName));
return true; // return true so callers know a change actually happened
}
}
publicclassCustomerViewModel : ObservableBase
{
privatestring _fullName = string.Empty;
privateint _loyaltyPoints;
privatebool _isPremiumMember;
publicstringFullName
{
get => _fullName;
set => SetField(ref _fullName, value); // fires PropertyChanged automatically
}
publicintLoyaltyPoints
{
get => _loyaltyPoints;
set
{
if (value < 0)
thrownewArgumentOutOfRangeException(nameof(value), "Points cannot be negative.");
SetField(ref _loyaltyPoints, value);
// Changing points might flip premium status — update that property tooIsPremiumMember = value >= 1000;
}
}
// Private setter: the class controls this, callers just observe itpublicboolIsPremiumMember
{
get => _isPremiumMember;
private set => SetField(ref _isPremiumMember, value);
}
// Computed — no setter at all. Always derived, always correct.publicstringMembershipLabel => IsPremiumMember ? "⭐ Premium" : "Standard";
}
classProgram
{
staticvoidMain()
{
var customer = newCustomerViewModel();
// Subscribe to the event — in a real UI framework, the data binding does this
customer.PropertyChanged += (sender, e) =>
Console.WriteLine($" [UI notified] '{e.PropertyName}' changed");
Console.WriteLine("--- Setting name ---");
customer.FullName = "Jordan Rivera";
Console.WriteLine("\n--- Adding 500 points (stays Standard) ---");
customer.LoyaltyPoints = 500;
Console.WriteLine($" Status: {customer.MembershipLabel}");
Console.WriteLine("\n--- Adding 600 more points (becomes Premium) ---");
customer.LoyaltyPoints = 1100;
Console.WriteLine($" Status: {customer.MembershipLabel}");
Console.WriteLine("\n--- Setting same value again (no event fired) ---");
customer.FullName = "JordanRivera"; // SetField skips because value is unchangedConsole.WriteLine($" Name: {customer.FullName}");
}
}
Output
--- Setting name ---
[UI notified] 'FullName' changed
--- Adding 500 points (stays Standard) ---
[UI notified] 'LoyaltyPoints' changed
Status: Standard
--- Adding 600 more points (becomes Premium) ---
[UI notified] 'LoyaltyPoints' changed
[UI notified] 'IsPremiumMember' changed
Status: ⭐ Premium
--- Setting same value again (no event fired) ---
Name: Jordan Rivera
Watch Out:
Never call virtual methods or fire events in a constructor. If a derived class overrides a property that your base constructor reads, the derived constructor hasn't run yet and the property can return garbage. Initialize properties with safe defaults at declaration instead and let the caller configure the object afterward.
Production Insight
Our team had a bug where PropertyChanged wasn't firing for IsPremiumMember because we used a private setter without calling SetField.
We assigned _isPremiumMember = value directly in the setter of LoyaltyPoints.
Rule: always go through the property's own setter, even within the same class.
Key Takeaway
INotifyPropertyChanged requires full properties.
Use the SetField pattern to avoid magic strings and reduce boilerplate.
Private setters keep mutation local while still allowing data binding.
Property Validation Patterns — Throwing vs Clamping vs Setting a Flag
When a property receives an invalid value, you have three choices: throw an exception, clamp the value to an acceptable range, or set an error flag. Each has its place.
Throwing is the right default for most business rules — a negative account balance should crash the operation, not silently fix itself. Clamping (e.g., set to 0 if value < 0) is useful for UI slider properties where the user expects the value to stay within bounds. Setting an error flag (e.g., an IsValid or an error dictionary) is common in form validation where you want to show multiple errors at once.
The key is consistency: if you throw in one property, throw in all. Mixing styles confuses callers. Also, never throw from a property getter — it breaks debugging and serialization. Keep validation in the setter only.
TemperatureSensor.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
usingSystem;
usingSystem.ComponentModel;
usingSystem.Collections.Generic;
publicclassTemperatureSensor : INotifyDataErrorInfo
{
privatedouble _celsius;
privatereadonlyDictionary<string, string> _errors = new();
// Pattern 1: Throw on invalid value (for critical constraints)publicdoubleCelsius
{
get => _celsius;
set
{
if (value < -273.15)
thrownewArgumentOutOfRangeException(
nameof(value), "Temperature cannot be below absolute zero.");
_celsius = value;
}
}
// Pattern 2: Clamp to acceptable range (for UI sliders)privatedouble _humidity;
publicdoubleHumidity
{
get => _humidity;
set
{
var clamped = Math.Clamp(value, 0, 100);
if (clamped != value)
{
// log the clamping for debuggingConsole.WriteLine($"Humidity clamped from {value} to {clamped}");
}
_humidity = clamped;
}
}
// Pattern 3: Set an error flag (for form validation with multiple errors)privatestring _location = string.Empty;
publicstringLocation
{
get => _location;
set
{
if (string.IsNullOrWhiteSpace(value))
{
_errors[nameof(Location)] = "Location is required.";
ErrorsChanged?.Invoke(this, newDataErrorsChangedEventArgs(nameof(Location)));
}
else
{
_errors.Remove(nameof(Location));
}
_location = value;
}
}
// INotifyDataErrorInfo memberspublicboolHasErrors => _errors.Count > 0;
publiceventEventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
publicSystem.Collections.IEnumerableGetErrors(string? propertyName) =>
propertyName != null && _errors.TryGetValue(propertyName, outvar err) ? new[] { err } : Array.Empty<string>();
}
classProgram
{
staticvoidMain()
{
var sensor = newTemperatureSensor();
try { sensor.Celsius = -300; }
catch (ArgumentOutOfRangeException ex) { Console.WriteLine($"Caught: {ex.Message}"); }
sensor.Humidity = 150; // clamped to 100Console.WriteLine($"Humidity after clamp: {sensor.Humidity}");
sensor.Location = "";
Console.WriteLine($"Location errors: {sensor.HasErrors}");
}
}
Output
Humidity clamped from 150 to 100
Caught: Temperature cannot be below absolute zero. (Parameter 'value')
Humidity after clamp: 100
Location errors: True
Mental Model: Guard, Clamp, or Collect
Throw: for business rules that must never be violated. Stops the flow immediately.
Clamp: for UI or numeric ranges where a reasonable fallback exists. Always log it.
Collect: for form validation where the user needs to see all errors at once before resubmitting.
Production Insight
A fintech app we built used clamping for account balances instead of throwing. Users could accidentally set negative balances that were silently rounded to zero. The rounding hid the real transaction failures and made the reconciliation a nightmare.
Rule: business-critical properties must throw — silence hides problems.
Key Takeaway
Choose validation style by context: throw for correctness, clamp for UX, collect for forms.
Never mix styles in the same class — it confuses callers.
Never throw from a getter; only setters should validate.
Properties in Interfaces and Abstract Classes — Contracts Enforced by Design
Interfaces can declare properties, but they cannot contain implementation. An interface property is just a contract: string Name { get; set; }. Any class implementing that interface must provide the getter and setter. This gives you polymorphic access to properties without coupling to a concrete type.
Abstract classes can declare abstract properties (same contract, no body) or virtual properties (with default behavior that derived classes can override). Virtual properties are powerful because you can add logging or validation to a base property while letting derived classes specialize.
One nuance: interfaces can define get only, set only, or both. In C# 8+, interfaces can also provide a default implementation, but that's rarely needed. Stick to abstract properties for shared base logic.
The real win is testability: you can mock an interface with properties, making it easy to substitute dependencies in unit tests without concrete classes.
IAccount.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
usingSystem;
// Interface property contractpublicinterfaceIAccount
{
stringAccountId { get; }
decimalBalance { get; }
voidDeposit(decimal amount);
voidWithdraw(decimal amount);
}
// Abstract base class with both abstract and virtual propertiespublicabstractclassAccountBase : IAccount
{
publicabstractstringAccountId { get; }
publicabstractdecimalBalance { get; }
// Virtual property with default implementationpublicvirtualstringStatus => "Active";
publicabstractvoidDeposit(decimal amount);
publicabstractvoidWithdraw(decimal amount);
}
// Concrete implementationpublicclassSavingsAccount : AccountBase
{
privatedecimal _balance;
publicoverridestringAccountId { get; }
publicoverridedecimalBalance
{
get => _balance;
// No setter — balance changes only through Deposit/Withdraw
}
publicoverridevoidDeposit(decimal amount)
{
if (amount <= 0) thrownewArgumentException("Deposit amount must be positive.");
_balance += amount;
}
publicoverridevoidWithdraw(decimal amount)
{
if (amount <= 0) thrownewArgumentException("Withdrawal amount must be positive.");
if (amount > _balance) thrownewInvalidOperationException("Insufficient funds.");
_balance -= amount;
}
publicSavingsAccount(string accountId)
{
AccountId = accountId;
}
}
classProgram
{
staticvoidMain()
{
IAccount account = newSavingsAccount("SAV-123");
account.Deposit(500);
Console.WriteLine($"Account {account.AccountId}: Balance = {account.Balance:C}");
account.Withdraw(200);
Console.WriteLine($"After withdrawal: {account.Balance:C}");
}
}
Output
Account SAV-123: Balance = $500.00
After withdrawal: $300.00
Interface Properties Best Practice:
Keep interface properties simple — getters only or both get/set. Avoid throwing exceptions in interface implementations from property accessors; prefer methods for operations that can fail. This keeps properties predictable and side-effect-free.
Production Insight
We had a base class with a virtual Name property that called a DB lookup in the getter. Derived classes didn't override it. Every test that accessed Name hit the database. Performance tanked.
Rule: keep virtual property getters cheap — if expensive computation is needed, make it a method or use lazy caching with a clear intent.
Key Takeaway
Interface properties enable polymorphism and testability.
Abstract properties force derived classes to implement the contract.
Virtual properties allow base behavior with override hooks.
Keep property getters free of side effects.
● Production incidentPOST-MORTEMseverity: high
The Silent Data Corruption: When Team Members Bypass the Property Setter
Symptom
Customer support reports inconsistent account balances. Some accounts have negative balances even though the Balance property throws on negative values. The UI shows correct values after an edit, but reports show old, invalid data.
Assumption
The validation in the setter is working—the UI never allowed negative values, so the database must have been corrupted by a different system.
Root cause
A team member added a method inside the BankAccount class that wrote directly to _balance = value instead of using Balance = value. The setter's validation logic (checking value < 0) was completely bypassed. The bug existed for three months before discovery.
Fix
1. Change all direct backing field assignments in the class to go through the property setter.
2. Mark the backing field as readonly after removal? Not an option because setter must write. Instead, add a code analysis rule (Roslyn analyzer) to forbid direct access to backing fields outside the property body.
3. Add a debug assertion in every public method that sets the balance to ensure it matches the property value.
Key lesson
Never access backing fields directly outside the property getter/setter. Use properties even within the owning class.
Enable Roslyn analyzer CA1819 (or custom) to flag backing field usage outside property definitions.
Consider using private backing fields with no direct access helpers — or use auto-properties whenever possible to eliminate the temptation.
Production debug guideSymptom-to-action guide for property-related bugs4 entries
Symptom · 01
Property setter not called when assigning a value
→
Fix
Check if you're using an auto-property or a full property. If auto, the setter is implicit and you cannot put breakpoints. Convert to a full property with a backing field and add logging. Also check if the calling code is writing to the field directly instead of the property.
Symptom · 02
INotifyPropertyChanged not firing on UI update
→
Fix
Verify the setter actually invokes PropertyChanged?.Invoke(...). Common mistake: only raising when value changes, but the UI expects an event on every assignment. Also check if the backing field is being read directly in the getter — the getter must return the backing field, not another source.
Symptom · 03
Exception thrown in setter but not caught gracefully
→
Fix
Setters that throw validation errors must be called from try-catch blocks. In data-binding contexts (WPF/MAUI), an unhandled exception can crash the app. Wrap assignments in try-catch inside view model code. Alternatively, use a validation pattern (like FluentValidation) and set an error flag instead of throwing.
Symptom · 04
Computed property returns stale data
→
Fix
Computed properties have no backing store — they calculate every time. If the result appears stale, the underlying dependencies aren't triggering a refresh. For UI bindings, the computed property must raise PropertyChanged manually when its dependent properties change, or use CallerMemberName to propagate changes.
★ Property Bug Cheat SheetFive common property failures and their immediate fixes
Backing field assigned directly, bypassing validation−
Immediate action
Search all files for `_fieldName =` outside the property body. Replace with `PropertyName =`.
Commands
grep -r "_balance =" . --include="*.cs"
Add a Roslyn rule: IDE0044 or custom analyzer to flag backing field access outside property.
Fix now
Temporarily change the backing field to readonly (if not requiring setter) or add [Obsolete] on the field.
Property getter performing heavy computation every call+
Immediate action
Profile how often the getter is called. Cache the result using Lazy<T> or manual caching with a dirty flag.
Commands
Add a quick console log: `Console.WriteLine($"{nameof(ComputedValue)} getter called");`
Wrap with `Stopwatch` to measure duration.
Fix now
Replace with Lazy<T> initialized in constructor, or compute once and store in a backing field.
Init-only property still modifiable after construction+
Immediate action
Check if the property is declared with `init` or `set`? If `set`, callers can write after construction. Verify the accessor is `init`.
Commands
Search for `{ set; }` in the class. Replace with `init` if immutability is required.
Add a unit test that attempts to modify the property after object creation and expects a compile error.
Fix now
Temporarily change the setter to throw InvalidOperationException if called after initialization.
PropertyChanged event not raised for computed property+
Immediate action
Ensure the computed property's setter (if any) fires the event. For getter-only computed props, you need to fire the event manually when dependent properties change.
Commands
Add PropertyChanged in the dependent setters: `PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ComputedProp)));`
Use a helper like `SetField` that returns bool to chain notifications.
Fix now
Temporarily make the computed property a full property with a backing field that gets updated in each dependent setter.
Auto-Property vs Full Property — Quick Comparison
Aspect
Auto-Property
Full Property (Backing Field)
Syntax verbosity
Minimal — one line
Verbose — field + getter + setter bodies
Validation in setter
Not possible
Yes — throw, clamp, log freely
Firing change events
Not possible
Yes — INotifyPropertyChanged pattern
Setting a debugger breakpoint
Cannot — no body to break on
Yes — put it anywhere in get/set body
Thread-safe lazy init
Not possible without extra field
Yes — use Lazy<T> or double-check lock in getter
Init-only (C# 9)
{ get; init; } supported
Supported — mark setter with 'init' keyword
Binary compatibility
Yes — adding logic later is safe
Yes — callers recompile cleanly
Expression-body shorthand
N/A
get => _field; works for simple getters
Suitable for interfaces
Yes
Yes
Suitable for data binding (WPF/MAUI)
Only for one-time set values
Yes — required for two-way binding
Key takeaways
1
An auto-property and a full property compile to the same IL
the difference is only how much control you need; graduate to a full property the moment you need validation, logging, or events.
2
Properties give you binary compatibility
you can add a getter/setter body later without forcing callers to recompile, something impossible with public fields.
3
Computed properties (expression-body or getter-only) guarantee consistency
derived values can never go stale because there's nothing to synchronize.
4
The 'init' accessor (C# 9) is the sweet spot for immutable objects that still need readable object-initializer syntax
use it in DTOs and record-like classes.
Common mistakes to avoid
5 patterns
×
Calling the backing field directly inside the class instead of going through the property
Symptom
Validation and change events in the setter are silently bypassed, causing corrupted state or a frozen UI.
Fix
Always use 'this.Balance = value' or just 'Balance = value' inside the class, not '_balance = value', unless you're inside the property itself.
×
Using a public field when you meant a public property
Symptom
You later need to add logging or validation, but you can't without breaking binary compatibility; assemblies that reference the class must be fully recompiled.
Fix
Make the habit of always declaring public state as a property from day one, even if it's a trivial auto-property. The cost is zero, the payoff is huge.
×
Infinite recursion by naming the backing field the same as the property
Symptom
A StackOverflowException at runtime with no obvious cause. This happens when you write 'public string Name { get => Name; set => Name = value; }' — the property calls itself.
Fix
Always prefix the backing field with an underscore (_name) so the two identifiers are visually and literally distinct.
×
Exposing mutable collections via auto-properties
Symptom
Callers can add/remove items directly, bypassing any validation or events you intended.
Fix
Expose the collection as IReadOnlyList<T> or return a copy, and provide controlled Add/Remove methods.
×
Using virtual properties in constructors
Symptom
A derived class's property override is called before the derived constructor runs, leading to null references or default values.
Fix
Avoid calling virtual members in constructors. If you must set a property, use a helper method that can be overridden safely, or use dependency injection.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What is the difference between a property and a field in C#, and why sho...
Q02SENIOR
Can you explain what 'init' accessors are in C# 9 and give a scenario wh...
Q03SENIOR
If you have a class that implements INotifyPropertyChanged, why can't yo...
Q01 of 03JUNIOR
What is the difference between a property and a field in C#, and why should you prefer properties for public members?
ANSWER
A field is a raw memory slot — no logic, no protection, no hooks. A property wraps that slot in getter and setter methods, allowing you to add validation, logging, or change notifications without changing the public API. Properties also support binary compatibility: you can add body logic later without forcing callers to recompile. Interfaces can declare properties but not fields, and data-binding frameworks (WPF/MAUI) require properties.
Q02 of 03SENIOR
Can you explain what 'init' accessors are in C# 9 and give a scenario where you'd choose 'init' over a private setter?
ANSWER
An 'init' accessor allows a property to be set only during object initialization (constructor, object initializer, or via with expression). After construction, the property becomes effectively read-only. You'd choose 'init' over a private setter when you want immutable objects but still want to use object-initializer syntax (e.g., for DTOs, records, or configuration objects). Private setters allow mutation within the class, while 'init' locks the value permanently after construction, which is better for true immutability.
Q03 of 03SENIOR
If you have a class that implements INotifyPropertyChanged, why can't you use an auto-property for the notifying properties, and what pattern do you use instead?
ANSWER
Auto-properties have no body, so you cannot insert the PropertyChanged.Invoke call. You need a full property with a backing field and explicit setter logic. The common pattern is to write a helper method like SetField<T>(ref T backingField, T newValue, [CallerMemberName] string propertyName = "") that updates the backing field and fires the event, then call it from each property setter. This avoids code duplication and eliminates magic strings via the [CallerMemberName] attribute.
01
What is the difference between a property and a field in C#, and why should you prefer properties for public members?
JUNIOR
02
Can you explain what 'init' accessors are in C# 9 and give a scenario where you'd choose 'init' over a private setter?
SENIOR
03
If you have a class that implements INotifyPropertyChanged, why can't you use an auto-property for the notifying properties, and what pattern do you use instead?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What is the difference between a C# property and a field?
A field is a raw memory slot on the object — no logic, no protection, no hooks. A property wraps that slot in getter and setter methods, letting you add validation, logging, or change notifications without changing the public API. Interfaces can declare properties but not fields, and data-binding frameworks in WPF or MAUI require properties to work.
Was this helpful?
02
When should I use an auto-property vs a property with a backing field in C#?
Use an auto-property when you genuinely need nothing more than store-and-retrieve behavior. Switch to a full property with a backing field the moment you need to validate the incoming value, fire a PropertyChanged event, log access, or return a cached/computed value. You can always upgrade from auto to full without breaking callers.
Was this helpful?
03
Is a getter-only property the same as a read-only field in C#?
No. A read-only field is a fixed memory slot sealed after the constructor runs. A getter-only property can compute its return value on every call, be overridden in derived classes, and be declared in an interface. They look similar from the caller's perspective but are architecturally different — properties are far more flexible.
Was this helpful?
04
Can I use a property in an interface?
Yes, interfaces can declare properties (getter, setter, or both). The implementing class must provide the implementation. This allows polymorphic access to properties without coupling to a concrete type.
Was this helpful?
05
Why would I use an init-only property instead of a read-only property initialized in the constructor?
Init-only properties (C# 9) allow you to set values using object-initializer syntax (e.g., new Person { Name = "Alice" }), while still being immutable after construction. Read-only properties can only be set in the constructor, which forces you to pass all values through constructor parameters. Init-only is more flexible for DTOs and configuration objects.