Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 6 – Classes, objects and encapsulation

Classes, objects and constructors

Write a C# class with fields, constructors and methods, chain constructors, use object initializers and required members, and refuse bad data at creation.

  • Beginner
  • 15 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Define a class with fields, a constructor and methods, and create objects of it with new
  • Predict the order in which field initializers, chained constructors and object initializers run
  • Use object initializers and required members, and fix error CS9035
  • Validate arguments in the constructor so that every object keeps its class invariant

Before you start

What you’ll learn · Before you start

What you will learn

  • Define a class with fields, a constructor and methods, and create objects of it with new
  • Predict the order in which field initializers, chained constructors and object initializers run
  • Use object initializers and required members, and fix error CS9035
  • Validate arguments in the constructor so that every object keeps its class invariant

Before you start

On this page

ravi.Withdraw(1000m) on an account that holds 250 rupees leaves a balance of -750.00, and nothing in the class says no. A class bundles an object’s data with the only code allowed to change it, and its constructor decides what every new object starts with, so a class is where a balance below zero can be made impossible.

You will write a class with fields, constructors and methods, chain one constructor to another, set the rest with object initializers and required members, and make a constructor refuse bad arguments before the object exists.

A class describes objects, and new creates each one

account.cs declares one class, BankAccount, and creates two accounts from it. A class is a type you write. Its fields hold the data of each object, its constructor: A member that runs once when new creates an object. It has the name of its class and no return type, and it gives the new object's fields their starting values. runs when an object is created, and its methods are what an object can do. new BankAccount("Asha Rao", 1500m) creates an object, also called an instance, runs the constructor on it and hands back a reference to it:

account.cs: one class, two accounts C# · account/account.cs
// One class, two objects: each object gets its own copy of the fields, and each method works on the object it is
// called on.
var asha = new BankAccount("Asha Rao", 1500m);
var ravi = new BankAccount("Ravi Iyer", 0m);
Console.WriteLine(ravi.Describe());

asha.Deposit(500m);
asha.Withdraw(300m);
ravi.Deposit(250m);
Console.WriteLine(asha.Describe());
Console.WriteLine(ravi.Describe());

// Nothing in the class stops this withdrawal.
ravi.Withdraw(1000m);
Console.WriteLine(ravi.Describe());

class BankAccount
{
    // Fields: the data that every BankAccount object carries. Only code in this class can use them.
    private readonly string _owner;
    private decimal _balance;
    private int _transactions;

    // The constructor has the class's name and no return type. It runs once, when new creates the object.
    public BankAccount(string owner, decimal opening)
    {
        _owner = owner;
        _balance = opening;
    }

    // Methods: what an account can do. Each one works on the fields of the account it is called on.
    public void Deposit(decimal amount)
    {
        _balance += amount;
        _transactions++;
    }

    public void Withdraw(decimal amount)
    {
        _balance -= amount;
        _transactions++;
    }

    public string Describe() => $"{_owner}: balance {_balance:F2}, transactions {_transactions}";
}

Output

Ravi Iyer: balance 0.00, transactions 0
Asha Rao: balance 1700.00, transactions 2
Ravi Iyer: balance 250.00, transactions 1
Ravi Iyer: balance -750.00, transactions 2

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run account.cs

Each object has its own copy of the three fields, and each method call works on the object named before the dot. Step through the calls and watch which object changes:

Interactive · step by step One class, two objects with their own fields Step through account.cs. Variables stand on the left and the objects they refer to on the right; watch which object each call changes.
One class, two objects with their own fields, step 5 of 5. Showing asha, BankAccount object (Asha Rao, 1700.00, 2), ravi, BankAccount object (Ravi Iyer, -750.00, 2). In focus: BankAccount object.ashaBankAccount objectAsha Rao, 1700.00, 2raviBankAccount objectRavi Iyer, -750.00, 2a negative balance

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The first object. new BankAccount("Asha Rao", 1500m) creates an object and runs the constructor, which stores the owner and the opening balance. Nothing sets _transactions, so it starts at 0, the default for an int field.
  2. The second object. A second new makes a second object from the same class, with fields of its own: Ravi Iyer, a balance of 0.00 and 0 transactions, as the first line of output shows.
  3. Calls on asha. asha.Deposit(500m) and asha.Withdraw(300m) work on the fields of the object that asha refers to. Its balance becomes 1700.00 after 2 transactions; the other object does not change.
  4. A call on ravi. ravi.Deposit(250m) changes only the second object: 250.00 after 1 transaction. Each method call has one object to work on, the one before the dot.
  5. Nothing says no. ravi.Withdraw(1000m) takes 1000 from 250 and leaves -750.00. The class let it happen, because no method checks anything. A later section of this lesson adds the checks.

The first line of output shows _transactions at 0, although no code set it. A field that nothing assigns holds the default value of its type: 0 for numbers, false for bool and null for a reference. The fields are private, so only code inside BankAccount can read or change them, and code outside has to go through the methods. That is what makes the last line worth fixing: Withdraw is the only way to lower a balance, so one check in it would protect every account. It has no check yet, and ravi ended at -750.00.

Coming from Java

A C# class looks like a Java class: fields, constructors and methods, with objects created by new. C# code reads data through properties such as course.Title rather than getTitle() methods, as the next lesson shows. C# has finalizers, once called destructors, but the garbage collector decides when they run and most classes never need one; files and connections are closed with using and IDisposable, in the module on exceptions and resources.

Checkpoint

Check: Classes, objects and their own fields

  1. Question 1 of 2 In account.cs, asha.Deposit(500m) and asha.Withdraw(300m) run before any call on ravi. What would ravi.Describe() report at that moment?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Ravi Iyer: balance 0.00, transactions 0

    new creates a separate object each time, with its own copy of the instance fields, and a method changes only the object it is called on.

  2. Question 2 of 2 _transactions has no initializer, and the constructor never assigns it. What does it hold when the object is created?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 0, the default value of int

    Every instance field starts at the default of its type, which is why the first line of output showed transactions 0.

A constructor runs once, before any code can use the object

A constructor has the name of its class and no return type, not even void. It runs exactly once for each object, when new creates it, and a class may have several constructors with different parameters. A class that declares no constructor gets a public parameterless one from the compiler. Declare any constructor yourself and that one is gone: for a class whose only constructor is BankAccount(string owner, decimal opening), new BankAccount() no longer compiles.

Several constructors usually share most of their work. Instead of copying it, one constructor calls another with : this(...) after its parameter list. order.cs gives its Ticket class two constructors and prints a numbered line at each step of new Ticket("4519203761", "Asha Rao") { Coach = "B2" }. Step is a static helper that prints those lines; static members are two lessons on.

order.cs: each step of creating one ticket, numbered C# · order/order.cs
// Prints each step of creating one ticket, in the order C# runs them.
var ticket = new Ticket("4519203761", "Asha Rao") { Coach = "B2" };
Console.WriteLine($"5. new has finished: {ticket.Describe()}");

class Ticket
{
    private static int s_step;

    private readonly string _status = Step("field initializer sets _status", "booked");
    private string _coach = "not assigned";

    public Ticket(string pnr, string passenger, int farePaise)
    {
        Step($"Ticket(pnr, passenger, farePaise) runs its body, fare {farePaise}");
        Pnr = pnr;
        Passenger = passenger;
        FarePaise = farePaise;
    }

    // This constructor chains to the one above: that one runs first, then this body.
    public Ticket(string pnr, string passenger)
        : this(pnr, passenger, 0)
    {
        Step("Ticket(pnr, passenger) runs its body");
    }

    public string Pnr { get; }

    public string Passenger { get; }

    public int FarePaise { get; }

    public string Coach
    {
        get => _coach;
        set
        {
            Step($"object initializer sets Coach = {value}");
            _coach = value;
        }
    }

    public string Describe() => $"{Pnr} {Passenger}, coach {Coach}, {_status}";

    // Prints a numbered line and hands back the value it was given.
    private static T Step<T>(string what, T value)
    {
        Console.WriteLine($"{++s_step}. {what}");
        return value;
    }

    private static void Step(string what) => Step(what, 0);
}

Output

1. field initializer sets _status
2. Ticket(pnr, passenger, farePaise) runs its body, fare 0
3. Ticket(pnr, passenger) runs its body
4. object initializer sets Coach = B2
5. new has finished: 4519203761 Asha Rao, coach B2, booked

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run order.cs

Line 2 came before line 3: the constructor named by this(...) ran its whole body before the body of the constructor that new called. Line 1 shows that the field initializers ran before both bodies, and only once, although two constructors ran. That gives a rule for writing them: put every check and assignment in the constructor with the most parameters, and let the shorter ones chain to it with the values they supply, as Ticket(pnr, passenger) supplies a fare of 0.

Copying the checks into each constructor

Two constructors that each check the passenger name drift apart the first time someone fixes one of them, and tickets created through the other keep the bug. Put the checks in one constructor and chain the others to it with : this(...), so that every way of creating a ticket runs the same checks.

Checkpoint

Check: Constructors and chaining with this(...)

  1. Question 1 of 2 new Ticket("4519203761", "Asha Rao") calls Ticket(pnr, passenger), which chains with : this(pnr, passenger, 0). Which body runs first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The body of Ticket(pnr, passenger, farePaise), the constructor named by this(...)

    : this(...) calls the other constructor before the body of the constructor that new chose, so the chained body runs first and the caller's body second.

  2. Question 2 of 2 BankAccount declares one constructor, BankAccount(string owner, decimal opening). What happens to a line that says new BankAccount()?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It does not compile: the compiler adds a parameterless constructor only to a class that declares none

    The free parameterless constructor exists only while a class declares no constructor of its own. Declare one explicitly if callers really need it.

Object initializers and required members set the rest

Line 4 of order.cs came from the braces after the constructor call. An object initializer: The braces after a constructor call, as in new Ticket(pnr, name) { Coach = "B2" }. They assign fields and properties after every constructor has finished. such as { Coach = "B2" } assigns fields and properties that the caller can reach, and it runs after every constructor has finished, so a caller can set what no constructor takes. Here is the whole order, step by step:

Interactive · stage by stage What runs when new creates a ticket Step through one new expression, from empty fields to the object handed back, with the line order.cs printed at each step.
What runs when new creates a ticket, step 6 of 6. Showing Fields set to defaults, Field initializers (1. _status = "booked"), The chained constructor (2. Ticket(pnr, passenger, 0)), The constructor called (3. Ticket(pnr, passenger)), The object initializer (4. Coach = "B2"), The object handed back (5. ticket is assigned). In focus: The object handed back.Fields set to defaults0, null and falseField initializers1. _status = "booked"The chained constructor2. Ticket(pnr, passenger, 0)The constructor called3. Ticket(pnr, passenger)The object initializer4. Coach = "B2"The object handed back5. ticket is assigned

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Empty fields. Before any of your code runs, every field of the new object holds its default: 0 for numbers, null for references, false for bools.
  2. Field initializers. The initializers written on the field declarations run next, in the order they are written. order.cs printed line 1: the field initializer sets _status to "booked".
  3. this(...) first. new called Ticket(pnr, passenger), which chains with : this(pnr, passenger, 0). The constructor it chains to runs its body first, so line 2 is the three-parameter body.
  4. Then the caller's body. Only when the chained constructor has returned does the body of Ticket(pnr, passenger) run: line 3. The field initializers did not run a second time.
  5. The object initializer. The braces after the constructor call run last, after every constructor has finished: line 4 is the Coach setter, called with "B2".
  6. Ready. new hands the finished object to the variable ticket, and line 5 describes it: coach B2, booked. No code saw the object before this step.

A property declared with { get; init; } may be set by an object initializer or a constructor and never again; the next lesson covers init. An initializer is optional, though: nothing makes a caller write one. The required modifier, added in C# 11, does. Every expression that creates the object must set each required member: A field or property marked required (C# 11). Every expression that creates the object must set it in an object initializer, or the build fails with CS9035. in its object initializer. required.cs creates three passengers:

required.cs: required members set three ways C# · required/required.cs
using System.Diagnostics.CodeAnalysis;

// Three ways to create a Passenger: an object initializer that sets the required members, a constructor that
// promises to set them, and an initializer that sets an optional member as well.
var asha = new Passenger { Name = "Asha Rao", Age = 34 };
var ravi = new Passenger("Ravi Iyer", 61);
var meena = new Passenger { Name = "Meena Das", Age = 8, Berth = "lower" };

foreach (var p in new[] { asha, ravi, meena })
{
    Console.WriteLine($"{p.Name}, {p.Age}, berth: {p.Berth}");
}

class Passenger
{
    public Passenger()
    {
    }

    // SetsRequiredMembers tells the compiler that this constructor sets every required member.
    [SetsRequiredMembers]
    public Passenger(string name, int age)
    {
        Name = name;
        Age = age;
    }

    public required string Name { get; init; }

    public required int Age { get; init; }

    public string Berth { get; init; } = "any";
}

Output

Asha Rao, 34, berth: any
Ravi Iyer, 61, berth: any
Meena Das, 8, berth: lower

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run required.cs

Asha and Meena set Name and Age in their initializers, and Meena set the optional Berth as well. Ravi was created through a constructor marked [SetsRequiredMembers], which tells the compiler that this constructor sets every required member, so his new needed no initializer. The compiler takes the attribute on trust: Microsoft’s reference warns that it does not check that such a constructor really sets them. Leave the required members out, or assign an init-only property after the object exists, and the build stops:

passengers/Program.cs C# · passengers/Program.cs
// Two mistakes the compiler stops: a Passenger without its required members, and a change to an init-only property.
var upper = new Passenger { Berth = "upper" };
var ravi = new Passenger { Name = "Ravi Iyer", Age = 61 };
ravi.Name = "R. Iyer";

class Passenger
{
    public required string Name { get; init; }

    public required int Age { get; init; }

    public string Berth { get; init; } = "any";
}
Building passengers Python · required_session.py
from session import run

# passengers creates one Passenger without its two required members and changes the init-only Name of another after
# it was created. The project is built once; the compiler stops at both mistakes.
run("""
cd passengers
!dotnet build -v q
""")

Output

$ cd passengers
$ dotnet build -v q
passengers/Program.cs(2,17): error CS9035: Required member 'Passenger.Name' must be set in the object initializer or attribute constructor. [passengers/App.csproj]
passengers/Program.cs(2,17): error CS9035: Required member 'Passenger.Age' must be set in the object initializer or attribute constructor. [passengers/App.csproj]
passengers/Program.cs(4,1): error CS8852: Init-only property or indexer 'Passenger.Name' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor. [passengers/App.csproj]

Build FAILED.

passengers/Program.cs(2,17): error CS9035: Required member 'Passenger.Name' must be set in the object initializer or attribute constructor. [passengers/App.csproj]
passengers/Program.cs(2,17): error CS9035: Required member 'Passenger.Age' must be set in the object initializer or attribute constructor. [passengers/App.csproj]
passengers/Program.cs(4,1): error CS8852: Init-only property or indexer 'Passenger.Name' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor. [passengers/App.csproj]
    0 Warning(s)
    3 Error(s)

Time Elapsed …
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 required_session.py

CS9035 names each required member that the initializer left out, Name and Age. CS8852 refuses ravi.Name = "R. Iyer";, because an init-only property cannot change once the object exists. Both are compile errors, so no code that compiles can create a Passenger without saying who the passenger is.

Key idea

Field initializers run first, then the chained constructor, then the constructor that new called, and the object initializer last; required makes the compiler insist that the initializer sets the members no constructor sets.

Checkpoint

Check: Object initializers and required members

  1. Question 1 of 2 Passenger has required Name and Age properties and only a parameterless constructor. What does new Passenger { Berth = "upper" } do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The build fails with CS9035 for Name and for Age

    required moves the check to compile time: each missing member is its own CS9035 error, as the build of passengers showed.

  2. Question 2 of 2 In new Ticket("4519203761", "Asha Rao") { Coach = "B2" }, when does Coach = "B2" run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: After every constructor body has finished

    Field initializers, the chained constructor, the constructor new called, then the object initializer: the order the C# guide lists and order.cs printed.

Checks in the constructor keep every object valid

An account’s balance should never be negative, and its owner should never be blank. A rule that must hold for every object of a class at every moment is a class invariant: A rule about an object's state that holds for every object of a class whenever other code can see it, such as a balance that is never negative. Constructors make it true and methods keep it true.. The constructor is the first place to enforce it, because it runs before any other code can see the object. validate.cs starts its constructor and each method with guard clause: A check at the start of a method that returns or throws at once when an input is bad, so the code after it can assume the input is good and stays unindented. More in “if, switch statements and switch expressions” that throw when an argument is wrong:

validate.cs: an account that checks its arguments C# · validate/validate.cs
// Every attempt either creates a valid account or throws before any code can use the object.
Try("new BankAccount(\"   \", 100m)", () => new BankAccount("   ", 100m));
Try("new BankAccount(\"Ravi Iyer\", -500m)", () => new BankAccount("Ravi Iyer", -500m));

var asha = new BankAccount("Asha Rao", 1500m);
Console.WriteLine($"created {asha.Describe()}");
Try("asha.Withdraw(0m)", () => asha.Withdraw(0m));
Try("asha.Withdraw(2000m)", () => asha.Withdraw(2000m));
Try("asha.Withdraw(400m)", () => asha.Withdraw(400m));
Console.WriteLine($"now {asha.Describe()}");

static void Try(string code, Action action)
{
    Console.WriteLine(code);
    try
    {
        action();
        Console.WriteLine("   ok");
    }
    catch (ArgumentException e)
    {
        Console.WriteLine($"   {e.GetType().Name}: {e.Message.ReplaceLineEndings(" ")}");
    }
    catch (InvalidOperationException e)
    {
        Console.WriteLine($"   {e.GetType().Name}: {e.Message}");
    }
}

class BankAccount
{
    private readonly string _owner;
    private decimal _balance;

    public BankAccount(string owner, decimal opening)
    {
        // Guard clauses: check every argument before storing anything.
        ArgumentException.ThrowIfNullOrWhiteSpace(owner);
        ArgumentOutOfRangeException.ThrowIfNegative(opening);
        _owner = owner.Trim();
        _balance = opening;
    }

    public void Deposit(decimal amount)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
        _balance += amount;
    }

    public void Withdraw(decimal amount)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
        if (amount > _balance)
        {
            throw new InvalidOperationException($"cannot withdraw {amount:F2}: the balance is {_balance:F2}");
        }

        _balance -= amount;
    }

    public string Describe() => $"{_owner}: balance {_balance:F2}";
}

Output

new BankAccount("   ", 100m)
   ArgumentException: The value cannot be an empty string or composed entirely of whitespace. (Parameter 'owner')
new BankAccount("Ravi Iyer", -500m)
   ArgumentOutOfRangeException: opening ('-500') must be a non-negative value. (Parameter 'opening') Actual value was -500.
created Asha Rao: balance 1500.00
asha.Withdraw(0m)
   ArgumentOutOfRangeException: amount ('0') must be a non-negative and non-zero value. (Parameter 'amount') Actual value was 0.
asha.Withdraw(2000m)
   InvalidOperationException: cannot withdraw 2000.00: the balance is 1500.00
asha.Withdraw(400m)
   ok
now Asha Rao: balance 1100.00

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run validate.cs

Step through the three constructor calls and the withdrawal that asked for too much:

Interactive · step by step Checks that run before an account exists Step through three calls of the constructor. Each check either lets the call go on or throws, and a call that throws leaves no object behind.
Checks that run before an account exists, step 4 of 4. Showing new BankAccount(...), Owner blank?, Opening below 0?, Store the fields, The account exists (Asha Rao, 1100.00), An exception (ArgumentOutOfRangeException). In focus: The account exists.nonoyesyesnew BankAccount(...)Owner blank?Opening below 0?Store the fieldsThe account existsAsha Rao, 1100.00An exceptionArgumentOutOfRangeExceptionnever below 0

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. A blank owner. new BankAccount(" ", 100m): ThrowIfNullOrWhiteSpace finds only spaces and throws ArgumentException. The constructor stops at its first line, and new never hands back an object.
  2. A negative balance. new BankAccount("Ravi Iyer", -500m): the owner passes, and ThrowIfNegative throws ArgumentOutOfRangeException. Its message names the parameter, opening, and the value, -500.
  3. A valid account. new BankAccount("Asha Rao", 1500m) passes both checks, so the constructor stores the fields and new hands back an account with a balance of 1500.00.
  4. Methods check too. Withdraw(2000m) asks for more than the balance, so it throws InvalidOperationException before it subtracts anything. The balance stays 1500.00, and Withdraw(400m) then leaves 1100.00.

A constructor that throws never finishes, so new never hands an object to anyone, and every account that does exist has passed both checks. The same goes for the methods: Withdraw(2000m) threw before it subtracted anything, so the balance stayed 1500.00, and Withdraw(400m) then left 1100.00.

Checks in a Validate method that callers must remember

var account = new BankAccount(owner, opening); account.Validate(); leaves an invalid account in memory until someone remembers the second line, and one caller always forgets. Run the checks inside the constructor, where no path that creates an object can skip them.

The checks come from helper methods added in .NET 8, which build the message for you. The run shows that each message names the parameter, and that the two range checks name the value they rejected as well:

Helper Throws when the argument is Exception
ArgumentException.ThrowIfNullOrWhiteSpace(owner) null, empty or only white space ArgumentNullException for null, else ArgumentException
ArgumentOutOfRangeException.ThrowIfNegative(opening) below 0 ArgumentOutOfRangeException
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount) 0 or below ArgumentOutOfRangeException

A rule about the state of the object rather than one argument, such as a withdrawal larger than the balance, throws InvalidOperationException instead, as Withdraw does.

Key idea

Check every argument at the top of the constructor, before anything is stored: an object whose constructor throws is never handed to anyone, so every object that exists has passed the checks.

Checkpoint

Check: Class invariants checked in the constructor

  1. Question 1 of 2 In validate.cs, asha opens with 1500. Withdraw(2000m) throws InvalidOperationException, then Withdraw(400m) succeeds. What is the balance?

    Type a number.

    A hint

    Did the failed withdrawal change anything before it threw?

    Show the answer: Question 1 of 2

    Answer: 1100 rupees

    Withdraw checks before it subtracts, so the failed call left 1500 untouched and the second took 400, leaving 1100, as the last line of the run says.

  2. Question 2 of 2 A fare must be more than 0 paise. Which helper rejects both -100 and 0?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ArgumentOutOfRangeException.ThrowIfNegativeOrZero(farePaise)

    Choose the helper whose rule matches the invariant: ThrowIfNegative for "0 or more", ThrowIfNegativeOrZero for "more than 0".

Practise: a train ticket that refuses bad data

ticket.cs checks a TrainTicket class that you complete. Its constructor takes a PNR of 10 digits, the passenger’s name, the journey date and the fare in paise, and Cancel returns the refund in paise and marks the ticket cancelled. The refund rule written at the top of the file is made up for this exercise. The class you start from only throws, so all 8 checks fail:

ticket.cs: eight checks of TrainTicket C# · ticket/ticket.cs
// Practice: write the TrainTicket constructor and Cancel, then run this file until all eight checks pass.
// The refund rule is made up for this exercise and is no railway's real rule: cancelled 2 or more days before the
// journey, 75 % of the fare comes back; 1 day before, 50 %; on the day of the journey or later, nothing.
var journey = new DateOnly(2026, 12, 20);

(string Check, Func<string> Run, string Expected)[] checks =
[
    ("a valid ticket keeps its PNR", () => Make("4519203761", "Asha Rao", 84_500).Pnr, "4519203761"),
    ("a PNR of 5 digits", () => Make("45192", "Asha Rao", 84_500).Pnr, "throws ArgumentException"),
    ("a blank passenger name", () => Make("4519203761", "   ", 84_500).Pnr, "throws ArgumentException"),
    ("a fare of 0 paise", () => Make("4519203761", "Asha Rao", 0).Pnr, "throws ArgumentOutOfRangeException"),
    ("cancelled 5 days before", () => Valid().Cancel(new DateOnly(2026, 12, 15)).ToString(), "63375"),
    ("cancelled 1 day before", () => Valid().Cancel(new DateOnly(2026, 12, 19)).ToString(), "42250"),
    ("cancelled on the journey day", () => Valid().Cancel(journey).ToString(), "0"),
    ("cancelled twice", CancelTwice, "throws InvalidOperationException"),
];

int passed = 0;
foreach (var (check, run, expected) in checks)
{
    string got;
    try
    {
        got = run();
    }
    catch (Exception e)
    {
        got = $"throws {e.GetType().Name}";
    }

    bool ok = got == expected;
    if (ok)
    {
        passed++;
    }

    Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}

Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;

TrainTicket Make(string pnr, string passenger, long farePaise) => new(pnr, passenger, journey, farePaise);

TrainTicket Valid() => Make("4519203761", "Asha Rao", 84_500);

string CancelTwice()
{
    var ticket = Valid();
    ticket.Cancel(new DateOnly(2026, 12, 15));
    return ticket.Cancel(new DateOnly(2026, 12, 16)).ToString();
}

class TrainTicket
{
    public TrainTicket(string pnr, string passenger, DateOnly journey, long farePaise)
    {
        throw new NotImplementedException();
    }

    public string Pnr { get; }

    public string Passenger { get; }

    public DateOnly Journey { get; }

    public long FarePaise { get; }

    public bool Cancelled { get; private set; }

    // Returns the refund in paise and marks the ticket cancelled.
    public long Cancel(DateOnly today) => throw new NotImplementedException();
}

Output (exit status 1)

a valid ticket keeps its PNR: FAIL, throws NotImplementedException instead of 4519203761
a PNR of 5 digits: FAIL, throws NotImplementedException instead of throws ArgumentException
a blank passenger name: FAIL, throws NotImplementedException instead of throws ArgumentException
a fare of 0 paise: FAIL, throws NotImplementedException instead of throws ArgumentOutOfRangeException
cancelled 5 days before: FAIL, throws NotImplementedException instead of 63375
cancelled 1 day before: FAIL, throws NotImplementedException instead of 42250
cancelled on the journey day: FAIL, throws NotImplementedException instead of 0
cancelled twice: FAIL, throws NotImplementedException instead of throws InvalidOperationException
0 of 8 checks pass

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run ticket.cs

Write the constructor and Cancel

Copy ticket.cs to your computer. In the constructor, throw ArgumentException unless the PNR has 10 chars that are all digits (pnr.All(char.IsAsciiDigit)), reject a blank name with ThrowIfNullOrWhiteSpace and a fare of 0 or less with ThrowIfNegativeOrZero, then store the four values. In Cancel, throw InvalidOperationException when the ticket is already cancelled, and work out the days left with Journey.DayNumber - today.DayNumber. Run dotnet run ticket.cs until it prints 8 of 8 checks pass.

Then test yourself on objects, constructors, initializers and invariants. The questions start where your answers so far are weakest, and every choice explains why it is right or what it misses.

Practice that adapts to you

Practise this lesson

9 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 9 What does new BankAccount("Asha Rao", 1500m) give back?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: A reference to a new object with its own fields, after the constructor has run

    A class is the type and an object is one instance of it. new creates the instance and returns a reference to it.

  2. Question 2 of 9 Suppose _balance were declared public decimal Balance; as a field, with a check added to Withdraw. Which line could still make a balance negative?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: ravi.Balance = -750m; written anywhere in the program

    Keeping fields private and changing them only through methods is what lets the class enforce its rules.

  3. Question 3 of 9 Which member of class BankAccount is a constructor?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: public BankAccount(string owner, decimal opening) { … }

    A constructor has the same name as its class and no return type, and new is the only way to call it.

  4. Question 4 of 9 Ticket has a field initializer and two constructors, one chaining to the other. How many times does the field initializer run for one new Ticket(pnr, passenger)?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: Once

    However many constructors chain, the field initializers of one new expression run once, before the first body.

  5. Question 5 of 9 What may set a property declared public string Name { get; init; }?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: An object initializer or a constructor, and nothing after the object exists

    init is a set accessor that works only while the object is being created: in its constructors and in the object initializer of the new expression.

  6. Question 6 of 9 A constructor marked [SetsRequiredMembers] sets Name but forgets the required Age. What does the compiler do?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: Nothing: it trusts the attribute, so new Passenger("Asha Rao") compiles and Age is 0

    [SetsRequiredMembers] is a promise you make to the compiler. Use it only on a constructor that really assigns every required member.

  7. Question 7 of 9 Which of these is a class invariant of BankAccount in validate.cs?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: The balance is never below 0, for every account at every moment

    An invariant is a statement about an object's state that every constructor makes true and every method keeps true.

  8. Question 8 of 9 A team removes the checks from the constructor and adds a public bool IsValid() that callers should check after new. What goes wrong first?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: Invalid accounts exist and get used by any caller that skips IsValid()

    Checks in the constructor run on every path that creates an object. A separate check runs only where a caller remembers it.

  9. Question 9 of 9 validate.cs calls ArgumentOutOfRangeException.ThrowIfNegative(opening) with -500. Which part of the message tells you which argument was wrong?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: (Parameter 'opening'), followed by Actual value was -500.

    The .NET 8 helpers fill in the parameter name and the rejected value, so the message points at the argument without any extra code.

JSON to C# Class Paste a JSON order to get a C# class with its properties, then give it a constructor with the checks this lesson writes.

Interview questions

Fresher · What is the difference between a class and an object in C#?

A class is a type you declare: it describes the fields every object of it carries, the constructors that create one and the methods it offers. An object is one instance of that class, created at run time by new, with its own copy of the instance fields. One BankAccount class can give a thousand accounts, each with its own owner and balance, and a method called on one account works on that account’s fields only. A class is a reference type, so a variable of type BankAccount holds a reference to an object, not the object itself, and two variables can refer to one account. The class exists once in the compiled program, while objects come and go as the program runs.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    Orders reach your database with an empty customer id and a quantity of -3. The Order class has a public parameterless constructor and a public setter on every property. How would you redesign the way an Order is created so that an invalid one cannot exist, and what would you still let callers change?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A Shipment class has five constructors, each with its own copy of the same checks and defaults. A fix to the weight check reached two of them, and shipments created through the other three still accept a weight of 0. How do you restructure the constructors, and how do you show that nothing else changed?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A class declares the fields every object carries, the constructors that create one and the methods it offers, and new creates an object with its own copy of the instance fields, each starting at its type’s default.
  • A constructor has the class’s name and no return type; once a class declares one, the compiler stops supplying the parameterless constructor.
  • Chain constructors with : this(...) so that the checks live in one place: the chained constructor’s body runs first, and field initializers run once, before both.
  • Object initializers run after every constructor; required members must be set in them, or the build stops with CS9035, and init properties cannot change afterwards.
  • Guard clauses at the top of the constructor keep a class invariant from the first moment, because an object whose constructor throws is never handed to anyone.

Terms in this lesson

class invariant
A rule about an object's state that holds for every object of a class whenever other code can see it, such as a balance that is never negative. Constructors make it true and methods keep it true.
constructor
A member that runs once when new creates an object. It has the name of its class and no return type, and it gives the new object's fields their starting values.
object initializer
The braces after a constructor call, as in new Ticket(pnr, name) { Coach = "B2" }. They assign fields and properties after every constructor has finished.
required member
A field or property marked required (C# 11). Every expression that creates the object must set it in an object initializer, or the build fails with CS9035.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.