Introduction

Even in today’s world, Object-Oriented Programming (OOP) remains the backbone of modern software development.

Whether you’re building enterprise applications in .NET, designing front-end systems in Angular (or any related SPA framework), or structuring microservices, the four pillars of OOP – Abstraction, Encapsulation, Polymorphism, and Inheritance – are the things to build scalable, maintainable, readable, and flexible software.

So, let’s try to explore OOP by answering the questions: what it is, why we need it, and the four pillars of OOP.

Okay, let’s get started.

What is Object-Oriented Programming?

It is safe to say that OOP is a programming paradigm that heavily relies on the concept of objects, which represent the real world. So, it’s a programming pattern or style.

If we try to dismantle the words, first, the meaning of “object” is to think of it as an entity in the real world, like a car or a motorcycle.

Second, the meaning of “oriented” means interested in a particular kind of thing or entity.

Once you have learned this kind of paradigm, you’ll experience a mental shift when structuring your programs. Hopefully, you will be able to create reusable blocks (classes) that you can then use to make individual instances as objects at runtime.

Why Do We Need Object-Oriented Programming?

As developers, when we try to think of an answer to this question, we often focus on the features of OOP, but that isn’t really the case.

However, it’s about adopting a mentality that thinks in terms of “Real World Objects”.

It translates them into code representations (OOP), and because of this mentality, your code at least syncs with the real world.

So, let’s say you’re trying to improve your Inventory System in a given application.

You would likely consider a product, product category, or order as a promising candidate for a class, with properties or attributes representing real-world objects.

The Four Pillars of Object-Oriented Programming

Data Abstraction (Show only what’s necessary)

When dealing with abstraction, think of it as the process of presenting only the essential features of an object to the outside world and concealing irrelevant information.

Moreover, data abstraction is a process; a process of formulating concepts by extracting shared features from specific objects.

Furthermore, this process is a thought process that teaches your mind to conceptualize objects (also known as a design phase). It occurs when gathering or understanding requirements, and then, upon implementation (execution phase), “encapsulation” comes into play.

Thus, abstraction helps developers focus on a system’s functionality rather than its implementation, allowing us to utilize abstract classes and interfaces.

Let’s try an example.

public interface IGenericRepository<T> where T: class
{
    Task<IReadOnlyList<T>> GetAsync<TKey>(Expression<Func<T, TKey>>? orderBy = null,
                                                  bool ascending = true, CancellationToken ct = default);
    Task<T> GetByIdAsync(int id, CancellationToken ct = default);
    IQueryable<T> FindByCondition(Expression<Func<T, bool>> expression);
    Task CreateAsync(T entity, CancellationToken ct = default);
    Task UpdateAsync(T entity, CancellationToken ct = default);
    Task DeleteAsync(T entity, CancellationToken ct = default);
}

From our code example, the IGenericRepository is an interface that defines the operations that are possible but not how they’re implemented, which gives us flexibility to plug in different data sources later on, such as SQL, NoSQL, or even In-memory, without changing the calling code.

Encapsulation (Protect Data)

Now that we have a good understanding of abstraction, it is time to focus on encapsulation.

Again, during the execution phase (coding phase), developers use encapsulation by utilizing the access modifiers such as private, protected, and public.

Thus, during this execution phase, developers ensure controlled access and protect the object’s state by bundling data and methods that operate on it, using restricted direct access via access modifiers to the class’s inner workings.

Let’s try to see an example below.

namespace Bank.Model;

public class BankAccount
{
    private decimal _balance;

    protected string _AccountType = "savings";
    public string Owner { get; private set; }

    public BankAccount(string owner)
    {
        this.Owner = owner;
        this._balance = 0;
        
    }

    public void Deposit(decimal amount) => _balance += amount;

    public bool Withdraw(decimal amount)
    {
        if (amount > this._balance) return false;
        _balance -= amount;
        return true;
    }

    public decimal GetBalance() => _balance;

    protected string GetAccountInfo() =>
       $"{Owner} - {_AccountType} account with balance {_balance}";

}

public class PremiumAccount : BankAccount
{
    public PremiumAccount(string owner) : base(owner) { }

    // This class can access the protected AccountType
    public string GetPremiumInfo() => $"Premium {GetAccountInfo()}";
}


To truly appreciate the code, here’s a class diagram representation of it.

From our example, let’s examine the behavior.

Private Members

private decimal _balance;

So _balance is private, so it can’t be accessed directly outside of the class (BankAccount), which ensures that external code can’t accidentally set its value or manipulate it in unsafe ways.

From the encapsulation benefit, the internal state is protected from misuse and maintains data integrity.

Protected Members

protected string AccountType = "Savings";
protected string GetAccountInfo() => $"{Owner} - {AccountType} account with balance {_balance}";

So, the protected strings here are only accessible through derived classes, which extends functionality safety without exposing these details to everyone.

From the encapsulation benefit, subclasses get controlled access for reuse/extension without exposing sensitive data to unrelated code.

Public Members

public void Deposit(decimal amount);
public bool Withdraw(decimal amount);
public decimal GetBalance();
public string Owner { get; private set; }

These methods and properties can be accessed outside, thanks to the benefit of encapsulation, which, in their implementation, only exposes what is necessary.  However, they do not expose how the balance is stored or validated — that part is encapsulated within the class.

If you are curious, here’s the whole unit test of the class/object.

namespace Bank.Model.Test
{
    public class BankAccountTests
    {
        [Fact]
        public void Deposit_ShouldIncreaseBalance()
        {
            var account = new BankAccount("Jin");
            account.Deposit(100);

            Assert.Equal(100, account.GetBalance());
        }

        [Fact]
        public void Withdraw_ShouldDecreaseBalance_WhenFundsAvailable()
        {
            var account = new BankAccount("Jin");
            account.Deposit(200);

            var result = account.Withdraw(50);

            Assert.True(result);
            Assert.Equal(150, account.GetBalance());
        }

        [Fact]
        public void Withdraw_ShouldFail_WhenInsufficientFunds()
        {
            var account = new BankAccount("Jin");

            var result = account.Withdraw(50);

            Assert.False(result);
            Assert.Equal(0, account.GetBalance());
        }

        [Fact]
        public void PremiumAccount_ShouldAccessProtectedMembers()
        {
            var premium = new PremiumAccount("Jin");

            // Calls derived class method that internally uses protected members
            var info = premium.GetPremiumInfo();

            Assert.Contains("Premium", info);
            Assert.Contains("Jin", info);
        }

        //[Fact] ❌ This would not compile, proving private is enforced
        //public void CannotAccessPrivateBalance()
        //{
        //    var account = new BankAccount("Jin");
        //    var balance = account._balance; // ❌ compile error (private)
        //}
    }
}

Polymorphism

From the word itself, poly means many, while morph means “a transforming”, so we can say that this concept enables objects to take many forms. As a result, makes our code more extensible and flexible.

Static Polymorphism

This static polymorphism can be achieved through method overloading and operator overloading.

public class Calculator
{
    public int Add(int a, int b) => a + b;
    public double Add(double a, double b) => a + b;
}

Dynamic Polymorphism

This type of polymorphism can be achieved by method overriding, which can be done by using the “virtual” and “override” keywords, or by implementing interfaces.

public abstract class Notification
{
    public abstract void Send(string message);
}

public class EmailNotification : Notification
{
    public override void Send(string message)
    {
        Console.WriteLine($"Email sent: {message}");
    }
}

public class SmsNotification : Notification
{
    public override void Send(string message)
    {
        Console.WriteLine($"SMS sent: {message}");
    }
}

From our repository example, let’s try to define another repository related to it.

So we can use Interface-based polymorphism (Repository Example):

public interface IProductRepository: IGenericRepository<Product>
{
    Task<IReadOnlyList<Product>> ListByCategoryAsync(long categoryId, CancellationToken ct = default);
    Task<bool> NameExistsAsync(string name, int? excludeId = null, CancellationToken ct = default);
}
public interface ICategoryRepository : IGenericRepository<Category>
{
    Task<bool?> HasProductsAsync(long categoryId, CancellationToken ct = default);
}

Business logic doesn’t change—it simply works with IGenericRepository<T>.

Inheritance

In my experience, I can share that I’ve seen two ways of inheriting: the first is the inheritance chain through the use of classes, and the second is inheritance interface, which extends a contract.

Inheritance Chain

A good example of this is ASP.NET Web Forms, which leverages the inheritance chain.

A good reason for that is the lifecycle, state management, and rendering pipeline.

However, let’s see an example of an inheritance chain below.

public class Employee
{
    public string Name { get; set; }
    public virtual void Work() => Console.WriteLine("Employee working...");
}

public class Developer : Employee
{
    public override void Work() => Console.WriteLine("Developer writing code...");
}

Inheritance Interface

Honestly, I like the second one (Inheritance Interface), it is much easier to unit test your objects and easier to dismantle objects when dealing with or creating unit tests.

Let’s see an example below.

public interface IEntity
{
    Guid Id { get; }
}

public interface IAuditableEntity : IEntity
{
    DateTime CreatedAt { get; }
    DateTime UpdatedAt { get; }
}

Key Takeaways

I’ve been in numerous interviews throughout my career, and most of the time, OOP is asked; I always answer based on my experience and examples.
It is challenging to memorize everything, but understanding how these concepts work and interact with each other, especially in real-world systems, can be really beneficial.

Knowing these concepts by heart will give you a significant edge in your work as a software engineer. By applying abstraction with interfaces, encapsulation with controlled state, polymorphism for flexibility, and inheritance for reuse, you can create software that adapts and evolves in response to changing business needs.

Until next time, happy programming!

Please don’t forget to bookmark, like, and comment. Cheers! And thank you!

Leave a comment

Trending