Introduction

Most explanations of Object-Oriented Programming (OOP) rely on examples such as Animal/Dog or Shape/Circle. These examples are useful for learning syntax, but they do not always demonstrate why OOP concepts matter in production applications.

Enterprise HR and Payroll systems provide a more realistic example. Consider a multi-level leave approval workflow where employees submit requests, managers approve them, and the application must protect the request's state throughout the process.

This scenario provides a practical way to understand five core OOP concepts in C#:

It also helps clarify the difference between an abstract class and an interface.

Class and Object: Definition vs. Real Data

In an HR or Payroll system, a Leave Request represents an actual business record submitted by an employee.

A class defines the structure and behavior that every leave request can have:

class LeaveRequest
{
    public string EmpID;
    public string LeaveRequestID;
    public DateTime FromDate;
    public DateTime ToDate;
    public string Reason;
    public int Status;
}

The class is a blueprint. It does not represent one particular employee's request.

An object is a specific instance of that class containing actual data:

LeaveRequest request = new LeaveRequest
{
    EmpID = "1023",
    LeaveRequestID = "LR1001",
    FromDate = new DateTime(2026, 9, 20),
    ToDate = new DateTime(2026, 9, 22),
    Reason = "Personal",
    Status = 0
};

There may be one LeaveRequest class definition in the application, but thousands of LeaveRequest objects can exist at runtime.

The distinction is simple:

This same relationship appears throughout enterprise applications: an Employee class can produce many employee objects, an Invoice class can produce many invoices, and a LeaveRequest class can produce many leave requests.

Abstraction: Hiding Implementation Complexity

When an employee submits a leave request, the application may perform many operations behind the scenes:

The employee or calling code does not need to understand all of these implementation details.

Instead, the application can expose a simple operation:

public bool SubmitLeaveRequest(LeaveRequest request)
{
    // Validation
    // Database operation
    // Transaction handling
    // Exception handling

    return true;
}

The caller only needs to know that it can call SubmitLeaveRequest() and receive a result.

This is Abstraction: exposing the functionality that a caller needs while hiding implementation details that are not relevant to the caller.

For example:

bool submitted = service.SubmitLeaveRequest(request);

The calling code does not need to know whether the service uses Entity Framework Core, ADO.NET, a stored procedure, or another persistence mechanism.

The implementation can change without requiring every caller to understand those internal details.

Encapsulation: Protecting Business-Critical State

Encapsulation becomes particularly important in an approval workflow.

Suppose a leave request moves through several approval levels. The application must prevent arbitrary code from changing its status.

If Status is publicly writable:

request.Status = 1;

any part of the application could potentially mark the request as approved without completing the required workflow.

In an HR or Payroll system, this can create downstream problems because approval status may influence attendance processing or payroll calculations.

Instead, the object should control how its state changes:

class LeaveRequest
{
    private int status;

    public int Status
    {
        get { return status; }
        private set { status = value; }
    }

    public void ApproveLevel(int currentSeqNo, int nextSeqNo)
    {
        if (currentSeqNo == nextSeqNo)
        {
            Status = 1;
        }
    }
}

The important part is private set.

Other classes can read the status:

int status = request.Status;

But they cannot directly change it:

request.Status = 1; // Not allowed

The request itself controls the state transition.

This is Encapsulation: keeping an object's internal state protected and allowing changes through controlled operations.

In a production application, the approval method would normally contain more comprehensive workflow rules, such as validating the current approval level, checking the approver's authorization, recording the approval event, and moving the request to the next state.

Inheritance: Sharing Common Request Behavior

Enterprise applications commonly contain several types of requests.

For example:

Many of these requests share common information such as employee ID, submission date, and workflow state.

Instead of duplicating that structure in every class, common functionality can be placed in a base class.

abstract class BaseRequest
{
    public string EmpID { get; set; }
    public int Status { get; protected set; }
    public DateTime SubmittedDate { get; set; }

    public void ApproveLevel(int currentSeqNo, int nextSeqNo)
    {
        if (currentSeqNo == nextSeqNo)
        {
            Status = 1;
        }
    }
}

class LeaveRequest : BaseRequest
{
    public DateTime FromDate { get; set; }
    public DateTime ToDate { get; set; }
    public string Reason { get; set; }
}

class AdvanceRequest : BaseRequest
{
    public decimal AdvanceAmount { get; set; }
    public string Purpose { get; set; }
}

LeaveRequest and AdvanceRequest automatically receive the common members defined by BaseRequest.

They only need to define the properties specific to their own business domain.

This is the practical value of Inheritance: common structure and behavior can be defined once and reused by related types.

The abstract keyword also prevents BaseRequest from being instantiated directly:

BaseRequest request = new BaseRequest(); // Not allowed

Instead, the application works with concrete request types such as LeaveRequest or AdvanceRequest.

Polymorphism: Different Behavior Through a Common Type

Different request types may require different approval messages.

A leave request could say:

Your leave from September 20 to September 22 is approved.

An advance request could say:

Your advance of ₹25,000 is approved and will reflect in the next payroll.

A common method can be declared in the base class and overridden by derived classes:

abstract class BaseRequest
{
    public string EmpID { get; set; }
    public int Status { get; protected set; }

    public abstract string GetApprovalMessage();
}

class LeaveRequest : BaseRequest
{
    public DateTime FromDate { get; set; }
    public DateTime ToDate { get; set; }

    public override string GetApprovalMessage()
    {
        return $"Your leave from {FromDate:d} to {ToDate:d} is approved.";
    }
}

class AdvanceRequest : BaseRequest
{
    public decimal AdvanceAmount { get; set; }

    public override string GetApprovalMessage()
    {
        return $"Your advance of {AdvanceAmount:C} is approved and will reflect in the next payroll.";
    }
}

Now the application can work with the common BaseRequest type:

List<BaseRequest> requests = new()
{
    new LeaveRequest
    {
        FromDate = new DateTime(2026, 9, 20),
        ToDate = new DateTime(2026, 9, 22)
    },

    new AdvanceRequest
    {
        AdvanceAmount = 25000
    }
};

foreach (BaseRequest request in requests)
{
    Console.WriteLine(request.GetApprovalMessage());
}

The calling code does not need to determine whether each object is a LeaveRequest or an AdvanceRequest.

When GetApprovalMessage() is called, C# dispatches the call to the implementation belonging to the actual object.

That is Polymorphism: the same interface or base-class operation can produce different behavior depending on the object's runtime type.

This becomes increasingly valuable as an application adds more request types.

Interface vs. Abstract Class

The distinction between an abstract class and an interface is another important OOP concept.

An abstract class can contain shared implementation and state:

abstract class BaseRequest
{
    public string EmpID { get; set; }

    public void LogSubmission()
    {
        Console.WriteLine("Request submitted.");
    }

    public abstract string GetApprovalMessage();
}

The base class provides functionality that derived classes can reuse while also requiring them to implement specific behavior.

An interface primarily defines a contract:

interface IApprovalNotifiable
{
    string GetApprovalMessage();
}

A class implementing the interface must provide the required member:

class LeaveRequest : IApprovalNotifiable
{
    public string GetApprovalMessage()
    {
        return "Leave request approved.";
    }
}

The practical distinction is:

Use an abstract class when:

Use an interface when:

For example, LeaveRequest and AdvanceRequest can reasonably inherit from BaseRequest because they are both workflow requests.

An IApprovalNotifiable interface, however, could be implemented by a request, notification service, or another component that simply needs to provide an approval message.

Why These Concepts Matter in Real Applications

The value of OOP becomes clearer when the concepts are connected to actual business requirements.

In a leave approval workflow:

OOP Concept

Practical Application

Class

Defines the structure and behavior of a leave request

Object

Represents one employee's actual leave request

Abstraction

Hides database, validation, and transaction complexity

Encapsulation

Protects approval status and controls state changes

Inheritance

Shares common workflow functionality across request types

Polymorphism

Allows different request types to provide their own behavior

These concepts are not merely academic rules.

They help address real software-design problems:

Conclusion

OOP becomes easier to understand when its concepts are connected to business workflows rather than isolated examples.

A multi-level HR or Payroll approval process provides a practical illustration:

Developers working on HR, Payroll, procurement, expense management, or other workflow-driven applications are likely to encounter these design problems regularly. Understanding the OOP concepts behind those problems makes it easier to design code that is maintainable, extensible, and aligned with the business domain.