C# Properties Explained — Getters, Setters and Real-World Patterns
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.
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}"); } } }
Balance: $500.00
After deposit adjustment: $750.00
Caught: Balance cannot be negative. Use Withdraw() instead. (Parameter 'value')
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.
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 } }
Placed: 2024-07-15 09:23:41Z
Mechanical Keyboard x1 = $129.99
USB-C Cable x3 = $37.47
Grand Total: $167.46
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.
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}"); } }
[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
| 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 |
| 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
- 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.
- Properties give you binary compatibility: you can add a getter/setter body later without forcing callers to recompile, something impossible with public fields.
- Computed properties (expression-body or getter-only) guarantee consistency — derived values can never go stale because there's nothing to synchronize.
- 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
- ✕Mistake 1: 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.
- ✕Mistake 2: 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.
- ✕Mistake 3: 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.
Interview Questions on This Topic
- QWhat is the difference between a property and a field in C#, and why should you prefer properties for public members?
- QCan you explain what 'init' accessors are in C# 9 and give a scenario where you'd choose 'init' over a private setter?
- QIf 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?
Frequently Asked Questions
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.
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.
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.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.