Defining a class

A class is a blueprint for objects with the same shape and behavior. The general ideas (objects, encapsulation, inheritance, polymorphism) are explained in Object-Oriented Concepts; this page shows how C# does it. C# is object-oriented to the core: every method lives in a type, and even the top-level statements of a program are compiled into a hidden class.

A class lists its members. A field is a variable that belongs to the object. A method is a function that belongs to the class and works on the object's data. You create an object with new, and reach its members with a dot.

var delft = new Meter();
delft.Id = "MTR-0042";
delft.City = "Delft";
delft.AddReading(412.5);
delft.AddReading(415.8);

Console.WriteLine($"{delft.Id} in {delft.City}: {delft.Usage():F1} kWh");

class Meter
{
    public string Id = "";
    public string City = "";
    private List<double> readings = [];

    public void AddReading(double kwh)
    {
        readings.Add(kwh);
    }

    public double Usage()
    {
        return readings[^1] - readings[0];
    }
}
MTR-0042 in Delft: 3.3 kWh

The class is declared below the top-level statements: in a single-file program, types always come after the code that runs. Inside a method, the fields of the current object are simply available by name (readings); you may write this.readings to make that explicit. By convention, class, method and public member names use PascalCase, and private fields and local variables use camelCase (many teams prefix private fields with an underscore: _readings).

Variables hold references

A class is a reference type: the object lives on the heap, and a variable holds a reference to it. Assigning one variable to another copies the reference, not the object, so both names point to the same meter. And == on two class instances asks "is this the same object?", not "do they contain the same data?"

var a = new Meter { Id = "MTR-0042" };
var b = a;
b.Id = "MTR-0099";
Console.WriteLine(a.Id);

var c = new Meter { Id = "MTR-0099" };
Console.WriteLine(a == b);
Console.WriteLine(a == c);
Console.WriteLine(a);

class Meter
{
    public string Id { get; set; } = "";
}
MTR-0099
True
False
Meter

a and c hold equal data but are two different objects, so a == c is False. Printing an object calls its ToString() method, which by default only returns the type name. When you want comparison by content and a readable printout for free, use a record (see Records, Structs and Enums). The memory picture behind references is on Memory Management.

Access modifiers

An access modifier is the keyword in front of a type or member that decides which code may use it. It is how C# enforces encapsulation: the compiler refuses access from outside, so a class can protect its data and change its internals later without breaking callers.

Modifier Who can use the member
public Any code.
private Only code inside the same class. The default for members.
protected The class and classes derived from it (see Inheritance).
internal Any code in the same assembly (the same project's compiled DLL or executable). The default for classes declared outside another class.
protected internal, private protected Combinations: "derived classes or same assembly", and "derived classes in the same assembly". Rarely needed.

Because private is the default, a member without a modifier is hidden. Leaving it out is legal, but writing private explicitly makes the intent clear to the next reader.

var meter = new Meter();
Console.WriteLine(meter.serial);

class Meter
{
    string serial = "KE-7781-0042";
}
example.cs(2,25): error CS0122: 'Meter.serial' is inaccessible due to its protection level

Properties

Public fields are rare in C#. Data that other code may see is exposed through a property instead: a member that looks like a field from the outside but has a get accessor (run when the value is read) and usually a set accessor (run when it is assigned). The getter and setter idea is the same as in JavaScript; in C# it is the normal way to publish data.

Auto-implemented properties

An auto-implemented property (auto-property) is a property written as just { get; set; }: the compiler creates a hidden backing field to store the value. Each accessor can have its own access modifier, so { get; private set; } means "anyone may read, only this class may change". Leaving out the setter ({ get; }) gives a property that can only be set in the constructor.

var ticket = new Ticket("INC-40112", "No power in Leiden");
ticket.Assign("Amara Okafor");
Console.WriteLine($"{ticket.Number}: {ticket.Status}, {ticket.AssignedTo}");
ticket.Status = "Closed";

class Ticket
{
    public Ticket(string number, string summary)
    {
        Number = number;
        Summary = summary;
    }

    public string Number { get; }
    public string Summary { get; }
    public string Status { get; private set; } = "New";
    public string? AssignedTo { get; private set; }

    public void Assign(string engineer)
    {
        AssignedTo = engineer;
        Status = "Assigned";
    }
}
example.cs(4,1): error CS0272: The property or indexer 'Ticket.Status' cannot be used in this context because the set accessor is inaccessible

The compiler refuses the last top-level line: the status can only change through Assign, which keeps it consistent with AssignedTo. That is encapsulation at work. The = "New" after a property is an initial value. The ? in string? says the property may be null until a ticket is assigned (see Nullable Reference Types and Null Safety).

Properties with logic

Why not just make the field public? Because a property can grow logic later without changing its callers: validation, a computed value, a change notification. Code that reads tariff.PricePerKwh keeps working when the property starts checking its input. In C# 14 the keyword field refers to the compiler-generated backing field, so you can add a check to one accessor and keep the other automatic.

var t = new Tariff();
t.Name = "Green Fixed";
t.PricePerKwh = 0.31m;
Console.WriteLine($"{t.Name}: {t.PricePerKwh:F2} per kWh");
t.PricePerKwh = -0.05m;

class Tariff
{
    public string Name { get; set; } = "";

    public decimal PricePerKwh
    {
        get => field;
        set
        {
            if (value < 0)
                throw new ArgumentOutOfRangeException(nameof(value), "Price cannot be negative.");
            field = value;
        }
    }
}
Green Fixed: 0.31 per kWh
Unhandled exception. System.ArgumentOutOfRangeException: Price cannot be negative. (Parameter 'value')

Inside a set accessor, value is the value being assigned. Before C# 14 you declared the backing field yourself (private decimal _price;) and wrote get => _price;; you will see that style in most existing code. A property without a setter can also be computed each time it is read, written as an expression-bodied member: public decimal PriceWithVat => PricePerKwh * 1.21m;

Constructors

A constructor is a special method with the same name as the class and no return type. It runs once, when new creates the object, and its job is to leave the object in a valid state: check the arguments and set every member that needs a value. A class can have several constructors with different parameters (overloading, see Methods and Parameters); : this(...) lets one constructor call another, so the checks live in one place.

var home = new Customer("Mei Chen", "Utrecht");
var unknown = new Customer("Ravi Kumar");

Console.WriteLine(home.Describe());
Console.WriteLine(unknown.Describe());

class Customer
{
    public string Name { get; }
    public string City { get; }

    public Customer(string name, string city)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("A customer needs a name.", nameof(name));
        Name = name;
        City = city;
    }

    public Customer(string name) : this(name, "unknown")
    {
    }

    public string Describe() => $"{Name} ({City})";
}
Mei Chen (Utrecht)
Ravi Kumar (unknown)

A class without any constructor gets an invisible parameterless one, which is why new Meter() worked in the first example. As soon as you write a constructor with parameters, that default one disappears, and new Customer() becomes a compile error: the class now insists on a name.

Primary constructors

A primary constructor (C# 12) puts the constructor parameters directly after the class name. The parameters are in scope in the whole class body: you can use them to initialize properties, or use them directly in methods. It removes the repetition of declaring, listing and assigning each value.

var meter = new Meter("MTR-0042", 412.5);
meter.Add(3.3);
meter.Add(1.2);
Console.WriteLine($"{meter.Id}: {meter.Reading:F1} kWh");

class Meter(string id, double start)
{
    public string Id { get; } = id;
    public double Reading { get; private set; } = start;

    public void Add(double kwh) => Reading += kwh;
}
MTR-0042: 417.0 kWh

Unlike a record's parameters, the parameters of a class's primary constructor do not become public properties. When a method uses one directly, the compiler stores it in a hidden private field, and that field is mutable:

var meter = new Meter("MTR-0042", 412.5);
meter.Add(3.3);
Console.WriteLine(meter.Report());

class Meter(string id, double reading)
{
    public void Add(double kwh) => reading += kwh;

    public string Report() => $"{id}: {reading:F1} kWh";
}
MTR-0042: 415.8 kWh

Object initializers

An object initializer is the { Name = value, ... } block after new: it sets accessible properties by name right after the constructor has run. It reads like a form being filled in, and you set only the properties you need; the rest keep their initial values.

var ticket = new Ticket
{
    Number = "INC-40112",
    Summary = "No power in Leiden",
    Priority = 2,
};

Console.WriteLine($"{ticket.Number} P{ticket.Priority}: {ticket.Summary}");

class Ticket
{
    public string Number { get; set; } = "";
    public string Summary { get; set; } = "";
    public int Priority { get; set; } = 3;
    public List<string> Notes { get; } = [];
}
INC-40112 P2: No power in Leiden

When the type is clear from the left-hand side, the target-typed form leaves the class name out: Ticket t = new() { Number = "INC-40112" };. A trailing comma after the last property is allowed. With { get; set; } properties, though, anyone can still change the values later. The next two features close that gap.

Init-only properties

An init-only property has an init accessor instead of set (C# 9). It can be assigned in an object initializer or a constructor, and after that it is read-only. That gives you the readable initializer syntax and an object that cannot be changed afterward: a meter reading, once taken, should not change.

var reading = new Reading { MeterId = "MTR-0042", Kwh = 412.5 };
Console.WriteLine($"{reading.MeterId}: {reading.Kwh} kWh");
reading.Kwh = 0;

class Reading
{
    public string MeterId { get; init; } = "";
    public double Kwh { get; init; }
}
example.cs(3,1): error CS8852: Init-only property or indexer 'Reading.Kwh' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.

Required members

An object initializer cannot force anyone to set a property: leave out Email and the object is created anyway, with an empty or null value. A required member, marked with the required modifier (C# 11), turns that into a compile error: every new must set it, in the initializer or through a constructor marked [SetsRequiredMembers].

var customer = new Customer { Name = "Mei Chen" };
Console.WriteLine(customer.Name);

class Customer
{
    public required string Name { get; init; }
    public required string Email { get; init; }
}
example.cs(1,20): error CS9035: Required member 'Customer.Email' must be set in the object initializer or attribute constructor.

Set both, and it compiles. Optional properties simply stay without required:

var c = new Customer { Name = "Mei Chen", Email = "mei.chen@example.com" };
Console.WriteLine($"{c.Name} <{c.Email}>, newsletter: {c.Newsletter}");

class Customer
{
    public required string Name { get; init; }
    public required string Email { get; init; }
    public bool Newsletter { get; init; }
}
Mei Chen <mei.chen@example.com>, newsletter: False

required checks presence, not content: Email = "" still compiles. Validation of values belongs in a constructor or a property setter. The combination required ... { get; init; } is the modern way to write a small data class that must be complete and cannot change. When you also want value equality, a record does all of that in one line.

Static members

Most members belong to an object: each meter has its own Id. A static member belongs to the class itself and is shared by all objects; you reach it through the class name (Ticket.Created), not through an instance. This is the C# form of a static method or class attribute.

var first = new Ticket("No power in Leiden");
var second = new Ticket("Smart meter offline");

Console.WriteLine(first.Number);
Console.WriteLine(second.Number);
Console.WriteLine(Ticket.Created);

class Ticket
{
    private static int nextNumber = 40112;

    public static int Created { get; private set; }

    public string Number { get; }
    public string Summary { get; }

    public Ticket(string summary)
    {
        Number = $"INC-{nextNumber++}";
        Summary = summary;
        Created++;
    }
}
INC-40112
INC-40113
2

A static method has no this: it cannot touch instance members unless it is given an object. A static class can contain only static members and can never be instantiated; it is the place for helper methods and constants that do not need an object, like the built-in Math and Console classes.

Console.WriteLine(Energy.KwhToMj(10));
Console.WriteLine(Energy.VatRate);
Console.WriteLine(Energy.WithVat(100m).ToString("F2"));

static class Energy
{
    public const decimal VatRate = 0.21m;
    public static readonly DateOnly TariffStart = new(2026, 1, 1);

    public static double KwhToMj(double kwh) => kwh * 3.6;

    public static decimal WithVat(decimal amount) => amount * (1 + VatRate);
}
36
0.21
121.00

A const is a compile-time constant: its value is baked into every place that uses it, so it suits values that will never change (and only numbers, characters, strings, bool and enums can be constants). A static readonly field is set once at run time and can hold any type, such as a DateOnly.

ToString and printing objects

Every class inherits from object, and with it a ToString() method that returns the type name. Override it to give objects a useful text form; Console.WriteLine and string interpolation call it automatically. The keyword override is explained on Inheritance, Interfaces and Polymorphism.

var meter = new Meter { Id = "MTR-0042", Kwh = 412.5 };
Console.WriteLine(meter);
Console.WriteLine($"Last reading: {meter}");

class Meter
{
    public string Id { get; set; } = "";
    public double Kwh { get; set; }

    public override string ToString() => $"{Id} ({Kwh:F1} kWh)";
}
MTR-0042 (412.5 kWh)
Last reading: MTR-0042 (412.5 kWh)

ToString() is the C# counterpart of Python's __str__. Keep it short and free of side effects: debuggers and log statements call it too.

Designing a class

A few habits keep classes easy to use and hard to misuse:

  • Make invalid states impossible. Check arguments in the constructor or setter, and give properties a valid starting value, so an object is correct from the moment it exists.
  • Expose as little as possible. Start with private fields and get-only or private set properties; widen access only when another class really needs it.
  • Change state through methods with a meaning. ticket.Assign("Amara Okafor") says what happens and keeps related values consistent; two separate property assignments do not.
  • Prefer immutable data. For values that should not change after creation, use get-only or init properties, or a record.
  • One class, one job. A Meter that also sends invoices and writes log files is hard to test and change; split responsibilities over small classes.