← Open in the full interactive course (progress tracking, search & more)

A payroll system is the textbook case for OOP — different kinds of employees, one shared contract, and code that never needs to ask "what type is this?" to do the right thing.

The Expense Tracker project put classes, collections, and file I/O to work together. This project turns the spotlight fully on object-oriented design: inheritance, polymorphism, interfaces, and encapsulation, all serving one genuinely realistic problem — a company that needs to pay several different kinds of employees, each computing pay in its own way, through one uniform payroll process.

This is the scenario OOP was practically invented for. A salaried employee, an hourly employee, and a manager are all clearly "employees" — they share a name, an ID, common behavior — but each computes pay completely differently. Modeling this with a pile of if/else if checks on some "employee type" flag gets uglier every time a new employee type is added. Modeling it with inheritance and polymorphism means the payroll code never needs to know or care what specific kind of employee it's looking at.

Project Brief

Build a console application that manages a small company's employees and calculates payroll. There are at least two distinct kinds of employees with genuinely different pay rules, plus a specialized role that extends one of them further.

Requirements

Designing the Class Hierarchy

Start from the contract every employee shares — every employee, no matter how they're paid, can be asked "what do I owe you this month?" That's a perfect fit for an interface:

public interface IPayable
{
    decimal CalculatePay();
}

Next, the shared "shape" of an employee — an ID, a name, common formatting — but no shared idea of how pay is calculated, since that genuinely differs. That's exactly what an abstract class is for: it provides real, shared implementation for the parts that are the same, and forces every subclass to supply the one part that isn't.

THE HIERARCHY
interface IPayable
CalculatePay() — the contract every payable thing fulfills
abstract class Employee : IPayable
Id, Name — shared, real implementation. CalculatePay() stays abstract.

SalariedEmployee : Employee

  • AnnualSalary
  • CalculatePay() = AnnualSalary / 12

HourlyEmployee : Employee

  • HourlyRate, HoursWorked
  • CalculatePay() = HourlyRate × HoursWorked
Manager : SalariedEmployee
Bonus — CalculatePay() = base salary pay plus Bonus (a third level of inheritance)

Building It Step by Step

Step 1 — The abstract base class

public abstract class Employee : IPayable
{
    public int Id { get; }
    public string Name { get; }

    protected Employee(int id, string name)
    {
        Id = id;
        Name = name;
    }

    public abstract decimal CalculatePay();

    public virtual string GetSummary() =>
        $"#{Id} {Name,-20} {CalculatePay(),10:C}";
}

Employee implements IPayable, but leaves CalculatePay() abstract — every concrete employee type must supply its own implementation, since Employee itself has no sensible default. Its constructor is protected, not public, because Employee is never meant to be created directly — only through one of its concrete subclasses. GetSummary() is virtual, giving subclasses a sensible default they're free to override if they need to display something extra.

Step 2 — Salaried employees

public class SalariedEmployee : Employee
{
    public decimal AnnualSalary { get; }

    public SalariedEmployee(int id, string name, decimal annualSalary)
        : base(id, name)
    {
        if (annualSalary <= 0)
            throw new ArgumentException("Annual salary must be positive.", nameof(annualSalary));
        AnnualSalary = annualSalary;
    }

    public override decimal CalculatePay() => AnnualSalary / 12;
}

: base(id, name) forwards the shared fields up to the Employee constructor. override supplies the one piece Employee left abstract — a monthly salaried employee simply earns a twelfth of their annual salary, every month, regardless of hours.

Step 3 — Hourly employees, with encapsulated validation

public class HourlyEmployee : Employee
{
    public decimal HourlyRate { get; }

    private decimal _hoursWorked;
    public decimal HoursWorked
    {
        get => _hoursWorked;
        set
        {
            if (value < 0)
                throw new ArgumentException("Hours worked cannot be negative.");
            _hoursWorked = value;
        }
    }

    public HourlyEmployee(int id, string name, decimal hourlyRate)
        : base(id, name)
    {
        HourlyRate = hourlyRate;
    }

    public override decimal CalculatePay() => HourlyRate * HoursWorked;
}

This is encapsulation in action: HoursWorked is exposed as an auto-looking property, but its setter contains real validation logic backed by a private field, _hoursWorked. Nobody outside this class can ever put it into an invalid state — trying employee.HoursWorked = -5; throws immediately, rather than silently corrupting payroll data that only surfaces as a bug much later.

Step 4 — Managers: a third level of inheritance

public class Manager : SalariedEmployee
{
    public decimal MonthlyBonus { get; set; }

    public Manager(int id, string name, decimal annualSalary, decimal monthlyBonus)
        : base(id, name, annualSalary)
    {
        MonthlyBonus = monthlyBonus;
    }

    public override decimal CalculatePay() => base.CalculatePay() + MonthlyBonus;
}

A Manager is a SalariedEmployee, which in turn is an Employee — a genuine three-level "is-a" chain, exactly the kind of relationship inheritance is meant to model. Rather than duplicating the salary-division formula, Manager.CalculatePay() calls base.CalculatePay() to reuse SalariedEmployee's logic, then adds the bonus on top. This is the same base keyword you've used since the very first inheritance lesson, now doing real work in a realistic scenario.

Step 5 — A directory that never needs to know the concrete type

public class EmployeeDirectory
{
    private readonly List<Employee> _employees = new();

    public void AddEmployee(Employee employee) => _employees.Add(employee);

    public IReadOnlyList<Employee> ListEmployees() => _employees;

    public decimal CalculateTotalPayroll()
    {
        decimal total = 0;
        foreach (var employee in _employees)
            total += employee.CalculatePay();   //  polymorphism — the right CalculatePay() runs
        return total;
    }
}

This is the payoff of the whole design. _employees is a List<Employee> — it can hold SalariedEmployee, HourlyEmployee, and Manager objects all mixed together, because every one of them is an Employee. When CalculateTotalPayroll calls employee.CalculatePay(), the CLR looks at the object's actual runtime type and calls the correct override — a manager's bonus gets added, an hourly worker's hours get multiplied — without EmployeeDirectory ever writing a single if (employee is Manager) check. Add a brand-new employee type next year, and this method needs zero changes.

Complete Solution

// ── Contract ──
public interface IPayable
{
    decimal CalculatePay();
}

// ── Base class ──
public abstract class Employee : IPayable
{
    public int Id { get; }
    public string Name { get; }

    protected Employee(int id, string name)
    {
        Id = id;
        Name = name;
    }

    public abstract decimal CalculatePay();

    public virtual string GetSummary() => $"#{Id} {Name,-20} {CalculatePay(),10:C}";
}

// ── Salaried employees ──
public class SalariedEmployee : Employee
{
    public decimal AnnualSalary { get; }

    public SalariedEmployee(int id, string name, decimal annualSalary) : base(id, name)
    {
        if (annualSalary <= 0)
            throw new ArgumentException("Annual salary must be positive.", nameof(annualSalary));
        AnnualSalary = annualSalary;
    }

    public override decimal CalculatePay() => AnnualSalary / 12;
}

// ── Hourly employees ──
public class HourlyEmployee : Employee
{
    public decimal HourlyRate { get; }

    private decimal _hoursWorked;
    public decimal HoursWorked
    {
        get => _hoursWorked;
        set
        {
            if (value < 0)
                throw new ArgumentException("Hours worked cannot be negative.");
            _hoursWorked = value;
        }
    }

    public HourlyEmployee(int id, string name, decimal hourlyRate) : base(id, name)
    {
        HourlyRate = hourlyRate;
    }

    public override decimal CalculatePay() => HourlyRate * HoursWorked;
}

// ── Managers — inherit from SalariedEmployee, not directly from Employee ──
public class Manager : SalariedEmployee
{
    public decimal MonthlyBonus { get; set; }

    public Manager(int id, string name, decimal annualSalary, decimal monthlyBonus)
        : base(id, name, annualSalary)
    {
        MonthlyBonus = monthlyBonus;
    }

    public override decimal CalculatePay() => base.CalculatePay() + MonthlyBonus;

    public override string GetSummary() => base.GetSummary() + " (Manager)";
}

// ── Directory / payroll service ──
public class EmployeeDirectory
{
    private readonly List<Employee> _employees = new();

    public void AddEmployee(Employee employee) => _employees.Add(employee);

    public IReadOnlyList<Employee> ListEmployees() => _employees;

    public decimal CalculateTotalPayroll()
    {
        decimal total = 0;
        foreach (var employee in _employees)
            total += employee.CalculatePay();
        return total;
    }
}

// ── Program ──
class Program
{
    static void Main()
    {
        var directory = new EmployeeDirectory();

        directory.AddEmployee(new SalariedEmployee(1, "Priya Shah", 84000m));
        directory.AddEmployee(new HourlyEmployee(2, "Sam Rivera", 22.50m) { HoursWorked = 160 });
        directory.AddEmployee(new Manager(3, "Jordan Lee", 108000m, 500m));

        bool running = true;
        while (running)
        {
            Console.WriteLine();
            Console.WriteLine("=== Employee Payroll System ===");
            Console.WriteLine("1. List all employees");
            Console.WriteLine("2. Show total payroll");
            Console.WriteLine("3. Add hourly employee");
            Console.WriteLine("4. Exit");
            Console.Write("Choose an option: ");

            switch (Console.ReadLine())
            {
                case "1":
                    foreach (var employee in directory.ListEmployees())
                        Console.WriteLine(employee.GetSummary());
                    break;

                case "2":
                    Console.WriteLine($"Total monthly payroll: {directory.CalculateTotalPayroll():C}");
                    break;

                case "3":
                    AddHourlyEmployeeFlow(directory);
                    break;

                case "4":
                    running = false;
                    break;

                default:
                    Console.WriteLine("Not a valid option, try again.");
                    break;
            }
        }
    }

    static void AddHourlyEmployeeFlow(EmployeeDirectory directory)
    {
        Console.Write("Name: ");
        string name = Console.ReadLine() ?? "";

        Console.Write("Hourly rate: ");
        if (!decimal.TryParse(Console.ReadLine(), out decimal rate))
        {
            Console.WriteLine("Invalid rate. Employee not added.");
            return;
        }

        Console.Write("Hours worked this period: ");
        if (!decimal.TryParse(Console.ReadLine(), out decimal hours))
        {
            Console.WriteLine("Invalid hours. Employee not added.");
            return;
        }

        try
        {
            var employee = new HourlyEmployee(new Random().Next(1000, 9999), name, rate)
            {
                HoursWorked = hours
            };
            directory.AddEmployee(employee);
            Console.WriteLine($"Added {employee.Name}, pay this period: {employee.CalculatePay():C}");
        }
        catch (ArgumentException ex)
        {
            Console.WriteLine($"Could not add employee: {ex.Message}");
        }
    }
}

Run this, choose option 1, and you'll see three completely different pay calculations — a fixed monthly salary, an hours-times-rate calculation, and a salary-plus-bonus calculation — all produced by calling the exact same method name, CalculatePay(), on a list typed simply as List<Employee>. That's polymorphism doing real, useful work.

Try It Yourself — Extension Challenges

Each of these builds directly on the class hierarchy above, reusing only concepts you already know.

Challenge 1 — Add a PartTimeEmployee type. A part-time employee is paid hourly, like HourlyEmployee, but pay above 20 hours per period is capped (no overtime).

Hint

Decide whether PartTimeEmployee should inherit from Employee directly, or from HourlyEmployee and override CalculatePay() to apply the cap. Inheriting from HourlyEmployee and overriding lets you reuse its HoursWorked validation for free — a good demonstration of why "favor a shallow, sensible hierarchy" matters.

Challenge 2 — Add an IRaisable interface with a GiveRaise(decimal percentage) method, implemented differently by salaried vs hourly employees (one raises the annual salary, the other raises the hourly rate).

Hint

Since AnnualSalary and HourlyRate are currently read-only (get-only) properties set only in the constructor, you'll need to change them to have a private set so GiveRaise can modify them internally while still preventing external code from setting them directly.

Challenge 3 — Remove an employee by Id. Add a directory method and menu option to remove an employee.

Hint

Loop through _employees to find a match by Id first, store it in a variable, then call _employees.Remove(match) after the loop — don't try to remove while still inside the foreach that's searching.

Challenge 4 — Persist the employee directory to a file, the same way the Expense Tracker project saved its data, using System.Text.Json.

Hint

This one is trickier than it looks: System.Text.Json can't automatically tell which concrete subclass (SalariedEmployee, HourlyEmployee, or Manager) to recreate when deserializing a list typed as Employee. As a starting point, try saving and loading each employee type as its own separate list (three files, or three properties in one saved object) rather than one mixed List<Employee> — a clean way to sidestep the polymorphic-deserialization problem for now.

Challenge 5 — Sort employees by pay from highest to lowest before listing them, without using LINQ.

Hint

Copy the employees into a new List<Employee>, then implement a simple bubble sort or selection sort by hand: repeatedly compare adjacent employees' CalculatePay() results and swap them if they're in the wrong order. It's not the most efficient approach, but it's a great way to practice loops and comparisons on real objects.

You've built a real class hierarchy with inheritance, interfaces, encapsulation, and polymorphism all cooperating — the exact set of OOP skills professional C# codebases lean on every day.


dotnetmadeeasy.com — Learn C# and .NET, the right way.