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#:
Class and Object
Abstraction
Encapsulation
Inheritance
Polymorphism
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:
Class → Defines what a leave request is.
Object → Represents one actual leave request.
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:
Validate the request
Check employee eligibility
Open a database connection
Execute a stored procedure or database command
Commit the transaction
Roll back if something fails
Handle exceptions
Return the result to the application
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:
Leave Request
Advance Request
Reimbursement Request
Travel Request
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:
Related types share meaningful state.
Related types share reusable implementation.
There is a genuine base-type relationship.
Use an interface when:
You want to define a capability or contract.
Unrelated classes may need the same capability.
Shared implementation or state is not the primary requirement.
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:
Duplication → Shared functionality can be centralized.
Uncontrolled state changes → Encapsulation can protect business-critical state.
Growing number of request types → Inheritance and polymorphism can provide a common structure.
Implementation complexity → Abstraction can keep callers independent of internal details.
Changing requirements → Well-defined contracts can reduce the impact of changes.
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:
A Class defines what a request contains and does.
An Object represents a real request.
Abstraction hides implementation complexity.
Encapsulation protects business-critical state.
Inheritance allows related request types to share behavior.
Polymorphism lets different request types provide their own implementations through a common type.
Interfaces define capabilities without requiring a shared class hierarchy.
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.
Join the conversation! Your thoughts help the community grow.