Object-oriented programming models a system as objects that own state and expose behavior. Its main value is not class syntax or inheritance: it is putting each invariant beside the operations allowed to change it. Interfaces define capabilities, composition assembles independently varying behavior, and runtime dispatch lets callers use a contract without knowing the concrete type. Inheritance is optional and earns its place only when a genuine subtype shares stable invariants and implementation.

Abstraction and invariants

An object should make invalid transitions difficult to express. A bank account exposes Deposit and Withdraw; it does not expose a public balance setter that any caller can bypass.

public sealed class BankAccount
{
    public decimal Balance { get; private set; }
 
    public void Deposit(decimal amount)
    {
        if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
        Balance += amount;
    }
 
    public void Withdraw(decimal amount)
    {
        if (amount <= 0 || amount > Balance)
            throw new InvalidOperationException("The withdrawal is not valid.");
 
        Balance -= amount;
    }
}

The private setter is not sufficient by itself; the methods enforce the invariant. Encapsulation is control over state transitions, not merely wrapping fields in a class.

Abstraction exposes only the contract a caller needs:

public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(
        Money amount,
        CancellationToken cancellationToken);
}
 
public sealed class CheckoutService(IPaymentGateway gateway)
{
    public Task<PaymentResult> CheckoutAsync(
        Money total,
        CancellationToken cancellationToken) =>
        gateway.ChargeAsync(total, cancellationToken);
}

The caller depends on a payment capability, while a Stripe or bank implementation owns protocol details. C# interfaces can include default members, but a default must have semantics valid for every implementer. A meaningless fallback signals that the interface has grown beyond one coherent capability.

Inheritance and composition

Inheritance binds a derived type to a base type’s contract and implementation. Composition binds an object to a collaborator’s contract and delegates work to it. Use inheritance when the relationship is a genuine subtype with stable shared invariants; use composition when behavior varies independently or may change at runtime. Reuse alone is not enough to justify a type hierarchy.

public interface IMovementStrategy
{
    Task MoveToAsync(string destination, CancellationToken cancellationToken);
}
 
public abstract class Robot
{
    protected Robot(IMovementStrategy movement) => Movement = movement;
 
    protected IMovementStrategy Movement { get; }
    public abstract Task PerformTaskAsync(CancellationToken cancellationToken);
}
 
public sealed class WaiterRobot(IMovementStrategy movement) : Robot(movement)
{
    public override Task PerformTaskAsync(CancellationToken cancellationToken) =>
        Movement.MoveToAsync("serving-counter", cancellationToken);
}

WaiterRobot is a Robot: callers can rely on the robot lifecycle and every subtype shares its invariants. Movement is composed because A*, waypoints, or direct movement can vary without creating AStarWaiterRobot, WaypointWaiterRobot, and every other cross-product subtype.

SignalPrefer inheritancePrefer composition
RelationshipA durable “is-a” subtypeA “has-a” collaborator
Shared stateBase class protects a real invariantState belongs to the delegated capability
VariationFixed by the concrete typeSwappable by configuration or request
Change costBase changes are safe for every subtypeImplementations should evolve independently
ReuseConsequence of the hierarchyExplicit delegation is the intended reuse

A derived class can depend on undocumented call order, protected state, or a virtual method invoked from a constructor. A later base-class change can compile cleanly and still break the subtype. Keep extension points small, document their preconditions and postconditions, and prefer sealed when a class was not designed for derivation.

Deep hierarchies multiply this risk. If an override suppresses base behavior, throws for a supported operation, or needs knowledge of base internals, the subtype is probably false. Flatten the hierarchy into capability interfaces and composed policies.

Subtyping and polymorphism

Inheritance or interface implementation creates a type-system subtype in C#. Sound design additionally requires behavioral substitutability: a value used through the base contract must preserve the expectations of callers that know only that contract.

public abstract class Account
{
    public abstract void Withdraw(decimal amount);
}
 
public sealed class FixedTermAccount : Account
{
    public override void Withdraw(decimal amount) =>
        throw new InvalidOperationException("Funds are locked.");
}

The code is assignable, but FixedTermAccount strengthens the precondition of Withdraw: the operation is not generally supported. Model the real capabilities instead.

public interface IAccount
{
    decimal Balance { get; }
}
 
public interface IWithdrawableAccount : IAccount
{
    void Withdraw(decimal amount);
}

A reporting service accepts IAccount; a transfer service requires IWithdrawableAccount. Neither must discover capability failure at runtime.

The substitution rule can be read operationally:

  • Do not require more than the base contract requires.
  • Do not promise less than the base contract promises.
  • Preserve invariants visible to callers.
  • Keep failures within the contract’s documented semantics.

Subtype polymorphism dispatches an operation through a base type or interface to the concrete implementation:

public interface IDiscountPolicy
{
    decimal Calculate(Order order);
}
 
decimal discount = policy.Calculate(order);

The CLR resolves an interface call through the concrete type’s interface map; virtual class calls use virtual slots. The JIT can devirtualize a call when it proves the concrete type, so choose the design for contract value rather than assumed dispatch overhead.

Overloading is different: the compiler selects an overload from static argument types. It is sometimes called compile-time polymorphism, but it does not provide runtime substitution.

Polymorphism helps when each implementation owns coherent behavior and new implementations arrive independently. A closed switch over three states can be clearer when the cases form one finite workflow. Splitting a small closed decision across types can hide the state machine without adding useful extensibility.

Pitfalls

Anemic domain model — a type with public setters while all rules live in services is procedural code wearing class syntax. A public Order.Status setter lets any caller skip payment checks; an Order.Ship() method can reject an illegal transition at the mutation boundary.

Interfaces for every class — a one-to-one IThing/Thing pair adds navigation and mocking ceremony without creating a useful boundary. Introduce an interface for multiple implementations, a capability consumed across a boundary, or a dependency tests must replace.

Inheritance for reuse alone — deriving EmailNotifier from SmtpClient exposes the wrong public contract and couples notification behavior to transport internals. Inject an SMTP collaborator instead.

Assignable but not substitutable — a subtype that throws for a base operation or strengthens its preconditions satisfies the method shape while breaking the behavioral contract.

Tradeoffs

ChoiceCostUse it when
Rich object with guarded methodsMore types and transition methodsIdentity and legal state transitions dominate the domain
Immutable data plus pure functionsState changes become explicit copiesThe work is a deterministic transformation or pipeline
Interface boundaryIndirection and contract-evolution workMultiple implementations or an architectural boundary are real
Concrete dependencyTighter couplingOne stable implementation is local and replacement has no value
InheritanceBase-class coupling and fragile extension pointsA genuine subtype shares a stable lifecycle and invariant
CompositionConstructor wiring and delegationCapabilities vary independently or at runtime

Most production C# systems mix styles: objects protect domain invariants, while records, LINQ, and pure functions handle transformations. Use the smallest boundary that makes ownership and failure modes clearer.

Questions

References