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.
- Interface: Defines a "can-do" contract. It specifies a set of capabilities that a class can provide, without dictating anything about its core identity.
- Abstract Class: Establishes an "is-a" relationship. It provides a common base for a family of related classes, often including shared state and implementation.
- 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?
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
Squareis aShape. ATruckis aVehicle. 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 abstractVehicleclass could have apublic int Speed { get; protected set; }property and a concreteAccelerate(int amount)method, while forcing derived classes to implement an abstractStartEngine()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, andIComparableare perfect examples. ACustomerobject, aNetworkService, and aFileParserare all different things, but they all can beILoggable. 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.
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
IHaveValueSemanticsand then manually implementingEquals,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:
This gives you a clear, type-safe way to represent different kinds of data events, which works beautifully with pattern matching.public abstract record AppEvent; public sealed record OrderSubmitted(int OrderId) : AppEvent; public sealed record UserLoggedIn(string Username) : AppEvent;
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:
-
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.
- Yes: You need a class. Think services, business entities with an ID (
-
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 structto avoid heap allocations. - Otherwise, a
record classis the standard choice.
- If the data is small (e.g., < 16 bytes) and will be created frequently in performance-sensitive code, consider a
- Yes: You need a record. This is your default for DTOs, messages, commands, events, or value objects in DDD (
-
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.
- Yes: You need an interface. This allows you to achieve polymorphism through composition. Think
-
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 Vehicleorabstract class Shape.
- 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
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. TwoOrderShippedevents 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.
- Choice: A
-
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.
- Choice: An
-
Modeling different types of Users: We might have
Customer,Guest, andAdministratorusers. They are all fundamentally users, but might have different permissions or properties. They share common data like anIdandEmail.- Choice: An
abstract class:public abstract class User { public int Id { get; } public string Email { get; } ... }. We can then havepublic class Customer : User { ... }andpublic class Administrator : User { ... }. This establishes a common identity and allows for shared properties and methods related to being a user.
- Choice: An
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.