OOP/OOD  

Diving in OOP (Polymorphism and Inheritance – Part 4): All About Access Modifiers in C#

Introduction

Three parts in, and our little coffee shop has grown quite a personality. We've overloaded the Barista, inherited from MenuItem, watched SpecialMenuItem override and hide its way through virtual chains, and even taught Beverage that you can't order "a beverage" without picking something specific.

This time, we're locking the doors — literally. Every class, every method, every field in a real coffee shop has someone who's allowed to touch it and a dozen people who aren't. The cashier shouldn't be able to rewrite the secret espresso recipe. A customer definitely shouldn't be able to call the "apply staff discount" method. That's exactly the job of access modifierspublic, private, protected, internal, protected internal, sealed, plus the closely related const, static, and readonly fields.

Fair warning again — this is a long one, possibly the longest yet, because access modifiers touch everything: classes, members, inheritance, even fields. But we'll do what we always do — one small, run-it-yourself code snippet at a time, and by the end you'll know this topic by heart.

Grab your coffee. Let's lock some things down.

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) — you're here

  5. Diving in OOP (Properties)

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

Series Articles

What Are Access Modifiers?

Straight from the textbook definition: access modifiers are keywords that set the accessibility of classes, methods, and other members. They're the whole reason encapsulation works — without them, every part of your coffee shop's code could reach into every other part, and nothing would ever stay private.

Let's take each one into the shop, one at a time.

Public, Private, and Protected at the Class Member Level

Whenever we write a class, we want control over who can touch its members. The one thumb rule to remember: members of the same class can always access each other freely, no restrictions, no exceptions.

Create a console application named CoffeeShopApp. Add a class called OrderProcessor,

namespace CoffeeShopApp
{
    class OrderProcessor
    {
        static void CalculateTax()
        {
            Console.WriteLine("OrderProcessor CalculateTax");
        }

        public static void ProcessOrder()
        {
            Console.WriteLine("OrderProcessor ProcessOrder");
            CalculateTax();
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            OrderProcessor.ProcessOrder();
        }
    }
}

Output:

OrderProcessor ProcessOrder
OrderProcessor CalculateTax

ProcessOrder is marked public, so anyone can call it. CalculateTax has no modifier at all — which quietly makes it private by default. That private-ness has zero effect within the same class, so ProcessOrder calling CalculateTax works just fine. This is called member access.

Point to Remember: The default access modifier for class members is private.

Now try calling CalculateTax directly from Program,

class Program
{
    static void Main(string[] args)
    {
        OrderProcessor.CalculateTax();
    }
}

Output:

Compile time error: 'CoffeeShopApp.OrderProcessor.CalculateTax()' is inaccessible due to its protection level

Locked out, exactly as expected. Now mark CalculateTax explicitly protected instead of leaving it default,

protected static void CalculateTax()
{
    Console.WriteLine("OrderProcessor CalculateTax");
}

Output:

Compile time error: 'CoffeeShopApp.OrderProcessor.CalculateTax()' is inaccessible due to its protection level

Same error — protected still won't let Program in, because Program isn't a derived class. But ProcessOrder, sitting inside the same class, can still call it without issue.

Modifiers in Inheritance

Let's bring a derived class into the picture — a SpecialMenuItem-style relationship, but themed for order processing this time,

class OrderProcessorBase
{
    static void CalculateTax()
    {
        Console.WriteLine("OrderProcessorBase CalculateTax");
    }

    public static void ApplyBasePrice()
    {
        Console.WriteLine("OrderProcessorBase ApplyBasePrice");
    }

    protected static void ApplyStaffDiscount()
    {
        Console.WriteLine("OrderProcessorBase ApplyStaffDiscount");
    }
}

class OrderProcessorDerived : OrderProcessorBase
{
    public static void ShowFullDetails()
    {
        CalculateTax();
        ApplyBasePrice();
        ApplyStaffDiscount();
    }
}
class Program
{
    static void Main(string[] args)
    {
        OrderProcessorDerived.ShowFullDetails();
    }
}

Output:

Compile time error: 'CoffeeShopApp.OrderProcessorBase.CalculateTax()' is inaccessible due to its protection level

ApplyStaffDiscount (our protected method) compiles fine from inside ShowFullDetails — that's exactly what protected is for, granting access to derived classes. But CalculateTax, private by default, remains locked even from the derived class. Remove that one call and the code compiles cleanly.

Point to Remember: private grants access only to the same class. public grants access to everyone. protected sits in between — only derived classes get in.

The Internal Modifier at Class Level

Now let's go cross-assembly. Create a class library project named CoffeeShopLibrary, and add a class called InventoryItem, marked internal,

CoffeeShopLibrary.InventoryItem:

namespace CoffeeShopLibrary
{
    internal class InventoryItem
    {
    }
}

Compile the library, then reference its .dll from your CoffeeShopApp console project.

CoffeeShopApp.Program:

using CoffeeShopLibrary;

namespace CoffeeShopApp
{
    class Program
    {
        static void Main(string[] args)
        {
            InventoryItem item;
        }
    }
}

Output:

Compile time error: 'CoffeeShopLibrary.InventoryItem' is inaccessible due to its protection level

internal restricts access to the assembly it was declared in, and nothing outside it — not even a project that references the DLL. Remove the internal keyword entirely and try again,

namespace CoffeeShopLibrary
{
    class InventoryItem
    {
    }
}

Output:

Compile time error: 'CoffeeShopLibrary.InventoryItem' is inaccessible due to its protection level

Same error — because a class with no modifier at all is internal by default. Had we marked InventoryItem as public, everything would compile without complaint.

Point to Remember: A class marked internal limits access to the current assembly only.

Namespaces With Modifiers

Just for fun, let's try slapping public on a namespace,

public namespace CoffeeShopApp
{
    class Program
    {
        static void Main(string[] args)
        {
        }
    }
}

Output:

Compile time error: A namespace declaration cannot have modifiers or attributes

Point to Remember: Namespaces have no accessibility specifiers at all. They're implicitly public everywhere, and you can't add any modifier — not even public itself.

Private Class

One more experiment — mark Program itself as private,

namespace CoffeeShopApp
{
    private class Program
    {
        static void Main(string[] args)
        {
        }
    }
}

Output:

Compile time error: Elements defined in a namespace cannot be explicitly declared as private, protected, or protected internal

Point to Remember: A top-level class can only be public or internal — never protected or private. The default is internal.

Access Modifiers for Class Members

Members, on the other hand, are far more flexible — they can carry any of the access modifiers, defaulting to private.

Point to Remember: Members of a class can be marked with any access modifier; the default is private.

What happens if we try to stack two modifiers on a method?

public class Program
{
    static void Main(string[] args)
    {
    }

    public private void ProcessOrder()
    {
    }
}

Output:

Compile time error: More than one protection modifier

Not allowed — with one notable exception coming up shortly (protected internal). Note also that built-in types like int and object carry no accessibility restrictions of their own — they're usable anywhere.

Internal Class, Public Method

Back to CoffeeShopLibrary. Mark InventoryItem internal, but give it a public method,

namespace CoffeeShopLibrary
{
    internal class InventoryItem
    {
        public void GetStock() { }
    }
}
using CoffeeShopLibrary;

namespace CoffeeShopApp
{
    public class Program
    {
        public static void Main(string[] args)
        {
            InventoryItem item = new InventoryItem();
            item.GetStock();
        }
    }
}

Output

Compile time errors:
'CoffeeShopLibrary.InventoryItem' is inaccessible due to its protection level
The type 'CoffeeShopLibrary.InventoryItem' has no constructors defined
'CoffeeShopLibrary.InventoryItem' is inaccessible due to its protection level
'CoffeeShopLibrary.InventoryItem' does not contain a definition for 'GetStock' ...

A pile of errors, but the takeaway is simple: even a public method is useless from outside the assembly if its containing class is internal. The class's protection level always wins.

Public Class, Private Method

Flip it — public class, private method,

namespace CoffeeShopLibrary
{
    public class InventoryItem
    {
        private void GetStock() { }
    }
}

Output

Compile time error: 'CoffeeShopLibrary.InventoryItem' does not contain a definition for 'GetStock' ...

Making the class public doesn't rescue a private method. Both the class and the member's accessibility need to line up for outside access to succeed.

Public Class, Internal Method

namespace CoffeeShopLibrary
{
    public class InventoryItem
    {
        internal void GetStock() { }
    }
}

Output:

Compile time error: 'CoffeeShopLibrary.InventoryItem' does not contain a definition for 'GetStock' ...

Same story — an internal member is invisible outside the DLL it was compiled into, public class or not.

Protected Internal

Now the combo modifier. In CoffeeShopLibrary, set up three classes,

namespace CoffeeShopLibrary
{
    public class MenuItem
    {
        protected internal void PrepareRecipe()
        {
        }
    }

    public class SpecialMenuItem : MenuItem
    {
        protected internal void PrepareSpecialRecipe()
        {
            PrepareRecipe();
        }
    }

    public class BillingService
    {
        public void GenerateBill()
        {
            MenuItem item = new MenuItem();
            item.PrepareRecipe();
        }
    }
}
using CoffeeShopLibrary;

namespace CoffeeShopApp
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BillingService billing = new BillingService();
            billing.GenerateBill();
        }
    }
}

Output: Compiles with no error.

protected internal grants access to two groups at once — a derived class (SpecialMenuItem), and any class within the same assembly (BillingService, sitting right there in CoffeeShopLibrary).

Point to Remember: protected internal means the derived class and any class within the same assembly can both access the member.

Protected Member

namespace CoffeeShopApp
{
    class Recipe
    {
        protected int secretIngredientCount;

        void MethodRecipe(Recipe recipe, SignatureRecipe signatureRecipe)
        {
            recipe.secretIngredientCount = 100;
            signatureRecipe.secretIngredientCount = 200;
        }
    }

    class SignatureRecipe : Recipe
    {
        void MethodSignature(Recipe recipe, SignatureRecipe signatureRecipe)
        {
            recipe.secretIngredientCount = 100;
            signatureRecipe.secretIngredientCount = 200;
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
        }
    }
}

Output

Compile time error: Cannot access protected member 'CoffeeShopApp.Recipe.secretIngredientCount' 
via a qualifier of type 'CoffeeShopApp.Recipe'; the qualifier must be of type 
'CoffeeShopApp.SignatureRecipe' (or derived from it)

Inside Recipe, no modifier restricts anything (same-class access is always free). But inside the derived SignatureRecipe, accessing the field through a plain Recipe-typed reference (recipe.secretIngredientCount) fails — while accessing it through a SignatureRecipe-typed reference (signatureRecipe.secretIngredientCount) works fine. Comment out the failing line and the rest compiles.

Point to Remember: You cannot access a protected member through a base-class reference — only through the derived class's own reference, even from within the derived class itself.

Accessibility Priority in Inheritance

namespace CoffeeShopApp
{
    class InventoryItem
    {
    }

    public class SpecialInventoryItem : InventoryItem
    {
    }

    public class Program
    {
        public static void Main(string[] args)
        {
        }
    }
}

Output:

Compile time error: Inconsistent accessibility: base class 'CoffeeShopApp.InventoryItem' 
is less accessible than class 'CoffeeShopApp.SpecialInventoryItem'

InventoryItem is internal by default; SpecialInventoryItem is explicitly public. A derived class can never be more accessible than its own base class.

Point to Remember: The base class must always allow at least as much accessibility as any class deriving from it.

Swap the modifiers — base public, derived internal — and the error disappears entirely.

Another scenario — return types,

namespace CoffeeShopApp
{
    class InventoryItem
    {
    }

    public class BillingService
    {
        public InventoryItem GetItem()
        {
            InventoryItem item = new InventoryItem();
            return item;
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
        }
    }
}

Output

Compile time error: Inconsistent accessibility: return type 'CoffeeShopApp.InventoryItem' 
is less accessible than method 'CoffeeShopApp.BillingService.GetItem()'

Point to Remember: A method's return type must have accessibility at least as generous as the method itself.

And once more with a field,

namespace CoffeeShopApp
{
    class InventoryItem
    {
    }

    public class BillingService
    {
        public InventoryItem item;
    }

    public class Program
    {
        public static void Main(string[] args)
        {
        }
    }
}

Output

Compile time error: Inconsistent accessibility: field type 'CoffeeShopApp.InventoryItem' 
is less accessible than field 'CoffeeShopApp.BillingService.item'

Making the field non-public (or the type public) fixes it instantly — the rule is consistent everywhere: whatever's exposed can never point to something less accessible.

Quick Reference Table

Declared AccessibilityMeaning
publicAccess is not restricted.
protectedLimited to the containing class or types derived from it.
internalLimited to the current assembly.
protected internalLimited to the current assembly, or types derived from the containing class (in any assembly).
privateLimited to the containing type.

A few extra rules worth memorizing

  • Only one access modifier is allowed per member, except for the protected internal combination.

  • Namespaces carry no access restrictions at all.

  • Top-level types default to internal, and can only ever be public or internal.

  • Depending on the kind of container a member sits in, only certain accessibilities are even legal — the table below spells out exactly what's allowed where.

Default and Allowed Accessibility by Container Type

Members ofDefault Member AccessibilityAllowed Declared Accessibility
enumpublicNone — always public, can't be changed
classprivatepublic, protected, internal, private, protected internal
interfacepublicNone — always public, can't be changed
structprivatepublic, internal, private

A couple of things worth calling out in that table:

  • enum members can never be anything but public — try marking one private and C# won't even let the keyword through.

  • interface members work the same way — always public by default, and (in the classic pre–C# 8 interface model) there's nothing else to declare.

  • struct members are noticeably more restricted than class members — no protected and no protected internal at all. That's a direct consequence of structs not supporting inheritance the way classes do, so anything phrased in terms of "derived types" simply doesn't apply.

Point to Remember: enum and interface members are always implicitly public — you cannot restrict their accessibility. struct members can only be public, internal, or private — never protected or protected internal, since structs don't support inheritance.

Sealed Classes

sealed is a special case worth its own section — a sealed class simply refuses to be a base class for anyone.

namespace CoffeeShopApp
{
    sealed class Receipt
    {
    }

    class DetailedReceipt : Receipt
    {
    }

    public class Program
    {
        public static void Main(string[] args)
        {
        }
    }
}

Output

Compile time error: 'CoffeeShopApp.DetailedReceipt': cannot derive from sealed type 'CoffeeShopApp.Receipt'

Point to Remember: A class marked sealed cannot act as a base class for any other class.

That doesn't mean a sealed class is useless — it works exactly like any normal class internally,

namespace CoffeeShopApp
{
    sealed class Receipt
    {
        public int totalAmount = 250;

        public void PrintReceipt()
        {
            Console.WriteLine("Printing receipt from a sealed class");
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            Receipt receipt = new Receipt();
            Console.WriteLine(receipt.totalAmount);
            receipt.PrintReceipt();
        }
    }
}

Output

250
Printing receipt from a sealed class

Point to Remember: Since a sealed class can never be derived from, none of its code can ever be overridden — its behavior is permanently locked in.

Constants

Now for one of the more genuinely surprising corners of C#.

Lab 1: A Basic Constant

public class Program
{
    private const int basePrice = 100;

    public static void Main(string[] args)
    {
        Console.WriteLine(basePrice);
    }
}

Output:

100

Point to Remember: A const variable must be initialized at the moment it's declared. There's no assigning it later.

Lab 2: Constants Depending on Constants

namespace CoffeeShopApp
{
    public class Program
    {
        private const int lattePrice = cappuccinoPrice + 100;
        private const int cappuccinoPrice = espressoPrice - 10;
        private const int espressoPrice = 300;

        public static void Main(string[] args)
        {
            Console.WriteLine("{0} {1} {2}", lattePrice, cappuccinoPrice, espressoPrice);
        }
    }
}

Guess the output before scrolling down.

Output:

390 290 300

C# is smart enough to resolve this despite the declaration order looking backwards — it figures out that lattePrice needs cappuccinoPrice, which needs espressoPrice, resolves espressoPrice to 300 first, then cappuccinoPrice to 290, then lattePrice to 390.

Lab 3: Circular Constants Are Forbidden

private const int lattePrice = cappuccinoPrice + 100;
private const int cappuccinoPrice = espressoPrice - 10;
private const int espressoPrice = lattePrice;

Output:

Compile time error: The evaluation of the constant value for 'CoffeeShopApp.Program.lattePrice' 
involves a circular definition

Point to Remember: Constants, like classes, cannot depend on each other circularly.

Lab 4: Reference-Type Constants

public class Program
{
    public const MenuItem featuredItem = new MenuItem();

    public static void Main(string[] args)
    {
    }
}

public class MenuItem
{
}

Output:

Compile time error: 'CoffeeShopApp.Program.featuredItem' is of type 'CoffeeShopApp.MenuItem'. 
A const field of a reference type other than string can only be initialized with null.

Point to Remember: A const field of a reference type (other than string) can only ever be initialized to null. new MenuItem() only resolves at run time, and const demands a compile-time value.

Set it to null instead, and the error disappears — featuredItem will always be null, but at least it compiles.

Lab 5: Constants Need a Type Name, Not an Instance

public class MenuItem
{
    public const int lattePrice = 250;
}

public class Program
{
    public static void Main(string[] args)
    {
        MenuItem item = new MenuItem();
        Console.WriteLine(item.lattePrice);
    }
}

Output

Compile time error: Member 'CoffeeShopApp.MenuItem.lattePrice' cannot be accessed with an 
instance reference; qualify it with a type name instead

Point to Remember: A const is implicitly static, so it must be referenced through the type name (MenuItem.lattePrice), never an instance.

Try marking it static explicitly on top of const, and,

Output:

Compile time error: The constant 'CoffeeShopApp.MenuItem.lattePrice' cannot be marked static

Point to Remember: A const field can never be explicitly marked static — it already is one, implicitly.

Lab 6: Hiding a Constant in a Derived Class

public class MenuItem
{
    public const int itemCode = 10;
}

public class SpecialMenuItem : MenuItem
{
    public const int itemCode = 100;
}

public class Program
{
    public static void Main(string[] args)
    {
        Console.WriteLine(MenuItem.itemCode);
        Console.WriteLine(SpecialMenuItem.itemCode);
    }
}

Output:

10
100

Compiler Warning:

'CoffeeShopApp.SpecialMenuItem.itemCode' hides inherited member 'CoffeeShopApp.MenuItem.itemCode'. 
Use the new keyword if hiding was intended.

Just like methods back in Part 3, a const with the same name in a derived class quietly hides the base class version.

Static Fields

Point to Remember: A variable in C# can never sit uninitialized.

Lab 1: Static Default Values

public class Program
{
    private static int totalOrders;
    private static bool isShopOpen;

    public static void Main(string[] args)
    {
        Console.WriteLine(totalOrders);
        Console.WriteLine(isShopOpen);
    }
}

Output

0
False

Point to Remember: Static fields are initialized the moment their class is first loaded — int defaults to 0, bool defaults to false.

Lab 2: Instance Field Defaults

public class Program
{
    private int totalOrders;
    private bool isShopOpen;

    public static void Main(string[] args)
    {
        Program shop = new Program();
        Console.WriteLine(shop.totalOrders);
        Console.WriteLine(shop.isShopOpen);
    }
}

Output

0
False

Point to Remember: Instance fields get their default values at the moment their specific instance is created — the new keyword allocates the memory and initializes each one.

Lab 3: The Order-Doesn't-Matter Trick

public class Program
{
    private static int basePrice = discount + 10;
    private static int discount = basePrice + 5;

    public static void Main(string[] args)
    {
        Console.WriteLine(Program.basePrice);
        Console.WriteLine(Program.discount);
    }
}

Output

10
15

C# processes static initializers top to bottom, one statement at a time — not all at once. discount starts life at its own default (0), so basePrice becomes 10. Then, on the next line, discount becomes basePrice + 5, i.e., 15. Because C# never evaluates both lines simultaneously, it never even notices the apparent circularity.

Lab 4: This Trick Fails for Instance Fields

public class Program
{
    int basePrice = discount + 10;
    int discount = basePrice + 5;

    public static void Main(string[] args)
    {
    }
}

Output

Compile time error:
A field initializer cannot reference the non-static field, method, or property 
'CoffeeShopApp.Program.discount'
A field initializer cannot reference the non-static field, method, or property 
'CoffeeShopApp.Program.basePrice'

Instance field initializers run at object construction time, and at that exact moment, sibling instance fields simply don't have a value yet — so referencing them is disallowed outright.

Readonly Fields

Lab 1: A Basic Readonly Field

public class Program
{
    public static readonly int shopId = 100;

    public static void Main(string[] args)
    {
        Console.WriteLine(shopId);
    }
}

Output:

100

Lab 2: You Can't Reassign It Later

public class Program
{
    public static readonly int shopId = 100;

    public static void Main(string[] args)
    {
        shopId = 200;
        Console.WriteLine(shopId);
    }
}

Output

Compile time error: A static readonly field cannot be assigned to (except in a static 
constructor or a variable initializer).

Point to Remember: A static readonly field can only be assigned in a variable initializer or a static constructor — never anywhere else.

Lab 3: Readonly Fields Don't Need Immediate Initialization

public class Program
{
    public static readonly int shopId;

    public static void Main(string[] args)
    {
    }
}

This compiles without error — unlike const, a readonly field doesn't need a value at declaration time.

Lab 4: Assigning Inside a Static Constructor

public class Program
{
    public static readonly int shopId;

    static Program()
    {
        shopId = 100;
        Console.WriteLine("Inside Constructor");
    }

    public static void Main(string[] args)
    {
        Console.WriteLine(shopId);
    }
}

Output:

Inside Constructor
100

A major difference from const — a readonly field can be assigned inside a constructor, giving you the flexibility to compute its value at run time instead of compile time.

Lab 5: Readonly Reference Types Work Just Fine

public class MenuItem
{
}

public class Program
{
    public readonly MenuItem featuredItem = new MenuItem();

    public static void Main(string[] args)
    {
    }
}

This compiles cleanly — the exact scenario that failed with const back in Lab 4 works perfectly here, because readonly doesn't demand a compile-time-known value. That's exactly why readonly is often described as a more flexible, more generic cousin of const — and it reads better too. priceOfLatte is a lot more intuitive than a bare 250 scattered across your codebase.

Lab 6: Modifier Order Matters

public class MenuItem
{
    public int readonly basePrice = 100;
}

Output:

Compile time error:
Member modifier 'readonly' must precede the member type and name
Invalid token '=' in class, struct, or interface member declaration

Point to Remember: readonly must come before the type — public readonly int basePrice, never public int readonly basePrice.

Lab 7: Readonly Fields Can't Be Passed by ref

public class MenuItem
{
    public readonly int basePrice = 100;

    void AdjustPrice(ref int newPrice)
    {
    }

    void ApplyAdjustment()
    {
        AdjustPrice(ref basePrice);
    }
}

Output:

Compile time error: A readonly field cannot be passed ref or out (except in a constructor)

Makes complete sense — a ref parameter exists precisely to let the callee modify the original value, and that's exactly what a readonly field refuses to allow, outside of a constructor.

Summary

Let's lock in every rule we uncovered today,

  1. The default access modifier for class members is private.

  2. A class marked internal limits access to the current assembly only.

  3. Namespaces carry no accessibility specifiers — they're implicitly public everywhere, and no modifier can be added.

  4. A top-level class can only be public or internal, defaulting to internal.

  5. Members can carry any access modifier; the default is private.

  6. protected internal grants access to derived classes and any class in the same assembly.

  7. Between public and internal, public always allows the greater access.

  8. A base class must always allow at least as much accessibility as any class deriving from it.

  9. A method's return type must be at least as accessible as the method itself.

  10. A sealed class cannot act as a base class for anything.

  11. Since sealed classes can't be derived from, their code can never be overridden.

  12. const variables must be initialized at the moment of declaration.

  13. const variables cannot depend on each other circularly.

  14. A const field of a reference type (other than string) can only be initialized to null.

  15. const values must be resolvable at compile time.

  16. const is implicitly static — always reference it through the type name.

  17. const can never be explicitly marked static.

  18. A C# variable can never hold an uninitialized value.

  19. Static fields get their default values the moment their class is first loaded.

  20. Instance fields get their default values at the moment their specific instance is created.

  21. static readonly fields can only be assigned in a variable initializer or a static constructor.

  22. enum and interface members are always implicitly public and can't be restricted; struct members can only be public, internal, or private — never protected or protected internal.

Conclusion

That's access modifiers, constants, static fields, and readonly fields — all locked down (pun very much intended). If Part 3 taught us how objects behave at run time, this part taught us who's even allowed to ask. Between public, private, protected, internal, and protected internal, you now have the full toolkit to design a coffee shop's classes the way a real one is run — customers see the menu, staff see the recipes, and nobody outside the shop touches the register.

Next up, the final stop in this series: Properties and Indexers — where we'll see how C# lets us wrap all this careful access control in something that still reads like a plain old field.

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