C# was designed by Microsoft as an object-oriented language from the ground up, making it one of the most powerful tools for building scalable, maintainable applications. If you’re coming from procedural programming or just want to understand how OOP actually works in C#—not just the theory—you’re in the right place. Object-oriented programming organizes code around four core pillars: Encapsulation, Abstraction, Inheritance, and Polymorphism. This guide walks through each concept with practical code examples that show how C# implements OOP principles. By the end, you’ll understand not just what these terms mean, but how to use them to write better C# code.

Understanding Classes and Objects in C#

Think of a class as a blueprint for a house. The blueprint defines what rooms exist, how many windows there are, and where the plumbing goes. But you can’t live in a blueprint. You need to build actual houses from it. In C#, classes work the same way—they’re blueprints that define the structure and behavior of data, while objects are the actual instances you create and work with at runtime.

Creating Your First Class

A class in C# combines data (properties or fields) with behavior (methods). Here’s a practical example of a Car class:

public class Car
{
    // Properties define what data the class holds
    public string Make { get; set; }
    public string Model { get; set; }
    public int Year { get; set; }
    
    // Methods define what the class can do
    public void Start()
    {
        Console.WriteLine($"{Make} {Model} is starting...");
    }
}

To create an object from this class, you instantiate it using the new keyword:

Car myCar = new Car();
myCar.Make = "Toyota";
myCar.Model = "Camry";
myCar.Year = 2023;
myCar.Start(); // Output: Toyota Camry is starting...

Each object you create is independent. You could create a second Car object with completely different values, and changes to one won’t affect the other.

Working with Constructors

Setting properties one by one gets tedious fast. Constructors solve this by letting you initialize objects when they’re created:

public class Car
{
    public string Make { get; set; }
    public string Model { get; set; }
    public int Year { get; set; }
    
    // Constructor with parameters
    public Car(string make, string model, int year)
    {
        this.Make = make;
        this.Model = model;
        this.Year = year;
    }
}

The this keyword refers to the current instance of the class, distinguishing between the constructor parameters and the class properties. Now you can create a fully initialized object in one line:

Car myCar = new Car("Toyota", "Camry", 2023);

You can define multiple constructors with different parameters, giving users flexibility in how they create objects. This is called constructor overloading and it’s a common pattern in C# development.

Encapsulation: Protecting Your Data

Encapsulation keeps your class internals safe from unintended modification by controlling what other parts of your code can see and change. Think of it as building a fortress around your data with carefully guarded gates that determine who gets in and how.

Access Modifiers Explained

C# provides five access modifiers that control the visibility of your class members:

  • public – Accessible from anywhere in your application
  • private – Only accessible within the same class (the default for class members)
  • protected – Accessible within the class and its derived classes
  • internal – Accessible within the same assembly
  • protected internal – Accessible within the same assembly or from derived classes
public class BankAccount
{
    private decimal balance;  // Hidden from outside access
    protected string accountType;  // Available to derived classes
    internal int accountNumber;  // Available within the assembly
    
    public string Owner { get; set; }  // Publicly accessible
}

Using Properties for Encapsulation

Properties give you controlled access to private fields through get and set accessors. Instead of exposing fields directly, you create a public interface that validates data and protects your internal state.

public class BankAccount
{
    private decimal balance;
    
    public decimal Balance
    {
        get { return balance; }
        private set 
        { 
            if (value < 0)
                throw new ArgumentException("Balance cannot be negative");
            balance = value;
        }
    }
    
    public void Deposit(decimal amount)
    {
        if (amount > 0)
            Balance += amount;
    }
}

This approach prevents direct manipulation of balance while allowing safe deposits through a controlled method. The private setter ensures only the class itself can modify the balance, while external code can read it. Auto-implemented properties (public string Owner { get; set; }) provide convenient shorthand when you don’t need validation logic.

Inheritance: Building on Existing Code

Inheritance allows you to create new classes that reuse, extend, and modify the behavior defined in existing classes. In C#, a derived class automatically inherits all accessible members from its base class, including fields, properties, and methods. Unlike some other languages, C# enforces single inheritance for classes—a class can inherit from only one base class, which prevents the complexity and ambiguity of multiple inheritance scenarios.

Creating Derived Classes

To create a derived class, use the colon syntax followed by the base class name. When you instantiate a derived class, C# first calls the base class constructor, then the derived class constructor. Here’s a practical example:

public class Vehicle
{
    public string Brand { get; set; }
    public int Year { get; set; }
    
    public Vehicle(string brand, int year)
    {
        Brand = brand;
        Year = year;
    }
    
    public void Start()
    {
        Console.WriteLine($"{Brand} is starting...");
    }
}

public class Car : Vehicle
{
    public int NumberOfDoors { get; set; }
    
    public Car(string brand, int year, int doors) : base(brand, year)
    {
        NumberOfDoors = doors;
    }
    
    public void Honk()
    {
        Console.WriteLine("Beep beep!");
    }
}

The Car class inherits all public members from Vehicle and adds its own specific functionality. The base keyword explicitly calls the parent constructor to initialize inherited properties.

When to Use Sealed Classes

The sealed keyword prevents other classes from inheriting from a class. Apply it when you want to protect the integrity of a class design or optimize performance—sealed classes allow the compiler to make certain optimizations.

public sealed class Configuration
{
    public string AppName { get; set; }
    // No class can inherit from Configuration
}

Use sealed for classes that contain security-critical code or when further inheritance would break the intended functionality. Most developers keep classes unsealed by default and add sealed only when there’s a specific reason.

Polymorphism: One Interface, Multiple Forms

Polymorphism lets you write code that works with objects through their base type while each object responds according to its actual type. This flexibility is essential when building extensible systems where you want to process different objects uniformly without knowing their specific implementations at compile time.

Compile-Time Polymorphism (Overloading)

Method overloading provides compile-time polymorphism by allowing multiple methods with the same name but different parameter signatures. The compiler determines which method to call based on the arguments you pass:

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

var calc = new Calculator();
int result1 = calc.Add(5, 10);        // Calls int version
double result2 = calc.Add(5.5, 10.2); // Calls double version
int result3 = calc.Add(1, 2, 3);      // Calls three-parameter version

This approach is useful for providing convenient APIs where the same operation works with different data types or parameter combinations.

Runtime Polymorphism (Overriding)

Runtime polymorphism uses the virtual and override keywords to allow derived classes to provide specific implementations that execute based on the object’s actual type:

public class PaymentProcessor
{
    public virtual decimal ProcessFee(decimal amount) => amount * 0.03m;
}

public class PremiumProcessor : PaymentProcessor
{
    public override decimal ProcessFee(decimal amount) => amount * 0.01m;
}

public class CryptoProcessor : PaymentProcessor
{
    public override decimal ProcessFee(decimal amount) => amount * 0.05m;
}

PaymentProcessor processor = new PremiumProcessor();
decimal fee = processor.ProcessFee(100); // Returns 1.00, not 3.00

This pattern enables treating different derived classes through a common base type reference. When you change processor to reference a different subclass, the behavior changes automatically without modifying the calling code. This is fundamental for plugin architectures, strategy patterns, and any system requiring dynamic behavior selection.

Abstraction with Abstract Classes and Interfaces

C# provides two primary mechanisms for implementing abstraction: abstract classes and interfaces. While both allow you to define contracts that derived types must fulfill, they serve different architectural purposes and come with distinct capabilities.

Abstract classes cannot be instantiated directly. They serve as base classes that combine partial implementation with enforced contracts. An abstract class can contain both abstract methods (methods without implementation that must be overridden) and concrete methods with full implementations. This makes them ideal when you have shared functionality alongside behavior that must vary across subclasses.

Interfaces, by contrast, traditionally defined pure contracts without any implementation. They specify what a class must do, not how to do it. A critical advantage: a class can implement multiple interfaces, bypassing C#’s single inheritance limitation for classes. Since C# 8.0, interfaces can include default implementations, blurring the line somewhat, but the core distinction remains.

Feature Abstract Class Interface
Instantiation Cannot be instantiated Cannot be instantiated
Method Types Abstract and concrete methods Abstract methods (default implementations since C# 8.0)
Fields Can contain fields and state Cannot contain instance fields
Multiple Inheritance Single inheritance only Class can implement multiple interfaces
Access Modifiers Supports all access modifiers Members are public by default
Constructors Can have constructors Cannot have constructors

When to Use Abstract Classes

Choose abstract classes when you need to share code among closely related classes. For example, a Vehicle abstract class might implement common properties like Speed and Color while declaring an abstract Start() method that each vehicle type implements differently. Abstract classes work best when you’re modeling an “is-a” relationship with shared state.

When to Use Interfaces

Use interfaces when you need to define capabilities that unrelated classes might share. For instance, ISerializable, IComparable, and IDisposable define behaviors that any class might need, regardless of inheritance hierarchy. Interfaces excel at defining contracts for dependency injection and enabling flexible, testable architectures.

The Four Pillars in Action: A Real-World Example

Let’s build a payment processing system that demonstrates how encapsulation, abstraction, inheritance, and polymorphism work together in production code. This example models real-world scenarios you’d encounter in e-commerce applications.

// Abstraction: Define what payment processors must do
public abstract class PaymentProcessor
{
    // Encapsulation: Private field with protected access
    protected decimal transactionFee;
    
    protected PaymentProcessor(decimal fee)
    {
        transactionFee = fee;
    }
    
    // Abstract method forces implementation in derived classes
    public abstract bool ProcessPayment(decimal amount);
    
    // Virtual method allows optional overriding (Polymorphism)
    public virtual decimal CalculateFinalAmount(decimal amount)
    {
        return amount + transactionFee;
    }
}

// Inheritance: CreditCardProcessor inherits from PaymentProcessor
public class CreditCardProcessor : PaymentProcessor
{
    private string cardNumber; // Encapsulation: private data
    
    public CreditCardProcessor(string card) : base(2.50m)
    {
        cardNumber = MaskCardNumber(card);
    }
    
    // Polymorphism: Override abstract method
    public override bool ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing ${amount} via Credit Card");
        return amount <= 10000; // Simulate validation
    }
    
    private string MaskCardNumber(string card)
    {
        return "****" + card.Substring(card.Length - 4);
    }
}

// Inheritance: Another concrete implementation
public class PayPalProcessor : PaymentProcessor
{
    private string email;
    
    public PayPalProcessor(string userEmail) : base(1.75m)
    {
        email = userEmail;
    }
    
    // Polymorphism: Different implementation, same interface
    public override bool ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing ${amount} via PayPal for {email}");
        return true;
    }
    
    // Polymorphism: Override virtual method for custom behavior
    public override decimal CalculateFinalAmount(decimal amount)
    {
        // PayPal waives fees for amounts under $50
        return amount < 50 ? amount : base.CalculateFinalAmount(amount);
    }
}

// Usage demonstrating polymorphism in action
class Program
{
    static void Main()
    {
        List<PaymentProcessor> processors = new List<PaymentProcessor>
        {
            new CreditCardProcessor("1234567890123456"),
            new PayPalProcessor("[email protected]")
        };
        
        foreach (var processor in processors)
        {
            decimal total = processor.CalculateFinalAmount(75.00m);
            processor.ProcessPayment(total);
        }
    }
}

This example shows how the four pillars create maintainable, extensible code. Encapsulation protects sensitive data like card numbers. Abstraction defines the payment contract. Inheritance eliminates duplicate code across payment types. Polymorphism lets you treat different processors uniformly while maintaining their unique behaviors.

Putting It All Together

The four pillars of OOP—encapsulation, abstraction, inheritance, and polymorphism—aren't just academic concepts. They're practical tools that help you write maintainable, scalable C# applications. C# implements these principles with clarity and power, which is one reason it's trusted by over 5 million developers worldwide for everything from enterprise systems to game development with Unity.

Encapsulation protects your data and creates clean interfaces. Abstraction lets you define contracts without worrying about implementation details. Inheritance eliminates code duplication and creates logical hierarchies. Polymorphism gives you the flexibility to write code that works with many types through a single interface. Together, these concepts transform how you approach software design.

The best way to solidify your understanding is through practice. Start by building your own classes for real-world scenarios—model a library system, a shopping cart, or an inventory manager. Experiment with inheritance hierarchies and see how polymorphism simplifies your code. Try refactoring procedural code into object-oriented designs and observe the benefits firsthand.

From here, explore more advanced C# features like generics, LINQ, and async/await patterns. Dive into design patterns like Factory, Strategy, and Observer that leverage OOP principles. Consider learning dependency injection and unit testing to build professional-grade applications. The OOP foundation you've built here will support everything you learn next in your C# journey.