Senior 4 min · March 06, 2026

C# Properties — Silent Data Corruption via Bypassed Setters

A direct backing field assignment bypassed validation, causing negative balances for three months.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
using System;

public class BankAccount
{
    // AUTO-PROPERTY: compiler generates a hidden backing field automatically.
    // Use this when you need no logic — just store and retrieve.
    public string AccountHolder { 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 outside

    public decimal Balance
    {
        get
        {
            // getter runs every time someone reads Balance
            return _balance;
        }
        set
        {
            // setter runs every time someone assigns to Balance
            if (value < 0)
            {
                // reject bad data here rather than letting corruption spread
                throw new ArgumentOutOfRangeException(
                    nameof(value),
                    "Balance cannot be negative. Use Withdraw() instead.");
            }
            _balance = value;  // only store when the value is valid
        }
    }

    public BankAccount(string accountHolder, decimal openingBalance)
    {
        AccountHolder = accountHolder;  // hits the auto-property setter
        Balance = openingBalance;        // hits our validated setter — good from day one
    }
}

class Program
{
    static void Main()
    {
        var account = new BankAccount("Alice Chen", 500m);
        Console.WriteLine($"Account: {account.AccountHolder}");
        Console.WriteLine($"Balance: {account.Balance:C}");

        account.Balance = 750m;  // valid — setter stores the value
        Console.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
using System;
using System.Collections.Generic;

public class OrderLine
{
    // INIT-ONLY PROPERTY (C# 9+): can be set at object creation, never changed after.
    // Perfect for immutable value objects — keeps object-initializer syntax working.
    public string ProductName { get; init; }
    public decimal UnitPrice  { get; init; }
    public int     Quantity   { get; init; }

    // COMPUTED PROPERTY — no backing field. Calculated fresh on every read.
    // The 'get' is implicit with the '=>' expression-body syntax.
    public decimal LineTotal => UnitPrice * Quantity;
}

public class Order
{
    // GETTER-ONLY AUTO-PROPERTY: set in constructor, never exposed for external mutation.
    public string OrderId { get; }
    public DateTime PlacedAt { get; }

    private readonly List<OrderLine> _lines = new();

    // READ-ONLY property exposing the collection safely.
    // We return IReadOnlyList so callers can iterate but not Add/Remove.
    public IReadOnlyList<OrderLine> Lines => _lines;

    // COMPUTED PROPERTY derived from child data — always in sync, zero maintenance.
    public decimal GrandTotal
    {
        get
        {
            decimal total = 0m;
            foreach (var line in _lines)
                total += line.LineTotal;  // each LineTotal is itself computed
            return total;
        }
    }

    public Order(string orderId)
    {
        OrderId  = orderId;              // only assignment allowed for getter-only props
        PlacedAt = DateTime.UtcNow;
    }

    public void AddLine(OrderLine line) => _lines.Add(line);
}

class Program
{
    static void Main()
    {
        var order = new Order("ORD-2024-001");

        // Object initializer syntax works because of 'init' setters
        order.AddLine(new OrderLine { ProductName = "Mechanical Keyboard", UnitPrice = 129.99m, Quantity = 1 });
        order.AddLine(new OrderLine { 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 stale
            Console.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
using System;
using System.ComponentModel;  // INotifyPropertyChanged lives here
using System.Runtime.CompilerServices;

// A reusable base class — write this once, inherit everywhere in your MVVM layer
public abstract class ObservableBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    // [CallerMemberName] automatically fills in the calling property's name.
    // Callers write: SetField(ref _name, value) — no magic strings needed.
    protected bool SetField<T>(ref T backingField, T newValue,
        [CallerMemberName] string propertyName = "")
    {
        // Skip the event if the value hasn't actually changed — avoids UI thrash
        if (EqualityComparer<T>.Default.Equals(backingField, newValue))
            return false;

        backingField = newValue;  // update the backing field first
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        return true;  // return true so callers know a change actually happened
    }
}

public class CustomerViewModel : ObservableBase
{
    private string _fullName = string.Empty;
    private int    _loyaltyPoints;
    private bool   _isPremiumMember;

    public string FullName
    {
        get => _fullName;
        set => SetField(ref _fullName, value);  // fires PropertyChanged automatically
    }

    public int LoyaltyPoints
    {
        get => _loyaltyPoints;
        set
        {
            if (value < 0)
                throw new ArgumentOutOfRangeException(nameof(value), "Points cannot be negative.");

            SetField(ref _loyaltyPoints, value);

            // Changing points might flip premium status — update that property too
            IsPremiumMember = value >= 1000;
        }
    }

    // Private setter: the class controls this, callers just observe it
    public bool IsPremiumMember
    {
        get => _isPremiumMember;
        private set => SetField(ref _isPremiumMember, value);
    }

    // Computed — no setter at all. Always derived, always correct.
    public string MembershipLabel => IsPremiumMember ? "⭐ Premium" : "Standard";
}

class Program
{
    static void Main()
    {
        var customer = new CustomerViewModel();

        // 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 = "Jordan Rivera";  // SetField skips because value is unchanged
        Console.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
using System;
using System.ComponentModel;
using System.Collections.Generic;

public class TemperatureSensor : INotifyDataErrorInfo
{
    private double _celsius;
    private readonly Dictionary<string, string> _errors = new();

    // Pattern 1: Throw on invalid value (for critical constraints)
    public double Celsius
    {
        get => _celsius;
        set
        {
            if (value < -273.15)
                throw new ArgumentOutOfRangeException(
                    nameof(value), "Temperature cannot be below absolute zero.");
            _celsius = value;
        }
    }

    // Pattern 2: Clamp to acceptable range (for UI sliders)
    private double _humidity;
    public double Humidity
    {
        get => _humidity;
        set
        {
            var clamped = Math.Clamp(value, 0, 100);
            if (clamped != value)
            {
                // log the clamping for debugging
                Console.WriteLine($"Humidity clamped from {value} to {clamped}");
            }
            _humidity = clamped;
        }
    }

    // Pattern 3: Set an error flag (for form validation with multiple errors)
    private string _location = string.Empty;
    public string Location
    {
        get => _location;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                _errors[nameof(Location)] = "Location is required.";
                ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Location)));
            }
            else
            {
                _errors.Remove(nameof(Location));
            }
            _location = value;
        }
    }

    // INotifyDataErrorInfo members
    public bool HasErrors => _errors.Count > 0;
    public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
    public System.Collections.IEnumerable GetErrors(string? propertyName) =>
        propertyName != null && _errors.TryGetValue(propertyName, out var err) ? new[] { err } : Array.Empty<string>();
}

class Program
{
    static void Main()
    {
        var sensor = new TemperatureSensor();

        try { sensor.Celsius = -300; }
        catch (ArgumentOutOfRangeException ex) { Console.WriteLine($"Caught: {ex.Message}"); }

        sensor.Humidity = 150;   // clamped to 100
        Console.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
using System;

// Interface property contract
public interface IAccount
{
    string AccountId { get; }
    decimal Balance { get; }
    void Deposit(decimal amount);
    void Withdraw(decimal amount);
}

// Abstract base class with both abstract and virtual properties
public abstract class AccountBase : IAccount
{
    public abstract string AccountId { get; }
    public abstract decimal Balance { get; }

    // Virtual property with default implementation
    public virtual string Status => "Active";

    public abstract void Deposit(decimal amount);
    public abstract void Withdraw(decimal amount);
}

// Concrete implementation
public class SavingsAccount : AccountBase
{
    private decimal _balance;
    public override string AccountId { get; }

    public override decimal Balance
    {
        get => _balance;
        // No setter — balance changes only through Deposit/Withdraw
    }

    public override void Deposit(decimal amount)
    {
        if (amount <= 0) throw new ArgumentException("Deposit amount must be positive.");
        _balance += amount;
    }

    public override void Withdraw(decimal amount)
    {
        if (amount <= 0) throw new ArgumentException("Withdrawal amount must be positive.");
        if (amount > _balance) throw new InvalidOperationException("Insufficient funds.");
        _balance -= amount;
    }

    public SavingsAccount(string accountId)
    {
        AccountId = accountId;
    }
}

class Program
{
    static void Main()
    {
        IAccount account = new SavingsAccount("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
AspectAuto-PropertyFull Property (Backing Field)
Syntax verbosityMinimal — one lineVerbose — field + getter + setter bodies
Validation in setterNot possibleYes — throw, clamp, log freely
Firing change eventsNot possibleYes — INotifyPropertyChanged pattern
Setting a debugger breakpointCannot — no body to break onYes — put it anywhere in get/set body
Thread-safe lazy initNot possible without extra fieldYes — use Lazy<T> or double-check lock in getter
Init-only (C# 9){ get; init; } supportedSupported — mark setter with 'init' keyword
Binary compatibilityYes — adding logic later is safeYes — callers recompile cleanly
Expression-body shorthandN/Aget => _field; works for simple getters
Suitable for interfacesYesYes
Suitable for data binding (WPF/MAUI)Only for one-time set valuesYes — 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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between a C# property and a field?
02
When should I use an auto-property vs a property with a backing field in C#?
03
Is a getter-only property the same as a read-only field in C#?
04
Can I use a property in an interface?
05
Why would I use an init-only property instead of a read-only property initialized in the constructor?
🔥

That's OOP in C#. Mark it forged?

4 min read · try the examples if you haven't

Previous
Abstract Classes in C#
6 / 10 · OOP in C#
Next
Generics in C#