Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 7

Choosing the Right Abstraction: Abstract Class, Interface, or Record

Recommended resources

Hello. In our previous lesson, we established the "favor composition over inheritance" principle, shifting our design focus from rigid "is-a" hierarchies to flexible "has-a" assemblies. That principle often leads to using interfaces to define behaviors that can be composed. But when is inheritance still appropriate? And where do modern C# features like records fit in?

Today, we will synthesize these ideas to address a crucial aspect of high-level API design. Our learning outcome is to select the appropriate tool (abstract class, interface, or record) for a given API design scenario. This lesson will equip you with a mental framework to make deliberate, expert-level choices that lead to clean, maintainable, and expressive C# code, moving beyond simple rules to nuanced decision-making.

The Three Pillars of Type Design

When designing a type in C#, your choice largely revolves around three concepts: defining a contract, establishing a common base, or modeling data.

  1. Interface: Defines a "can-do" contract. It specifies a set of capabilities that a class can provide, without dictating anything about its core identity.
  2. Abstract Class: Establishes an "is-a" relationship. It provides a common base for a family of related classes, often including shared state and implementation.
  3. Record: Models data with value semantics. It's designed for types whose primary purpose is to carry data, where equality is based on content, not on object identity.

Let's explore the trade-offs between these choices, starting with the classic dilemma.


1. The Classic Choice: Interface vs. Abstract Class

This is the most fundamental decision when modeling shared behavior. The core difference lies in intent: are you defining what a class is, or what it can do?

This infographic provides a great high-level summary. An interface defines a capability ("CAN DO"), while an abstract class defines a shared identity ("IS A").

An abstract class creates a tightly coupled inheritance hierarchy. A class can only inherit from one abstract class. An interface, by contrast, defines a contract that any class can implement, and a class can implement many interfaces. This enables a form of multiple inheritance of behavioral contracts, which is a cornerstone of the composition-over-inheritance principle we discussed.

Interfaces vs Abstract Classes

The video "Interfaces vs Abstract Classes" from Raw Coding clearly explains the philosophy behind this choice.

Please watch the segment from this part of the video, which discusses the "is-a" versus "has-a" or "behaves-like" distinction using practical examples for both abstract classes (shapes, vehicles) and interfaces (caching).

As the video highlights, the choice comes down to these key points:

  • Use an Abstract Class for an "is-a" relationship: This is for a family of objects that are fundamentally related. A Square is a Shape. A Truck is a Vehicle. This relationship is central to their identity. Abstract classes are ideal when you want to provide common, shared implementation and state (fields) that all derived classes will use. For example, an abstract Vehicle class could have a public int Speed { get; protected set; } property and a concrete Accelerate(int amount) method, while forcing derived classes to implement an abstract StartEngine() method.

  • Use an Interface for a "can-do" or "behaves-like" relationship: This is for defining a capability that can be applied to a wide range of unrelated classes. ILoggable, IDisposable, and IComparable are perfect examples. A Customer object, a NetworkService, and a FileParser are all different things, but they all can be ILoggable. An interface decouples the capability from the implementation hierarchy.

Since C# 8, interfaces can have default method implementations, which slightly blurs the lines. However, the fundamental distinction remains: interfaces cannot contain state (fields). An abstract class can have instance fields, allowing you to share state across the inheritance hierarchy, which is something an interface can never do.

milanm/csharp-cheatsheet: An cheat sheet for modern C# · GitHub

This C# cheat sheet offers a concise set of guidelines for making this choice.

At the end of the "Polymorphism" section, review the bulleted list under the heading "When designing class hierarchies". This provides a very direct summary.


2. The Modern Choice: Introducing Records for Data

Records, introduced in C# 9, are not direct replacements for interfaces or abstract classes but offer a powerful, specialized alternative for a common use case: modeling data.

As you'll recall from our earlier lesson, a record is a compiler-enhanced class (or struct) designed for immutability and value-based equality. This makes them fundamentally different from traditional classes, which use reference-based equality by default.

When Should You Use record, class, or struct - And Why That Matters?

The video "Record vs Class vs Struct" explains this distinction well.

First, watch the overview of what a record is from this section. It highlights that a record is a reference type with "value-typed semantics." Next, watch from this segment to see the practical benefits of these semantics, especially when using records in collections like HashSet or with LINQ's Distinct() method.

The key insight is that you should choose a record when the object's identity is its data. For example:

  • A Point(int X, int Y) record. Two points are the same if their X and Y values are the same. Their object identity in memory is irrelevant.
  • A WeatherForecast(DateTime Date, int TemperatureC) record returned from an API. It's a simple, immutable data carrier.
This infographic compares a traditional class with a record. Note a common point of confusion it introduces: it labels `Record` as a "Value Type". While records *behave* like values due to value-based equality, the default `record` keyword creates a `record class`, which is a **reference type**. The value type version is `record struct`. As an experienced developer, this distinction is crucial.

So, when would a record be a better choice than an interface or an abstract class?

  • Record vs. Interface: If you find yourself defining an interface like IHaveValueSemantics and then manually implementing Equals, GetHashCode, and the equality operators in a class, you should probably just use a record. The record does all that work for you.
  • Record vs. Abstract Class: An abstract class is for sharing behavioral logic in a hierarchy. Records are for modeling data. However, records can also participate in inheritance hierarchies (with other records). This makes them excellent for modeling algebraic data types or discriminated unions. For example, you can define a hierarchy of application events:
    public abstract record AppEvent;
    public sealed record OrderSubmitted(int OrderId) : AppEvent;
    public sealed record UserLoggedIn(string Username) : AppEvent;
    
    This gives you a clear, type-safe way to represent different kinds of data events, which works beautifully with pattern matching.

3. A Decision-Making Framework

To make a confident choice, you need a systematic approach. The following questions can guide your decision.

Choosing Between Classes, Records, and Structs in C# – BigO ...

The article "Choosing Between Classes, Records, and Structs in C#" provides an exceptionally detailed set of guidelines and examples.

First, carefully read the section "Best Practices and Decision Guidelines". This provides a series of questions to help you navigate the choice. Next, review the concrete scenarios in the following section, from "Example 1" to "Example 7". These examples illustrate the good and bad uses of each type.

Based on that reading and what we've discussed, here is a simplified decision tree:

  1. Is the primary purpose of this type to represent an entity with a distinct identity, state, and complex behavior?

    • Yes: You need a class. Think services, business entities with an ID (Customer, Order), or UI components. Reference equality is important here. Two orders with the same data are still two different orders.
  2. Is the primary purpose to carry an immutable snapshot of data, where two instances with the same data should be considered equal?

    • Yes: You need a record. This is your default for DTOs, messages, commands, events, or value objects in DDD (Money, Address).
      • If the data is small (e.g., < 16 bytes) and will be created frequently in performance-sensitive code, consider a readonly record struct to avoid heap allocations.
      • Otherwise, a record class is the standard choice.
  3. Is the primary purpose to define a reusable capability that can be applied to unrelated classes?

    • Yes: You need an interface. This allows you to achieve polymorphism through composition. Think ILoggable, ISerializable, IValidatable.
  4. Is the primary purpose to create a family of related types that share significant common code and/or state?

    • Yes: You need an abstract class. This is for true "is-a" hierarchies where you want to enforce a common base and avoid code duplication. Think abstract class Vehicle or abstract class Shape.

Scenario: E-commerce Notification System

Let's apply this framework to designing a notification system.

  • Representing the Event: An event like "Order Shipped" needs to carry data: OrderId, TrackingNumber, ShippedAt. The data is a snapshot. Two OrderShipped events with the same data are effectively the same.

    • Choice: A record class: public record OrderShipped(int OrderId, string TrackingNumber, DateTime ShippedAt); It's concise, immutable, and has value equality for free.
  • Defining Notification Channels: We need to send notifications via different channels like Email, SMS, and Push Notifications. Each has a completely different implementation, but they all must be able to send a message.

    • Choice: An interface: public interface INotificationChannel { Task SendAsync(string recipient, string message); }. This defines the "can-do" contract. EmailChannel, SmsChannel, etc., will all implement it.
  • Modeling different types of Users: We might have Customer, Guest, and Administrator users. They are all fundamentally users, but might have different permissions or properties. They share common data like an Id and Email.

    • Choice: An abstract class: public abstract class User { public int Id { get; } public string Email { get; } ... }. We can then have public class Customer : User { ... } and public class Administrator : User { ... }. This establishes a common identity and allows for shared properties and methods related to being a user.

Conclusion

You now have a robust framework for making expert decisions in your API designs. By moving beyond a simple "class vs. interface" debate and incorporating records, you can create more precise, expressive, and modern C# applications.

Key takeaways:

  • Interface: The tool for capabilities ("can-do"). Use it to define contracts that can be implemented by many unrelated types, promoting loose coupling and composition.
  • Abstract Class: The tool for shared identity ("is-a"). Use it to build a family of related types that share common code and state, enforcing a base implementation.
  • Record: The tool for data. Use it when an object's identity is defined by its contents, not its memory location. This is your default for DTOs, messages, and immutable value objects.

In our next module, we will dive into advanced generics. We've seen how powerful interfaces are for defining contracts, and our next step will be to learn about covariance (out) and contravariance (in). These features will allow you to make your generic interfaces, like IEnumerable<out T>, even more flexible and powerful.