Object-Oriented C#: Classes, Inheritance, and Interfaces

Master object-oriented C#: classes, properties, constructors, inheritance, abstract classes, interfaces, records, and when composition beats inheritance.

Executive Summary: Object-oriented C# is built from four working parts: classes that hold state and behavior, inheritance for genuine “is-a” relationships, interfaces for contracts, and records for data that travels. The trade-off the syntax hides is coupling: every inheritance link ties two types together for their lifetime, while an interface ties two sides to a contract only.

Open a service that has grown for two years and you will usually find a class called TaskService with a constructor of eleven parameters and a file long enough to scroll for a minute. Nobody designed that; it grew because “put it in the service” was the path of least resistance every single sprint. The tools that prevent it are old, not exotic: small classes, interfaces at the boundaries, and composition where others reach for inheritance.

Object-oriented C# means the part of the language that models your domain: classes with properties and constructors, inheritance and abstract classes, interfaces and polymorphism, and the newer records for immutable data. If the memory model behind classes is rusty, the C# basics guide covers value vs reference types. This page builds on that distinction.

The examples run on .NET SDK 10.0 with C# 14 and are standalone console code. No framework is required. One section touches dependency injection because that is where interfaces earn their keep, and the guide on dependency injection in ASP.NET Core goes deeper.

This page spends more words on composition than on inheritance trees for one reason: inheritance is the most overused tool in the C# toolbox. It is the right answer occasionally, and the expensive answer constantly, because it couples implementations rather than contracts. Everything below makes that concrete.

Classes: state, behavior, and constructors

A class bundles state (properties) with behavior (methods). Here is a task item from the recurring TaskApi example, expressed as a class:

public class TaskItem
{
    public Guid Id { get; }
    public string Title { get; set; }
    public bool Done { get; private set; }
    public DateTime? DueDate { get; set; }

    public TaskItem(string title)
    {
        Id = Guid.NewGuid();
        Title = title;
    }

    public void Complete()
    {
        Done = true;
    }

    public bool IsOverdue() =>
        DueDate is not null && DueDate < DateTime.UtcNow && !Done;
}

Reading the property declarations: { get; set; } is an auto-property, where the compiler generates the backing field. { get; } is read-only outside the constructor. { get; private set; } lets only the class itself change the value, so Done moves only through Complete(). The DateTime? type is a nullable value type: a due date may be absent. The guide on nullable reference types covers the companion feature for reference types.

The constructor runs once per object and initializes state. C# 12 added primary constructors, so this shorter form compiles too:

public class TaskItem(string title)
{
    public Guid Id { get; } = Guid.NewGuid();
    public string Title { get; set; } = title;
}

Both forms produce the same object; pick the one that reads better. What matters is the discipline, not the syntax: every object leaves its constructor in a valid state. As a result, a TaskItem never exists without an Id and a title.

Inheritance and abstract classes

Inheritance models “is-a”: a BugTask is a TaskBase. The base class declares shared state and lets derived classes specialize behavior with virtual and override:

public abstract class TaskBase
{
    public required string Title { get; init; }

    public abstract string Describe();
}

public sealed class BugTask : TaskBase
{
    public required string Severity { get; init; }

    public override string Describe() =>
        $"Bug: {Title} (severity {Severity})";
}

public sealed class FeatureTask : TaskBase
{
    public required string Owner { get; init; }

    public override string Describe() =>
        $"Feature: {Title}, owned by {Owner}";
}

Three keywords carry the weight. abstract on the class means it cannot be instantiated, only derived from. abstract on the method means every subclass must provide it. override replaces the base behavior. sealed closes the hierarchy: nobody can derive from BugTask, which is a feature, not a restriction. This is because inheritance is designed as the inheritance reference describes: a commitment between two implementations.

An abstract class can also implement a template method: it defines the algorithm and leaves the steps abstract. That is the strongest use of inheritance in business code, and it appears again in the decision framework.

Interfaces: contracts, not hierarchies

An interface is a contract: a list of members with no implementation that a class promises to provide. It is the single most useful tool for keeping large C# systems changeable:

public interface ITaskRepository
{
    TaskItem? Find(Guid id);
    void Save(TaskItem item);
}

public sealed class InMemoryTaskRepository : ITaskRepository
{
    private readonly Dictionary<Guid, TaskItem> _items = new();

    public TaskItem? Find(Guid id) =>
        _items.TryGetValue(id, out var item) ? item : null;

    public void Save(TaskItem item) => _items[item.Id] = item;
}

Code that depends on ITaskRepository never mentions the in-memory version. Swapping it for a database-backed implementation changes one registration line, not callers. This is the seam dependency injection builds on: the container maps ITaskRepository to an implementation at startup, as covered in dependency injection in ASP.NET Core.

Beginners ask the right question: why not an abstract class? The comparison table settles most cases:

Question Abstract class Interface
How many can one type have? One base class Many interfaces
Can carry state and fields? Yes No
Can provide implementation? Yes, shared code Only default interface methods
Models “is-a”, a family “can-do”, a capability

The default choice in modern C# is an interface at every boundary you expect to have two implementations of, and an abstract class only when subclasses genuinely share implementation. A class with one interface for its own sake, “for later”, is not design; it is speculation with extra files.

Polymorphism in practice

Polymorphism means calling code stays the same while the object decides what runs. Both the inheritance set and the interface set from above share the same trick through the TaskBase or ITaskRepository declaration:

TaskBase[] sprint =
{
    new BugTask { Title = "500 on export", Severity = "high" },
    new FeatureTask { Title = "Tag filtering", Owner = "imran" }
};

foreach (var task in sprint)
{
    Console.WriteLine(task.Describe());
}
// Bug: 500 on export (severity high)
// Feature: Tag filtering, owned by imran

The foreach loop calls one method name; each object dispatches to its own Describe. That is the whole mechanism behind substituting a fake repository in tests, adding a new provider behind an interface, or shipping a second notification channel. The official object-oriented tutorial runs the same pattern with more shapes.

Records vs classes: which to pick

A record is a reference type with value semantics, added for exactly the case classes handle worst: data that travels without behavior. The compiler generates a constructor, value equality, a readable ToString, and the ability to copy non-destructively:

public record TaskSummary(Guid Id, string Title, bool Done);

var summary = new TaskSummary(Guid.NewGuid(), "Ship the API", false);
var updated = summary with { Done = true };

// summary.Done is still false; updated is a new object
Console.WriteLine(updated);
// TaskSummary { Id = ..., Title = Ship the API, Done = True }

The decision table is short:

Question Use a class Use a record
What does the type represent? Behavior, identity, services Data: requests, responses, snapshots
Should copies compare equal? Usually no, identity matters Yes, same data means same value
How is it changed? Mutate properties with creates a copy
Where does it live? Domain logic, services, repositories API payloads, messages, events

In the TaskApi running through this series, TaskItem is a class because it carries behavior. By contrast, anything crossing an HTTP boundary is a record. The record reference documents the generated members in detail.

Composition over inheritance

Now the position that earns its space. When two pieces of code need to share behavior, inheritance is one option and composition is the other, and composition usually wins. Inheritance copies an implementation; composition borrows one across the boundary of an interface:

WRONG: a base class that holds repository logic so every service can “reuse” it. Six services later, changing the base class touches all six, and none of them can opt out.

RIGHT: each service takes the collaborator it needs through its constructor and stays sealed:

public sealed class TaskService
{
    private readonly ITaskRepository _repository;

    public TaskService(ITaskRepository repository)
    {
        _repository = repository;
    }

    public void Complete(Guid id)
    {
        var task = _repository.Find(id)
            ?? throw new InvalidOperationException($"Task {id} not found");

        task.Complete();
        _repository.Save(task);
    }
}

The service depends on a contract, gets tested against the in-memory repository, runs against the database one in production, and owns nothing else. Compare the two dependency pictures:

INHERITANCE                    COMPOSITION
TaskService : RepositoryBase   TaskService
      |                             |
  (6 services share               ITaskRepository
   one implementation,              |
   all coupled to it)     InMemory impl | Sql impl
                              (swap either, callers unchanged)

Rule of thumb: use inheritance for genuine “is-a” with shared implementation (a template method), and composition for everything that “has-a” collaborator. If a design question between the two takes more than a minute, composition is the safe default. This is because it is the choice you can undo.

How real systems do this

Production C# codebases share three habits worth copying:

  • Sealed classes by default. Framework types aside, teams mark services and handlers sealed so nobody inherits by accident. Opening a class is a decision, not a default.
  • Interfaces at the boundaries, records across them. Repositories, notifications, and clocks sit behind interfaces so tests substitute fakes. Requests and responses are records, so payloads compare by value and never mutate mid-request. building a Web API with ASP.NET Core shows the DTO side end to end.
  • Constructor injection instead of service locators. Dependencies arrive as constructor parameters through the container, which makes the class honest about what it needs, as the dependency injection guide covers.

A design mistake I have seen in production is a “BaseService” class that every service inherited. Shared logging, shared helpers, shared database access. It worked until two services needed different logging behavior, and the branch conditions multiplied inside the base class until every change touched every service. The refactor took a sprint: dissolve the base class, extract small collaborators behind interfaces, inject them. The lesson: inheritance shares implementation, and shared implementation eventually means shared breakage.

Decision framework: class, record, interface, or inheritance

  1. Is the type data or behavior? Data that travels with no methods: a record. Behavior, identity, or lifecycle: a class.
  2. Is the relationship “is-a” with shared code? Then an abstract class or base class earns its keep, ideally as one template method. If the only motivation is reuse, stop and use composition instead.
  3. Is this a boundary with two plausible implementations? An interface, implemented by sealed classes. If you cannot name the second realistic implementation, write the class first and extract the interface when the need arrives.
  4. Does the object need to be substitutable through a base type? If yes, that is polymorphism doing its job. If no type ever varies, the interface is decoration.
  5. Should subclasses be possible? Default to sealed and unseal deliberately when a real extension point appears.

When NOT to reach for these tools

  • When a static helper or a function would do. A stateless calculation, string normalization, or pure conversion does not need an interface, a base class, or a service. Make it a static method or a small record and move on.
  • When the inheritance chain passes two levels. A base, a derived, maybe one more. Beyond that, every fix at the top becomes an archaeology dig at the bottom. Composition flattens the same design.
  • When the interface is speculative. “We might need a second implementation someday” fails the test the moment you ask what the second implementation is. YAGNI is not laziness here; it is what keeps interfaces meaningful.

Common mistakes

  • God services. One class accreting every responsibility grows a constructor of eleven parameters and a change-anything-breaks-something profile; split by responsibility instead.
  • Inheritance for code reuse. Reuse through a base class couples every derived type to one implementation; compose collaborators behind interfaces.
  • Everything public and mutable. { get; set; } on every property invites callers to mutate state from anywhere; prefer init, private setters, or records for data that should not change.
  • An interface per class, cargo cult. IFoo implemented by exactly one Foo adds indirection without value. Extract the interface when a second implementation or a test double needs it.
  • Unsealed everything. Leaving every class open invites inheritance where composition was intended; seal by default.
  • Records for services. Records shine for immutable data; putting behavior, logging, and stateful work in one couples the two ideas badly.

Key takeaways

  • A class bundles state and behavior; keep it small, sealed, and valid the moment its constructor finishes.
  • Inheritance is for genuine “is-a” with shared implementation; interfaces are for “can-do” contracts at boundaries.
  • Polymorphism means callers depend on the declaration, and each object decides what runs. That is what makes fakes and swaps possible.
  • Records are value-semantic data types: use them for payloads, and with to copy instead of mutate.
  • Composition over inheritance: inject collaborators through constructors behind interfaces.
  • Sealed by default, interfaces when a second implementation is real, abstract classes for template methods.

FAQ

What is the difference between a class and a record in C#?

A class is a reference type optimized for behavior and identity, where two objects with the same data are still different objects. A record is a reference type with value semantics: the compiler generates value equality, a readable ToString, and with-expressions for non-destructive copies. Use records for data payloads and classes for services and domain behavior.

When should I use an abstract class instead of an interface?

Use an abstract class when related types share actual implementation, such as a template method that defines an algorithm once. Use an interface when you need a contract that unrelated types can satisfy. This is because a class can implement many interfaces but inherit only one base class. When in doubt, the interface keeps your options open.

What is polymorphism in C#?

Calling code uses a base type or interface, and each concrete object supplies its own behavior at runtime. A loop over TaskBase objects calling Describe runs a different method body for each subtype. The same mechanism lets a service depend on an interface and work with any implementation, including a fake in tests.

Why do C# teams mark classes sealed?

Because inheritance is a commitment: deriving from a class couples the subclass to the base implementation details. Sealing classes prevents accidental coupling, enables some compiler optimizations, and forces extension through the seams you designed, such as interfaces and composition. Unsealing later is easy; undoing a subclass tree is not.

What does composition over inheritance mean in practice?

Instead of a base class sharing code with subclasses, each class receives the behavior it needs as a collaborator through its constructor, usually behind an interface. Changing the collaborator then affects one class instead of an inheritance tree, and each class keeps a single reason to change.

Design the seams, not the tree

Classes give you encapsulation, records give you safe data, interfaces give you seams, and inheritance, used sparingly, gives you template methods. The designs that stay pleasant to work in are the ones where behavior is composed behind small contracts rather than inherited through a family tree. Once this feels natural, the next language stop is LINQ for querying collections, and every guide in the series lives on the .NET series index. The rule of thumb: seal the class, extract the interface when the second implementation is real, and compose what you were about to inherit.

Last updated on 16 October 2026

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *