It’s often easy to overlook class modifiers when we’re coding in C#. I’ve found myself regularly simply flagging something as public for the sake of convenience. Class modifiers don’t end with public and private though. Let’s dig into some of the class and property modifiers we find in C# 12.

Public

public is an access modifier that allows access unrestricted. This class can be instanced anywhere, whether it’s inside the same namespace or not. It’s the simplest of modifiers and often our default.

A simple example of an application that reads a driver’s name:

namespace Streets
{
    public class Driver
    {
        public string Name { get; set; }
    }

    class Program
    {
        static void Main()
        {
            var newDriver = new Driver();

            Console.WriteLine("Please enter the driver's name:");
            newDriver.Name = Console.ReadLine();
            
            Console.WriteLine($"Driver name: {newDriver.Name}");
        } 
    }
}

We can see how we can easily instantiate the Driver class and read the Driver’s name.

Output looks as follows:

Please enter the driver's name:
Ricky Bobby
Driver name: Ricky Bobby

Process finished with exit code 0.

Private

private is another access modifier and the near-antithesis of public. Classes and properties marked with private are only accessible within their parent class. Outside of that, they are inaccessible even to their derived classes (unlike their friend protected).

Though it is primarily used on a property level, as I will show you in an example down below, you can also use it on inner (or sub) classes. In other words, you do get private classes but only if they are nested inside other classes.

Let’s make our driver’s details a bit more private through the use of a private setter:

public class Driver
{
	public string Name { get; private set; }
}

It’s a minor change but what we’re effectively telling the compiler is that though we can publically view this property, we cannot change it publically - the change needs to happen inside the Driver class. Once we change it we’ll notice instantly that our original code breaks, we can no longer use the line:

newDriver.Name = Console.ReadLine();

What we do instead is set a public accessor for the property and use that instead:

namespace Streets
{
    public class Driver
    {
        public string Name { get; private set; }

        public void SetName(string newName)
        {
            Name = newName;
        }
    }

    class Program
    {
        static void Main()
        {
            var newDriver = new Driver();

            Console.WriteLine("Please enter the driver's name:");
            newDriver.SetName(Console.ReadLine());
            
            Console.WriteLine($"Driver name: {newDriver.Name}");
        } 
    }
}

Using the SetName method we are exposing a way to update the name but within our own control. We could similarly do it via the constructor. Let’s chance the whole property to private and use the constructor to read the value instead - along with some validation for our driver’s name:

namespace Streets
{
    public class Driver
    {
        public Driver(string name)
        {
            if (string.IsNullOrEmpty(name))
            {
                throw new ArgumentNullException(nameof(name));
            }
            Name = name;
        }

        private string Name { get; set; }

        public string GetName()
        {
            return Name;
        }
    }

    class Program
    {
        static void Main()
        {
            Driver newDriver;
            Console.WriteLine("Please enter a valid name:");
            while (true)
            {
                try
                {
                    newDriver = new Driver(Console.ReadLine());
                    break;
                }
                catch (ArgumentNullException)
                {
                    Console.WriteLine("Please enter a valid name:");
                }
            }
            
            Console.WriteLine($"Driver name: {newDriver.GetName()}");
        } 
    }
}

What we’ve done now is implemented a bit of a null guard into the constructor of the Driver class. If we find a null or an empty string, we throw an ArgumentNullException which gets picked up in the app. We also introduced a new public GetName method that will return the name, thus eliminating any public access to the property. Output looks as follows:

Please enter a valid name:

Please enter a valid name:

Please enter a valid name:
Ricky Bobby
Driver name: Ricky Bobby

Process finished with exit code 0.

Protected

protected is yet another access modifier. Not dissimilar to private it limits access to it’s derived classes only:

namespace Streets
{
    public class Person
    {
        protected string Name { get; set; }
    }
    
    public class Driver : Person
    {
        public Driver(string name)
        {
            if (string.IsNullOrEmpty(name))
            {
                throw new ArgumentNullException(nameof(name));
            }
            Name = name;
        }

        public string GetName()
        {
            return Name;
        }
    }

    class Program
    {
        static void Main()
        {
            Driver newDriver;
            Console.WriteLine("Please enter a valid name:");
            while (true)
            {
                try
                {
                    newDriver = new Driver(Console.ReadLine());
                    break;
                }
                catch (ArgumentNullException)
                {
                    Console.WriteLine("Please enter a valid name:");
                }
            }
            
            Console.WriteLine($"Driver name: {newDriver.GetName()}");
        } 
    }
}

Simply enough, if we made the property private, it would be inaccessible from the Driver derivative. This is a great way for sharing access within inheritance while keeping fields “protected”.

Internal

internal is the last of our basic access modifiers. It dictates that access is shared but only within the same assembly (dll). This is to say, if you’re coding a library and you have a field you don’t want to be shared when that library is referenced in another project, you can flag it with internal.

Let’s add another assembly, called Council. It’s the Council’s job to keep track of how many drivers are on the street but they don’t need to know the names of those drivers. We change our original Driver class as follows:

// Streets.dll assembly
namespace Streets
{
    ...
    public class Driver : Person
    {
        public Driver(string name)
        {
            if (string.IsNullOrEmpty(name))
            {
                throw new ArgumentNullException(nameof(name));
            }
            Name = name;
        }

        internal string GetName()
        {
            return Name;
        }
    }
	...
}

and the Council assembly can be implemented as below:

// Council.dll assembly
namespace Council
{
	class Program
	{
		static void Main()
		{
			Driver[] drivers = GetAllDrivers();
			foreach (var driver in drivers)
			{
				// Produces error CS0122
				Console.WriteLine(driver.GetName());
			}
		}
	}
}

By changing the GetName method to internal it becomes declaratively public but only within it’s own assembly. It is inaccessibly to the Council assembly.

Protected Internal

protected internal is a more complex access modifier - allowing derived classes, in other assemblies, to access the property. That is to say, if we moved our person class to the Council assembly we could mark Name as protected internal and still use it in our derived class:

// Council.dll assembly
namespace Council
{
	...
    public class Person
    {
        protected internal string Name { get; set; }
    }
    ...
}

We can now simply reference the Council assembly in our Streets assembly and we’d have no errors.

Private Protected

The reverse of protected internal, private protected limits access within derived types within it’s own assembly only. In other words, if we changed our Council assembly as follows:

namespace Council
{
	...
    public class Person
    {
        private protected internal string Name { get; set; }
    }
    ...
}

We’d not only get an Error CS0122 in our Streets assembly, but we’d also be unable to directly access that value outside of derived classes within the Council assembly.

Abstract

abstract is a decorater indicating a class that:

  • Cannot be instantiated.
  • May contain abstract methods and accessors (not properties).
  • A non-abstract class derived from an abstract class must include implementations of abstract methods and accessors. Imagine a class designed to describe derived classes. For instance, let’s assign our drivers a vehicle. We know what a vehicle should look like in that it should have some wheels and be able to honk it’s horn:
public abstract class Vehicle
{
	public abstract int GetNumberOfWheels();

	public string Horn()
	{
		return "Beep beep";
	}
}

Seems like it’s descriptive even though we make the assumption that every vehicle’s horn sounds the same. We can now create two derivatives of Vehicle:

public class MotorBike : Vehicle
{
	
}

public class Car : Vehicle
{
	
}

We are instantly greeted by Abstract inherited member 'int Streets.Vehicle.GetNumberOfWheels()' is not implemented but that’s easy enough to solve:

public class MotorBike : Vehicle
{
	public override int GetNumberOfWheels() => 2;
}

public class Car : Vehicle
{
	public override int GetNumberOfWheels() => 4;
}

Notice the override keyword. It indicates that this method is to override an abstract or virtual method or accessor. Now, let’s give our Driver class the ability to own more than one Vehicle:

public class Driver : Person
{
	public Driver(string name)
	{
		if (string.IsNullOrEmpty(name))
		{
			throw new ArgumentNullException(nameof(name));
		}
		Name = name;
	}

	private List<Vehicle> Vehicles { get; } = [];

	public void AddVehicles(IEnumerable<Vehicle> vehicles)
	{
		Vehicles.AddRange(vehicles);
	}

	public override string ToString()
	{
		var sb = new StringBuilder();
		sb.AppendLine($"Driver name: {Name}");
		foreach (var vehicle in Vehicles)
		{
			sb.AppendLine($"Vehicle type: {vehicle.GetType().Name}");
			sb.AppendLine($"Number of wheels: {vehicle.GetNumberOfWheels()}");
			sb.AppendLine($"Horn sound: {vehicle.Horn()}");
		}

		return sb.ToString();
	}
}

I’ve also overridden the ToString method to give us all the details for the driver. Now, all we have to do in our Main method is the following:

newDriver.AddVehicles([
new Car(),
new Car(),
new MotorBike()]);

Console.Write(newDriver.ToString());

Output looks as follows:

Please enter a valid name:
Ricky Bobby
Driver name: Ricky Bobby
Vehicle type: Car
Number of wheels: 4
Horn sound: Beep beep
Vehicle type: Car
Number of wheels: 4
Horn sound: Beep beep
Vehicle type: MotorBike
Number of wheels: 2
Horn sound: Beep beep

Process finished with exit code 0.

A similar keyword to abstract is virtual but we’ll come back to that later.

Async

The async decorator goes hand in hand with await. It indicates that a method is asynchronous. It is used to do potentially long-running calls without blocking the caller’s thread.

For instance, if you’re making a call to an API, there’s no point the thread you used should sit there and do nothing until you get a response back - it can continue working thanks to async methods.

This is a much bigger topic to discuss and not something I’ll cover in this article, perhaps in a later one. In the meanwhile, you can always go and read up on it here: https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/

Const

const is an age old keyword indicating a value is constant and once set will remain at the set value. It makes the value immutable. Should you try to change the value, the compiler will kick up a fuss and prevent it from happening:

class Program
{
	static void Main()
	{
		const int retryLimit = 3;
		var retryAttempts = 0;
		Driver newDriver;
		Console.WriteLine("Please enter a valid name:");
		while (retryAttempts < retryLimit)
		{
			try
			{
				newDriver = new Driver(Console.ReadLine());
				break;
			}
			catch (ArgumentNullException)
			{
				retryLimit++;
				Console.WriteLine("Please enter a valid name:");
			}
		}
		
		newDriver.AddVehicles([
		new Car(),
		new Car(),
		new MotorBike()]);
		
		Console.Write(newDriver.ToString());
	} 
}

In the above code, lo-and-behold, I made a mistake, instead of incrementing retryAttempts I’ve tried to increment retryLimit which would lead me to a bad loop. Fortunately, past me was clever enough to implement it as a const and the compiler kicked up a fuss so I could fix it as below:

class Program
{
	static void Main()
	{
		const int retryLimit = 3;
		var retryAttempts = 0;
	
		Driver newDriver = null;
		
		Console.WriteLine("Please enter a valid name:");
		
		while (retryAttempts < retryLimit)
		{
			try
			{
				newDriver = new Driver(Console.ReadLine());
				break;
			}
			catch (ArgumentNullException)
			{
				retryAttempts++; // Compiler error
				Console.WriteLine("Please enter a valid name:");
			}
		}
		
		if (newDriver == null)
                return;
		
		newDriver.AddVehicles([
		new Car(),
		new Car(),
		new MotorBike()]);
		
		Console.Write(newDriver.ToString());
	} 
}

Override

We’ve come across override already as part of the abstract modifier. We’ll also use it below as part of the virtual modifier. It allows overriding an abstract or virtual member.

Readonly

The readonly modifier works similarly to const. While const is a compile-time constant, readonly is a runtime constant. This means readonly variables can be instanced, static, or reference types while const variables are implicitly static and limited to basic types.

Sealed

sealed forms part of inheritance. When a class is specified as sealed it means that no other class can be derived from it. We can also use the sealed modifier on overrides to prevent derivatives from overriding specific methods or accessors. Let’s implement an SUV class, derived from our Car class, only we never want a car that has more than 4 wheels so we seal it:

public class Car : Vehicle
{
	public sealed override int GetNumberOfWheels() => 4;
}

public class SUV : Car
{
	// Generates an error - cannot override inherited method
	public override int GetNumberOfWheels() => base.GetNumberOfWheels() + 2;
}

Now the SUVs will be forced to have 4 wheels. If you want to drive a car with 6, you’ll probably have to register it as a lorry instead.

Static

static is a fairly common modifier. It is used to declare static members that belong to the type itself, instead of an instance of the type. You may have noticed, our Main method is static, meaning it is a method on the Program type and we don’t need to instance Program to access it. When we make a method or accessor static it will not have access to any instanced properties of the class. For instance, we can make our Horn() method static and everything will still work fine, that’s simply because it doesn’t need to access anything instanced:

public abstract class Vehicle
{
	public abstract int GetNumberOfWheels();

	public static string Horn()
	{
		return "Beep beep";
	}
}

Unsafe

unsafe is less frequently used but it allows the use of pointers. A very simple example, straight from learn.microsoft.com would be the below:

class UnsafeTest
{
    // Unsafe method: takes pointer to int.
    unsafe static void SquarePtrParam(int* p)
    {
        *p *= *p;
    }

    unsafe static void Main()
    {
        int i = 5;
        // Unsafe method: uses address-of operator (&).
        SquarePtrParam(&i);
        Console.WriteLine(i);
    }
}

Which would output the square of 5 which is 25. Generally, when something is marked as unsafe it won’t compile unless you specify the -unsafe parameter to the compiler https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-options/language#allowunsafeblocks. Unsafe code is not verifiable by the common language runtime and I have never seen it used at large scale.

Virtual

Finally we get to the virtual keyword. Not dissimilar to abstract a virtual method or accessor can be overridden as well but it allows us a base method, property, or accessor (unlike an abstract variant). Let’s modify our Horn() method:

public virtual string Horn()
{
	return "Beep beep";
}

What we’re telling the compiler here is if a derivative of this class wants, it can override Horn() but if it’s not overridden, do what the base class says. Now we can override this in our derivatives:

public class MotorBike : Vehicle
{
	public override int GetNumberOfWheels() => 2;
	public override string Horn()
	{
		return "Beep!";
	}
}

public class Car : Vehicle
{
	public sealed override int GetNumberOfWheels() => 4;
	public override string Horn()
	{
		return "Honk!";
	}
}

public class SUV : Car
{
}

And we modify our Main to add an SUV to our driver’s collection:

static void Main()
{
	const int retryLimit = 3;
	var retryAttempts = 0;
	
	Console.WriteLine("Please enter a valid name:");
	var newDriver = new Driver(Console.ReadLine());
	
	while (retryAttempts < retryLimit)
	{
		try
		{
			newDriver = new Driver(Console.ReadLine());
			break;
		}
		catch (ArgumentNullException)
		{
			retryAttempts++; // Compiler error
			Console.WriteLine("Please enter a valid name:");
		}
	}
	
	newDriver.AddVehicles([
	new Car(),
	new SUV(),
	new MotorBike()]);
	
	Console.Write(newDriver.ToString());
} 

The output now looks as follows:

Please enter a valid name:
Ricky Bobby
Driver name: Ricky Bobby
Vehicle type: Car
Number of wheels: 4
Horn sound: Honk!
Vehicle type: SUV
Number of wheels: 4
Horn sound: Honk!
Vehicle type: MotorBike
Number of wheels: 2
Horn sound: Beep!

It’s worth noting that SUV gets the override implementation of Car in this case.

Volatile

The volatile keyword is useful for working in a multi-threaded environment where some threads might be executing at the same time. When you declare a field as volatile you’re telling the compiler to exclude that piece from certain types of optimisation. You can see more in my write-up on the Singleton Design Pattern.

Honorable mentions

I haven’t made mention of the extern or event modifiers as they’re extremely niche and I’ve used them so rarely (if ever) that I would just be copy-pasting online documentation. I would prefer to spend some more time learning about them before returning and updating this post.

Conclusion

I hope that with the knowledge above you’re armed a bit better to create cleaner, more maintainable code.