Introduction

Four parts in, and our coffee shop is running like clockwork. We've overloaded the Barista, inherited through MenuItem, chased virtual dispatch through four-level chains, and locked every class and field down with the right access modifier. There's just one problem — right now, every field in our shop is either wide open to the public or completely sealed off. There's no middle ground. No way to say "you can read the barista's name, but you can't just walk in and rewrite it." No way to run a bit of logic every time someone checks a price.

That's exactly the gap properties fill. A property looks and feels exactly like a plain field from the outside — you read it, you assign to it, business as usual — but underneath, it's actually a pair of methods in disguise, quietly running whatever logic you want, every single time. MSDN puts it precisely: "A property is a member that provides a flexible mechanism to read, write, or compute the value of a private field. Properties can be used as if they are public data members, but they are actually special methods called accessors."

Grab your coffee — we're going deep on get, set, readonly properties, write-only properties, static properties, abstract properties, and how properties behave (strangely!) in inheritance. And since this series started years ago, we'll also stop by the modern C# syntax for properties — the stuff that didn't exist when this series began but you'll use constantly today.

Here's the series roadmap:

  1. Diving in OOP (Polymorphism and Inheritance – Part 1)

  2. Diving in OOP (Polymorphism and Inheritance – Part 2)

  3. Diving in OOP (Polymorphism and Inheritance – Part 3)

  4. Diving in OOP (Access Modifiers)

  5. Diving in OOP (Properties) — you're here

Note: Every code snippet here has actually been run — no theoretical guessing, just real compiler output (including the errors, especially the errors).

Series Articles

Properties, Explained

Being a C# developer means being handed properties as a gift, honestly. Internally, they're nothing like ordinary variables — a property has no memory of its own. It's really just a specially disguised method (or two) that runs whenever you touch it. A property is a class member, encapsulated and abstracted away from whoever's using it — the caller only ever sees "I'm reading/writing a value," never the logic running behind the scenes.

Let's write some code.

Lab 1: A Property Needs an Accessor

Create a console application named CoffeeShopApp. Add a class called Barista, and try this,

namespace CoffeeShopApp
{
    public class Barista
    {
        public string Name { }
    }
}

Output:

Error: 'Barista.Name': property or indexer must have at least one accessor

Self-explanatory — a property needs at least one accessor, a get or a set, something that actually happens when you touch it. Unlike a plain field, a property simply cannot exist empty.

Lab 2: Adding a Get Accessor

namespace CoffeeShopApp
{
    public class Barista
    {
        public string Name
        {
            get
            {
                return "I am a Barista's Name property";
            }
        }
    }
}
Barista barista = new Barista();
Console.WriteLine(barista.Name);

Output:

I am a Barista's Name property

That confirms it — our get accessor fired the moment we touched Name.

Get Accessor

Let's add a second property, ExperienceYears, that computes a barista's years of experience from their hire date,

using System;

namespace CoffeeShopApp
{
    public class Barista
    {
        public string Name
        {
            get { return "I am a Barista's Name property"; }
        }

        public int ExperienceYears
        {
            get
            {
                DateTime hireDate = new DateTime(2018, 01, 20);
                DateTime currentDate = DateTime.Now;
                int years = currentDate.Year - hireDate.Year;
                return years;
            }
        }
    }
}
Barista barista = new Barista();
Console.WriteLine(barista.Name);
Console.WriteLine("Experience: " + barista.ExperienceYears + " years");

Output:

I am a Barista's Name property
Experience: 8 years

Notice what just happened — ExperienceYears runs real logic (subtracting years, referencing DateTime.Now), yet Program.cs, the caller, has zero idea any of that is happening. It just uses the property like a plain value. That's encapsulation, doing exactly what it's meant to do.

Point to Remember: A get accessor is only for reading a value. A property with only get cannot be assigned to from the caller's side — it's strictly read-only in practice.

Set Accessor

Lab 1: Introducing Set

using System;

namespace CoffeeShopApp
{
    public class Barista
    {
        public string Name
        {
            get { return "I am a Barista's Name property"; }
        }

        public int ExperienceYears
        {
            get
            {
                DateTime hireDate = new DateTime(2018, 01, 20);
                DateTime currentDate = DateTime.Now;
                int years = currentDate.Year - hireDate.Year;
                Console.WriteLine("Get ExperienceYears called");
                return years;
            }
            set
            {
                Console.WriteLine("Set ExperienceYears called " + value);
            }
        }
    }
}
Barista barista = new Barista();
Console.WriteLine(barista.Name);
barista.ExperienceYears = 12;
Console.WriteLine("Experience: " + barista.ExperienceYears + " years");

Output:

I am a Barista's Name property
Set ExperienceYears called 12
Get ExperienceYears called
Experience: 8 years

We assigned 12, but reading it back still gives 8 — because our get accessor runs its own fixed calculation logic, completely disconnected from whatever the set accessor did with the value it received. That's a real gap. Let's fix it.

Lab 2: Backing the Property With a Field

using System;

namespace CoffeeShopApp
{
    public class Barista
    {
        private string name;
        private int experienceYears;

        public string Name
        {
            get { return name; }
            set
            {
                Console.WriteLine("Set Name called");
                name = value;
            }
        }

        public int ExperienceYears
        {
            get { return experienceYears; }
            set
            {
                Console.WriteLine("Set ExperienceYears called");
                experienceYears = value;
            }
        }
    }
}
Barista barista = new Barista();
barista.Name = "Pari";
Console.WriteLine(barista.Name);
barista.ExperienceYears = 12;
Console.WriteLine("Experience: " + barista.ExperienceYears + " years");

Output:

Set Name called
Pari
Set ExperienceYears called
Experience: 12 years

Now the values stick — get returns exactly what set stored, because both accessors share the same private backing field. This is the standard property pattern you'll see in day-to-day code: a public property wrapping a private field.

Point to Remember: The backing field for a property should share the exact same data type as the property itself.

Lab 3: Automatic Properties

If your property doesn't need any custom logic — it's purely a get/set pair over a hidden field — C# lets you skip the backing field entirely,

namespace CoffeeShopApp
{
    public class Barista
    {
        public string Name { get; set; }
        public int ExperienceYears { get; set; }
    }
}
Barista barista = new Barista();
barista.Name = "Pari";
Console.WriteLine(barista.Name);
barista.ExperienceYears = 12;
Console.WriteLine("Experience: " + barista.ExperienceYears + " years");

Output:

Pari
Experience: 12 years

{ get; set; } is an automatic property — C# quietly generates the hidden backing field for you behind the scenes. No console logging here, obviously, since there's no custom code to run.

Readonly Properties

Give a property only a get, and it becomes effectively read-only from the outside — nobody can assign to it once the object exists.

using System;

namespace CoffeeShopApp
{
    public class Barista
    {
        private string name = "Pari";
        private int experienceYears = 8;

        public string Name
        {
            get { return name; }
        }

        public int ExperienceYears
        {
            get { return experienceYears; }
        }
    }
}
Barista barista = new Barista();
barista.Name = "Akhil";
barista.ExperienceYears = 10;

Output:

Error: Property or indexer 'Barista.ExperienceYears' cannot be assigned to -- it is read only
Error: Property or indexer 'Barista.Name' cannot be assigned to -- it is read only

Exactly as expected — with only get defined, any attempt to assign from outside the class fails at compile time.

Write-Only Properties

The mirror image — only a set, no get. Handy for something like a register PIN a barista types in but should never be able to read back out.

namespace CoffeeShopApp
{
    public class Barista
    {
        private string registerPin;

        public string RegisterPin
        {
            set { registerPin = value; }
        }
    }
}
Barista barista = new Barista();
barista.RegisterPin = "4521";
Console.WriteLine(barista.RegisterPin);

Output:

Error: The property or indexer 'Barista.RegisterPin' cannot be used in this context 
because it lacks the get accessor

Setting it works fine; trying to read it back fails immediately, because there's no get accessor to call.

Insight of Properties in C#

Lab 1: You Can't Split a Property in Two

namespace CoffeeShopApp
{
    public class Barista
    {
        private string name;

        public string Name
        {
            set { name = value; }
        }

        public string Name
        {
            get { return name; }
        }
    }
}

Output:

Error: The type 'Barista' already contains a definition for 'Name'

The compiler treats a property name as one single, indivisible unit — you cannot declare get and set for the same name as two separate property blocks.

Lab 2: A Property Can't Share a Name With a Variable

namespace CoffeeShopApp
{
    public class Barista
    {
        private string name;

        public string name
        {
            get { return name; }
            set { name = value; }
        }
    }
}

Output:

Error: The type 'Barista' already contains a definition for 'name'

A property and a field can't share the exact same name and case — the compiler wouldn't be able to tell which one you meant to access.

Properties vs. Variables

There's a common belief that plain variables are always faster than properties. It's not entirely wrong, but it's not the full picture either — a property is really a method call under the hood, and the JIT compiler is often smart enough to inline simple accessors so there's barely any overhead at all. MSDN's own comparison table sums up the real differences well:

Point of DifferenceVariableProperty
DeclarationSingle declaration statementA block of statements (accessors)
ImplementationSingle storage locationExecutable code (property procedures)
StorageDirectly tied to the variable's valueTypically has internal storage not exposed outside the class
Executable codeNoneMust have at least one accessor
Read/write accessRead/write or read-onlyRead/write, read-only, or write-only
Custom actions on accessNot possibleFully possible, on get or set

Static Properties

Just like fields and methods, properties can be static too — accessed through the class name, shared across every barista in the shop.

using System;

namespace CoffeeShopApp
{
    public class Barista
    {
        public static string ShopName
        {
            set
            {
                Console.WriteLine("In set ShopName; value is " + value);
            }
            get
            {
                Console.WriteLine("In get ShopName");
                return "The Byte-Sized Café";
            }
        }
    }
}
Barista.ShopName = "The Byte-Sized Café Downtown";
Console.WriteLine(Barista.ShopName);

Output:

In set ShopName; value is The Byte-Sized Café Downtown
In get ShopName
The Byte-Sized Café

Static properties behave exactly like static fields or methods — accessed via the class name, never through an instance.

Property Return Type Rules

Lab 1: No Void Properties

namespace CoffeeShopApp
{
    public class Barista
    {
        public void PrepareOrder
        {
            get
            {
                Console.WriteLine("Get called");
            }
        }
    }
}

Output:

Error: 'PrepareOrder': property or indexer cannot have void type

Point to Remember: A property can never have a void return type.

Lab 2: Set Can't Return a Value

namespace CoffeeShopApp
{
    public class Barista
    {
        public int ExperienceYears
        {
            set { return 5; }
        }
    }
}

Output:

Error: Since 'Barista.ExperienceYears.set' returns void, a return keyword 
must not be followed by an object expression

The compiler treats set as an implicit void method that takes a parameter — a bare return; compiles fine, but return 5; doesn't, because set was never designed to hand anything back.

The value Keyword

value is reserved inside a set accessor — try declaring a local variable with that exact name, and things go sideways,

namespace CoffeeShopApp
{
    public class Barista
    {
        public string Name
        {
            set { string value; }
        }
    }
}

Output:

Error: A local variable named 'value' cannot be declared in this scope because it would 
give a different meaning to 'value', which is already used in a 'parent or current' 
scope to denote something else

value implicitly represents whatever was assigned to the property — you never declare it, you just use it.

Abstract Properties

Yes — properties can be abstract too, and they follow the same override rules as abstract methods from Part 3.

Lab 1: Overriding Both Accessors

using System;

namespace CoffeeShopApp
{
    public abstract class MenuItem
    {
        public abstract decimal Price { get; set; }
    }

    public class SpecialMenuItem : MenuItem
    {
        public override decimal Price
        {
            get
            {
                Console.WriteLine("Get Price called");
                return 250;
            }
            set
            {
                Console.WriteLine("Set Price called, value is " + value);
            }
        }
    }
}
SpecialMenuItem item = new SpecialMenuItem();
item.Price = 300;
Console.WriteLine(item.Price);

Output:

Set Price called, value is 300
Get Price called
250

MenuItem's abstract Price has no body at all for either accessor — neither get nor set — so SpecialMenuItem is required to implement both, marked override.

Point to Remember: If a derived class doesn't explicitly mark a property override, it's treated as new by default — the same hiding behavior we saw with methods back in Part 3.

Lab 2: You Can't Override an Accessor That Was Never There

using System;

namespace CoffeeShopApp
{
    public abstract class MenuItem
    {
        public abstract decimal Price { get; }
    }

    public class SpecialMenuItem : MenuItem
    {
        public override decimal Price
        {
            get
            {
                Console.WriteLine("Get Price called");
                return 250;
            }
            set
            {
                Console.WriteLine("Set Price called, value is " + value);
            }
        }
    }
}

Output:

Error: 'SpecialMenuItem.Price.set': cannot override because 'MenuItem.Price' 
does not have an overridable set accessor

Since the base class only declared a get, there's simply nothing for set to override.

Point to Remember: You can only override an accessor that actually exists in the base class's abstract property.

Properties in Inheritance — A Genuine Gotcha

namespace CoffeeShopApp
{
    public class MenuItemBase
    {
        public int Price
        {
            set { }
        }
    }

    public class SpecialMenuItem : MenuItemBase
    {
        public int Price
        {
            get { return 250; }
        }
    }
}
MenuItemBase baseItem = new MenuItemBase();
baseItem.Price = 100;

SpecialMenuItem specialItem = new SpecialMenuItem();
((MenuItemBase)specialItem).Price = 150;
specialItem.Price = 100;

It's tempting to think C# will merge these into one combined property — set from the base, get from the derived class. It doesn't. The compiler treats them as two completely independent properties; the one in SpecialMenuItem simply hides the one in MenuItemBase — exactly the same hiding behavior methods showed back in Part 3.

Output:

Error: Property or indexer 'SpecialMenuItem.Price' cannot be assigned to -- it is read only

((MenuItemBase)specialItem).Price = 150; compiles fine — casting up to the base class reaches the base's set. But specialItem.Price = 100; fails, because the derived class's own Price only ever declared a get.

Properties: The Modern C# Syntax

Everything above is the classic, foundational syntax — and it's still exactly how properties work under the hood today. But C# has added a lot of shorthand over the years that makes properties faster to write and, honestly, harder to misuse. Since we're building a real coffee shop here, let's see the modern equivalents side by side.

Expression-Bodied Properties (C# 6)

For a get-only property that's just one expression, you can drop the braces and return entirely,

public class Barista
{
    private string name;

    public string Greeting => "Welcome, " + name + "!";
}

=> here means exactly the same thing as get { return "Welcome, " + name + "!"; } — just far less to type.

Auto-Property Initializers (C# 6)

You can give an automatic property a default value right at declaration, no constructor required,

public class MenuItem
{
    public string Name { get; set; } = "House Blend";
    public decimal Price { get; set; } = 199;
}

Get-Only Auto Properties (C# 6)

Want a readonly automatic property that can still be set once, from inside a constructor? This wasn't possible with plain { get; } before C# 6,

public class Barista
{
    public string Name { get; }

    public Barista(string name)
    {
        Name = name;
    }
}

Name behaves exactly like our manual readonly property from earlier, but with none of the backing-field boilerplate.

Init-Only Setters (C# 9)

init lets a property be set during object initialization — and then it locks, permanently, just like readonly,

public class MenuItem
{
    public string Name { get; init; }
    public decimal Price { get; init; }
}
var latte = new MenuItem { Name = "Latte", Price = 250 };
// latte.Price = 300;   // compile-time error — init-only, can't be reassigned

This is genuinely useful for something like a MenuItem, which really shouldn't have its price silently mutated once created — you get the readability of object initializer syntax with the safety of a readonly field.

Required Members (C# 11)

required forces the caller to set a property during initialization, or the code simply won't compile,

public class MenuItem
{
    public required string Name { get; init; }
    public required decimal Price { get; init; }
}
var espresso = new MenuItem { Name = "Espresso", Price = 150 }; // fine
// var invalid = new MenuItem();   // compile-time error — Name and Price are required

No more forgetting to set a field and only discovering it's null at run time — required catches it right at the call site.

Primary Constructors (C# 12)

The newest shorthand — you can now fold constructor parameters straight into the class declaration, and reference them directly in property definitions,

public class Barista(string name, int experienceYears)
{
    public string Name { get; } = name;
    public int ExperienceYears { get; } = experienceYears;
}
var barista = new Barista("Pari", 8);
Console.WriteLine(barista.Name);

Output:

Pari

No explicit constructor body needed at all — the primary constructor parameters are available directly wherever you'd normally reference this.name.

Point to Remember: The classic get/set accessor rules from earlier in this article still apply underneath every one of these modern forms — init is just a restricted set, and expression-bodied syntax is just a compact get. Knowing the fundamentals means none of the new syntax ever feels like magic.

What's New in C# 14

C# 14 (shipping with .NET 10) brings the biggest change to property syntax since automatic properties themselves — plus a couple of smaller, genuinely useful additions. Let's put each one to work in the coffee shop.

The field Keyword — Semi-Automatic Properties

This is the headline feature. Until now, the moment you needed even a tiny bit of custom logic in a property — validation, logging, a side effect — you had to abandon automatic properties entirely and write out a full backing field by hand, exactly like our Barista.Name/Barista.ExperienceYears example earlier in this article.

C# 14 introduces the contextual field keyword, which refers to the property's compiler-generated backing field directly, without you ever having to declare it,

public class Barista
{
    public string Name
    {
        get => field;
        set => field = value ?? throw new ArgumentNullException(nameof(value));
    }

    public decimal HourlyRate
    {
        get;
        set
        {
            if (value < 0)
                throw new ArgumentOutOfRangeException(nameof(value), "Hourly rate can't be negative");
            field = value;
        }
    }
}
var barista = new Barista { Name = "Pari", HourlyRate = 250 };
Console.WriteLine(barista.Name);

barista.HourlyRate = -50;   // throws ArgumentOutOfRangeException at run time

Output:

Pari
Unhandled exception: System.ArgumentOutOfRangeException: Hourly rate can't be negative

Notice HourlyRate only defines a custom set — the get; on its own is enough, and field inside set automatically refers to the same hidden storage. This is exactly the middle ground developers have wanted for years: full validation logic, zero manual backing-field boilerplate.

Point to Remember: field is a contextual keyword — it only means "the compiler-generated backing field" inside a property accessor. Outside a property, field is still perfectly usable as an ordinary identifier name, so nothing existing breaks.

Partial Properties

C# 13 introduced partial properties, and C# 14 rounds out the feature — useful when a property's declaration and its implementation need to live in separate files, which source generators (like ones used for validation frameworks or ORMs) rely on heavily.

// MenuItem.cs — the declaring part
public partial class MenuItem
{
    public partial string Name { get; set; }
}

// MenuItem.Generated.cs — the implementing part
public partial class MenuItem
{
    private string _name = "House Blend";

    public partial string Name
    {
        get => _name;
        set => _name = value;
    }
}

Handy if you've ever used a code generator that needed to attach behavior to a property without touching the file where you declared it — the two halves compile into a single property, same as partial methods and partial classes have worked for years.

Null-Conditional Assignment

A small but genuinely satisfying quality-of-life addition — you can now use ?. on the left-hand side of an assignment, not just when reading a value,

MenuItem? featuredItem = GetTodaysFeaturedItem(); // might return null
featuredItem?.Price = 199;

Before C# 14, that line had to be written as a full if (featuredItem != null) { featuredItem.Price = 199; }. Now the null check and the assignment collapse into one line — and if featuredItem is null, the assignment simply doesn't happen, no exception, no extra branching.

Point to Remember: Null-conditional assignment only skips the assignment when the left-hand side is null — it does not swallow exceptions thrown by anything on the right-hand side of the expression.

Summary

Let's lock in everything we covered today,

  1. A property must have at least one accessor — get, set, or both.

  2. A property's backing field should share the same data type as the property.

  3. A property can never have a void return type.

  4. set is implicitly void — it can't return a value.

  5. value is a reserved word inside set and cannot be redeclared as a local variable.

  6. A property and a field cannot share the same name and case.

  7. A property cannot be split into two separately declared blocks under the same name.

  8. If a derived class property isn't explicitly marked override, it's treated as new by default.

  9. You can only override an accessor that's actually declared in the base class's abstract property.

  10. A get-only property is effectively read-only from the outside; a set-only property is write-only.

  11. A base class property and a derived class property with the same name are treated as entirely independent — the derived one hides the base one.

  12. Modern C# adds expression-bodied properties, auto-property initializers, get-only auto properties, init accessors, required members, and primary constructors — all shorthand over the exact same get/set fundamentals.

  13. C# 14 adds the field keyword for semi-automatic properties (custom logic with no manual backing field), partial properties, and null-conditional assignment (?.=).

Conclusion

That's properties, top to bottom — from the very first "why won't this compile without an accessor" error, all the way to required members and primary constructors in modern C#. Properties are the reason your coffee shop's MenuItem can look like a plain, friendly object from the outside while quietly enforcing every rule you want underneath — and now you know exactly what's happening on both sides of that curtain.

Next up in the series: Indexers — how to make your own classes behave like arrays, so you can write menu[0] and have it just work.

Keep coding, keep learning, and don't forget your coffee. ☕