Overview
Object-Oriented Programming, usually called OOP, is a programming paradigm that organizes software around objects. An object represents a concept in the problem domain and combines data with behavior. In C#, objects are usually created from classes, records, or structs, and they interact through methods, properties, interfaces, events, constructors, inheritance, and dependency injection.
OOP matters because most production C# applications are built around object-oriented ideas. ASP.NET Core controllers, services, repositories, Entity Framework Core entities, domain models, background workers, validators, middleware, and dependency injection registrations all depend on understanding how types, objects, interfaces, inheritance, and polymorphism work.
In interviews, OOP is important because it tests both language knowledge and design thinking. A candidate should not only know definitions such as encapsulation, abstraction, inheritance, and polymorphism, but also understand when to use them, when not to use them, and how they affect maintainability, testing, extensibility, and real-world application design.
A strong interview answer should connect OOP concepts to practical C# development. For example, interfaces are not only a theory topic; they are used heavily in dependency injection, unit testing, clean architecture, plugin systems, and API design. Inheritance is not only code reuse; it can support polymorphism but can also create fragile and tightly coupled designs when overused.
Core Concepts
Classes and Objects
A class is a blueprint for creating objects. It defines data and behavior through members such as fields, properties, methods, constructors, events, operators, indexers, and nested types.
An object is an instance of a class at runtime. Each object has its own state unless the state is stored in static members.
public class BankAccount
{
private decimal _balance;
public string AccountNumber { get; }
public BankAccount(string accountNumber, decimal openingBalance)
{
AccountNumber = accountNumber;
_balance = openingBalance;
}
public decimal GetBalance()
{
return _balance;
}
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Deposit amount must be positive.");
_balance += amount;
}
}
In this example, BankAccount is the class, and each created account is an object with its own account number and balance.
var account = new BankAccount("ACC-001", 1000m);
account.Deposit(250m);
Key points:
- A class defines structure and behavior.
- An object is a runtime instance of a class.
- Instance members belong to each object.
- Static members belong to the type itself.
- Constructors initialize objects into a valid state.
Common mistake: creating classes that only expose public fields and contain no meaningful behavior. This usually leads to weak encapsulation and scattered business logic.
Members of a C# Type
A C# class can contain different kinds of members:
- Fields: store data internally.
- Properties: expose data in a controlled way.
- Methods: define behavior.
- Constructors: initialize new instances.
- Events: notify other objects that something happened.
- Indexers: allow objects to be accessed like arrays.
- Operators: customize operator behavior.
- Nested types: define helper types inside another type.
Example using fields, properties, and methods:
public class Product
{
private decimal _price;
public string Name { get; set; } = string.Empty;
public decimal Price
{
get => _price;
set
{
if (value < 0)
throw new ArgumentException("Price cannot be negative.");
_price = value;
}
}
public decimal CalculateDiscountedPrice(decimal discountPercentage)
{
return Price - (Price * discountPercentage / 100);
}
}
Best practice: keep fields private and expose controlled access through properties or methods. This protects object state and makes future changes safer.
Encapsulation
Encapsulation means hiding internal implementation details and exposing only a safe public API. It protects object state from invalid changes and keeps business rules close to the data they protect.
Poor encapsulation:
public class Order
{
public decimal TotalAmount;
public string Status = "Draft";
}
Any code can change the order to an invalid state:
order.TotalAmount = -100;
order.Status = "Unknown";
Better encapsulation:
public class Order
{
private readonly List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public decimal TotalAmount => _items.Sum(item => item.TotalPrice);
public void AddItem(OrderItem item)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot add items after order submission.");
_items.Add(item);
}
public void Submit()
{
if (!_items.Any())
throw new InvalidOperationException("Cannot submit an empty order.");
Status = OrderStatus.Submitted;
}
}
The object controls its own state. Other code cannot directly set Status or modify the internal list.
Encapsulation is commonly used in:
- Domain entities.
- Value objects.
- Business rule validation.
- API models.
- Service classes.
- EF Core entities.
- Configuration classes.
Trade-offs:
- Strong encapsulation improves correctness and maintainability.
- Too much hiding can make objects difficult to use or test.
- Public setters are convenient but can make invalid states easier to create.
Best practices:
- Prefer private fields with public methods or properties when validation is needed.
- Use private setters when only the class should change a value.
- Expose collections as
IReadOnlyCollection<T>instead of mutableList<T>when callers should not modify them directly. - Keep business rules close to the object that owns the data.
Abstraction
Abstraction means exposing essential behavior while hiding unnecessary implementation details. In C#, abstraction is commonly achieved through interfaces, abstract classes, base classes, and carefully designed public APIs.
Example:
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body);
}
The caller does not need to know whether the email is sent through SMTP, SendGrid, Azure Communication Services, or another provider.
public class NotificationService
{
private readonly IEmailSender _emailSender;
public NotificationService(IEmailSender emailSender)
{
_emailSender = emailSender;
}
public Task NotifyUserAsync(string email)
{
return _emailSender.SendAsync(
email,
"Welcome",
"Your account has been created.");
}
}
Abstraction is important because it helps with:
- Dependency injection.
- Unit testing.
- Loose coupling.
- Clean architecture.
- Replacing implementations without changing callers.
- Plugin-style designs.
- Separating business logic from infrastructure.
Common interview point: abstraction is not the same as an abstract class. Abstraction is a design idea. An abstract class is one C# language feature that can implement that idea.
Trade-offs:
- Good abstractions make systems flexible and testable.
- Too many abstractions can make code harder to understand.
- Interfaces with too many members become difficult to implement.
- Abstractions should usually be based on real variation, not imaginary future requirements.
Best practices:
- Create interfaces around behavior, not around every class by default.
- Keep interfaces small and focused.
- Let application and domain layers depend on abstractions, while infrastructure provides implementations.
- Avoid abstractions that have only one implementation unless they are useful for testing, boundaries, or future extension.
Inheritance
Inheritance allows one class to derive from another class. The derived class reuses, extends, or modifies behavior from the base class.
public abstract class Employee
{
public string Name { get; }
protected Employee(string name)
{
Name = name;
}
public abstract decimal CalculatePay();
}
public class FullTimeEmployee : Employee
{
public decimal MonthlySalary { get; }
public FullTimeEmployee(string name, decimal monthlySalary)
: base(name)
{
MonthlySalary = monthlySalary;
}
public override decimal CalculatePay()
{
return MonthlySalary;
}
}
public class Contractor : Employee
{
public decimal HourlyRate { get; }
public int HoursWorked { get; }
public Contractor(string name, decimal hourlyRate, int hoursWorked)
: base(name)
{
HourlyRate = hourlyRate;
HoursWorked = hoursWorked;
}
public override decimal CalculatePay()
{
return HourlyRate * HoursWorked;
}
}
C# supports single inheritance for classes. A class can inherit from only one direct base class, but it can implement multiple interfaces.
Important inheritance keywords:
base: calls a base class constructor or member.virtual: allows a method or property to be overridden.override: provides a new implementation for a virtual or abstract member.abstract: declares an incomplete type or member that derived classes must implement.sealed: prevents a class from being inherited or prevents an override from being overridden again.protected: allows access inside the class and derived classes.
Use inheritance when there is a real is-a relationship and derived classes can safely substitute the base class.
Trade-offs:
- Inheritance can reduce duplication and support polymorphism.
- Deep inheritance hierarchies are difficult to understand and maintain.
- Base class changes can accidentally break derived classes.
- Inheritance creates strong coupling between base and derived classes.
Best practices:
- Prefer shallow inheritance hierarchies.
- Use inheritance for stable domain relationships.
- Prefer composition for flexible behavior reuse.
- Make base classes
abstractwhen they are not meant to be instantiated directly. - Use
sealedwhen a class or override should not be extended.
Polymorphism
Polymorphism means that different types can be treated through a common abstraction while providing different behavior.
In C#, polymorphism commonly appears through:
- Base class references.
- Interface references.
- Virtual and overridden methods.
- Abstract methods.
- Method overloading.
- Generic constraints.
Runtime polymorphism example:
var employees = new List<Employee>
{
new FullTimeEmployee("Alice", 5000m),
new Contractor("Bob", 50m, 120)
};
foreach (Employee employee in employees)
{
Console.WriteLine(employee.CalculatePay());
}
The compile-time type is Employee, but the runtime object may be FullTimeEmployee or Contractor. C# calls the correct overridden method at runtime.
Interface polymorphism example:
public interface IPaymentProcessor
{
Task ProcessAsync(decimal amount);
}
public class CreditCardPaymentProcessor : IPaymentProcessor
{
public Task ProcessAsync(decimal amount)
{
Console.WriteLine($"Processing {amount} by credit card");
return Task.CompletedTask;
}
}
public class BankTransferPaymentProcessor : IPaymentProcessor
{
public Task ProcessAsync(decimal amount)
{
Console.WriteLine($"Processing {amount} by bank transfer");
return Task.CompletedTask;
}
}
The caller can depend on IPaymentProcessor without knowing the concrete payment type.
Important comparison:
Common mistake: using new when override was intended. This can create confusing behavior because method hiding is not normal runtime polymorphism.
Interfaces
An interface defines a contract that a class, struct, or record can implement. It describes what a type can do, not how it does it.
public interface IRepository<T>
{
Task<T?> GetByIdAsync(Guid id);
Task AddAsync(T entity);
}
A class implements the interface:
public class ProductRepository : IRepository<Product>
{
private readonly AppDbContext _dbContext;
public ProductRepository(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<Product?> GetByIdAsync(Guid id)
{
return _dbContext.Products.FindAsync(id).AsTask();
}
public async Task AddAsync(Product entity)
{
_dbContext.Products.Add(entity);
await _dbContext.SaveChangesAsync();
}
}
Interfaces are heavily used in C# for:
- Dependency injection.
- Unit testing and mocking.
- Repository patterns.
- Strategy patterns.
- Clean architecture boundaries.
- External service boundaries.
- Multiple behavior contracts.
Best practices:
- Name interfaces with an
Iprefix, such asILogger,IRepository, orIEmailSender. - Keep interfaces focused on a single responsibility.
- Avoid large interfaces that force implementers to provide methods they do not need.
- Prefer interfaces when multiple unrelated types need to share behavior.
- Prefer abstract classes when you need shared state, protected members, constructors, or common implementation.
Abstract Classes
An abstract class is a class that cannot be instantiated directly. It can contain abstract members, concrete members, fields, constructors, and protected helper methods.
public abstract class FileParser
{
public async Task<IReadOnlyList<string>> ParseAsync(string path)
{
var content = await File.ReadAllTextAsync(path);
return ParseContent(content);
}
protected abstract IReadOnlyList<string> ParseContent(string content);
}
public class CsvFileParser : FileParser
{
protected override IReadOnlyList<string> ParseContent(string content)
{
return content.Split(',');
}
}
This pattern is useful when the base class owns a common workflow and derived classes customize specific steps.
Abstract class vs interface:
Best practice: choose an interface when you only need a contract. Choose an abstract class when related types need shared implementation or protected extensibility points.
Access Modifiers
Access modifiers control where types and members can be used.
Common access modifiers:
Example:
public class Customer
{
private string _secretNote = string.Empty;
public string Name { get; private set; }
protected DateTime CreatedAt { get; } = DateTime.UtcNow;
internal bool IsVerified { get; set; }
public Customer(string name)
{
Name = name;
}
}
Best practices:
- Use the most restrictive access level that still supports the required behavior.
- Avoid making members
publicjust for convenience. - Use
protectedcarefully because it becomes part of the inheritance contract. - Use
internalfor implementation details inside one assembly.
Properties, Fields, and Methods
A field stores data. A property exposes data through accessors. A method performs an action or calculation.
public class User
{
private string _email = string.Empty;
public string Email
{
get => _email;
set
{
if (!value.Contains('@'))
throw new ArgumentException("Invalid email address.");
_email = value;
}
}
public bool HasCompanyEmail()
{
return Email.EndsWith("@company.com", StringComparison.OrdinalIgnoreCase);
}
}
Use fields for internal storage. Use properties when callers need controlled access. Use methods when the operation represents behavior, validation, business logic, or a meaningful action.
Auto-properties are useful when no custom logic is needed:
public string FirstName { get; set; } = string.Empty;
Init-only properties are useful for object initialization while keeping the object mostly immutable afterward:
public class CreateUserRequest
{
public required string Email { get; init; }
public required string DisplayName { get; init; }
}
Best practices:
- Avoid public fields in most application code.
- Use
private setwhen only the class should modify a property after construction. - Use
initfor values that should be assigned during object creation only. - Use
requiredwhen callers must provide a value during initialization. - Avoid putting expensive operations inside property getters; use methods for operations with noticeable cost or side effects.
Constructors and Object Initialization
A constructor initializes an object. Constructors should leave the object in a valid state.
public class Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Currency is required.");
Amount = amount;
Currency = currency;
}
}
Constructor chaining uses this or base:
public class ApiClient
{
public string BaseUrl { get; }
public TimeSpan Timeout { get; }
public ApiClient(string baseUrl)
: this(baseUrl, TimeSpan.FromSeconds(30))
{
}
public ApiClient(string baseUrl, TimeSpan timeout)
{
BaseUrl = baseUrl;
Timeout = timeout;
}
}
Best practices:
- Validate required constructor arguments.
- Keep constructors simple.
- Avoid heavy I/O work inside constructors.
- Use dependency injection to provide dependencies.
- Use object initializers for simple data transfer objects.
- Use constructors or factory methods for domain objects that require validation.
Static Members
Static members belong to the type itself instead of a specific object instance.
public static class TaxCalculator
{
public static decimal CalculateVat(decimal amount, decimal rate)
{
return amount * rate;
}
}
Static members are useful for stateless utility behavior, constants, factory methods, and shared metadata.
Trade-offs:
- Static methods are simple to call.
- Static state can make testing harder.
- Static mutable state can create concurrency issues.
- Static dependencies are harder to replace than injected dependencies.
Best practice: use static classes for pure utility functions. Avoid static mutable state in web applications unless it is carefully synchronized and intentionally shared.
Composition
Composition means building a type by containing and using other types. It represents a has-a relationship.
public interface IDiscountPolicy
{
decimal ApplyDiscount(decimal amount);
}
public class PercentageDiscountPolicy : IDiscountPolicy
{
private readonly decimal _percentage;
public PercentageDiscountPolicy(decimal percentage)
{
_percentage = percentage;
}
public decimal ApplyDiscount(decimal amount)
{
return amount - (amount * _percentage / 100);
}
}
public class CheckoutService
{
private readonly IDiscountPolicy _discountPolicy;
public CheckoutService(IDiscountPolicy discountPolicy)
{
_discountPolicy = discountPolicy;
}
public decimal CalculateTotal(decimal subtotal)
{
return _discountPolicy.ApplyDiscount(subtotal);
}
}
CheckoutService has a discount policy. It does not inherit from a discount policy.
Composition is often preferred over inheritance because it creates more flexible designs. Behavior can be changed by injecting a different dependency instead of creating a new subclass.
Inheritance vs composition:
Best practices:
- Prefer composition for behavior reuse.
- Use inheritance for stable type hierarchies.
- Use interfaces with composition to support substitution.
- Avoid deep inheritance trees when strategies or services would be simpler.
Records, Classes, and Structs
C# supports several type choices that are relevant to OOP design.
Classes:
- Reference types.
- Usually used for objects with identity and behavior.
- Support inheritance.
- Common for services, entities, controllers, and domain models.
Records:
- Reference types by default, unless declared as
record struct. - Designed for immutable or value-like data models.
- Provide built-in value-based equality behavior.
- Useful for DTOs, commands, queries, events, and result objects.
Structs:
- Value types.
- Copied by value unless passed by reference.
- Useful for small, lightweight values.
- Cannot inherit from other structs or classes, but can implement interfaces.
Example record:
public record Address(string Street, string City, string Country);
Example class:
public class Customer
{
public Guid Id { get; }
public string Name { get; private set; }
public Customer(Guid id, string name)
{
Id = id;
Name = name;
}
}
Use classes when identity and lifecycle matter. Use records when value equality and immutability are useful. Use structs for small values where copying is acceptable and intentional.
Reference Equality and Value Equality
Reference equality checks whether two variables refer to the same object instance. Value equality checks whether two values are logically equal.
Class example:
var customer1 = new Customer(Guid.NewGuid(), "Alice");
var customer2 = new Customer(customer1.Id, "Alice");
Console.WriteLine(customer1 == customer2); // Usually false for normal classes
Record example:
public record ProductDto(int Id, string Name);
var product1 = new ProductDto(1, "Laptop");
var product2 = new ProductDto(1, "Laptop");
Console.WriteLine(product1 == product2); // True
Interview key point: normal classes use reference equality unless equality is overridden. Records are designed for value-based equality.
Best practices:
- Use records for immutable data models where value equality is expected.
- Override
EqualsandGetHashCodecarefully when custom class equality is required. - Be careful when using mutable properties in equality logic.
SOLID Principles and OOP Design
SOLID principles are commonly discussed in OOP interviews because they show how to design maintainable object-oriented systems.
Single Responsibility Principle:
A class should have one main reason to change.
Poor example:
public class InvoiceService
{
public void CalculateTotal() { }
public void SaveToDatabase() { }
public void SendEmail() { }
public void GeneratePdf() { }
}
Better design separates responsibilities:
public class InvoiceCalculator { }
public class InvoiceRepository { }
public class InvoiceEmailService { }
public class InvoicePdfGenerator { }
Open/Closed Principle:
Software should be open for extension but closed for modification. New behavior should often be added by adding new types instead of changing existing stable code.
public interface IShippingCostCalculator
{
decimal Calculate(Order order);
}
public class StandardShippingCalculator : IShippingCostCalculator
{
public decimal Calculate(Order order) => 10m;
}
public class ExpressShippingCalculator : IShippingCostCalculator
{
public decimal Calculate(Order order) => 25m;
}
Liskov Substitution Principle:
A derived type should be usable wherever its base type is expected without breaking expected behavior.
Common violation:
public class Bird
{
public virtual void Fly() { }
}
public class Penguin : Bird
{
public override void Fly()
{
throw new NotSupportedException();
}
}
A better model separates flying behavior from bird identity.
Interface Segregation Principle:
Clients should not be forced to depend on methods they do not use.
Poor example:
public interface IMachine
{
void Print();
void Scan();
void Fax();
}
Better:
public interface IPrinter
{
void Print();
}
public interface IScanner
{
void Scan();
}
public interface IFaxMachine
{
void Fax();
}
Dependency Inversion Principle:
High-level modules should depend on abstractions, not concrete implementation details.
public class ReportService
{
private readonly IReportRepository _repository;
public ReportService(IReportRepository repository)
{
_repository = repository;
}
}
This makes the code easier to test and easier to change.
Dependency Injection and OOP
Dependency Injection, or DI, is a common technique in C# applications where dependencies are provided from the outside instead of created inside the class.
Without DI:
public class OrderService
{
private readonly EmailSender _emailSender = new();
}
This tightly couples OrderService to EmailSender.
With DI:
public class OrderService
{
private readonly IEmailSender _emailSender;
public OrderService(IEmailSender emailSender)
{
_emailSender = emailSender;
}
}
ASP.NET Core can register and inject implementations:
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<OrderService>();
Why this matters:
- Improves testability.
- Reduces tight coupling.
- Supports clean architecture.
- Makes implementations replaceable.
- Helps separate business logic from infrastructure.
Common mistake: depending on interfaces but still creating concrete classes with new inside the class. That defeats much of the benefit of dependency injection.
OOP in Real-World C# Applications
Common real-world usage examples:
- ASP.NET Core controllers use classes and dependency injection.
- Services encapsulate business use cases.
- EF Core entities model domain or persistence data.
- Repositories abstract data access when useful.
- Validators encapsulate validation rules.
- Interfaces define boundaries between application and infrastructure layers.
- Middleware uses classes to encapsulate request pipeline behavior.
- Background services inherit from base classes such as
BackgroundService. - DTOs and records transfer data between layers.
- Domain entities use encapsulation to protect business invariants.
Example application service:
public class CreateOrderService
{
private readonly IOrderRepository _orderRepository;
private readonly IPaymentProcessor _paymentProcessor;
public CreateOrderService(
IOrderRepository orderRepository,
IPaymentProcessor paymentProcessor)
{
_orderRepository = orderRepository;
_paymentProcessor = paymentProcessor;
}
public async Task<Guid> CreateAsync(CreateOrderRequest request)
{
var order = Order.Create(request.CustomerId, request.Items);
await _paymentProcessor.ProcessAsync(order.TotalAmount);
await _orderRepository.AddAsync(order);
return order.Id;
}
}
This example uses encapsulation, abstraction, dependency injection, and composition together.
Common OOP Mistakes in C#
Common mistakes include:
- Exposing mutable public fields.
- Creating deep inheritance hierarchies.
- Using inheritance only to share code.
- Creating interfaces for every class without a clear reason.
- Building large interfaces with too many methods.
- Violating Liskov Substitution Principle with derived classes that cannot behave like the base class.
- Using
newmethod hiding instead ofoverride. - Putting too much logic in controllers instead of services or domain objects.
- Creating God classes that do too many unrelated things.
- Making everything static, which hurts testability and flexibility.
- Using anemic domain models where all data is public and all business rules are scattered in services.
- Overusing patterns without a real problem to solve.
OOP Best Practices for Interviews and Production Code
Best practices:
- Keep classes focused and cohesive.
- Use encapsulation to protect valid state.
- Prefer composition over inheritance for behavior reuse.
- Use inheritance only when the relationship is truly
is-a. - Depend on abstractions at architectural boundaries.
- Keep interfaces small and meaningful.
- Use dependency injection for replaceable dependencies.
- Use records for immutable data transfer models when value equality is useful.
- Avoid public mutable state.
- Prefer clear names that describe domain concepts.
- Keep business rules close to the data they protect when designing domain models.
- Write unit tests against public behavior, not private implementation details.
- Avoid unnecessary abstraction in small or simple code.