C#

C#: Should This Be a Class, Record, Struct, or Interface?

Every time you add a new type to a C# codebase, you face the same quiet decision: class, record, struct, or interface? And once you pick class, a second wave of questions arrives — should it be abstract? sealed? static?

Most developers default to class without thinking. That works fine until it doesn’t — until you hit subtle equality bugs, unexpected heap pressure, serialization surprises, or a refactor that’s painful because the wrong abstraction baked itself into every layer of the app.

This isn’t a pure language question. The right answer depends on mutability, equality semantics, serialization, DI boundaries, memory profile, and how the type will evolve over time. Let’s work through each option clearly — including the class variants that don’t get enough attention.


class: The Default, and Sometimes the Wrong Default

A class is a reference type. Two variables pointing to the same instance share the same object. Equality by default compares references, not values.

public class Point
{
    public double X { get; set; }
    public double Y { get; set; }
}

var a = new Point { X = 1, Y = 2 };
var b = new Point { X = 1, Y = 2 };

Console.WriteLine(a == b); // False — different references

Use a class when:

  • The type has identity — two instances with the same data are still meaningfully different objects (a User, an Order, a database entity).
  • The type is mutable and its state changes over time.
  • You need inheritance hierarchies.
  • The type will be registered with a DI container, especially with a non-transient lifetime.
  • The type is large enough that copying it by value would be wasteful.

Watch out for:

  • Mutability combined with shared references can cause hard-to-trace bugs. If two callers hold a reference to the same class instance and one mutates it, the other sees the change.
  • Reference equality catches people off guard in collections, dictionaries, and test assertions.

Class Variants: abstract, sealed, and static

Once you’ve decided the type should be a class, there are three important modifiers that change what the class is allowed to do and how the runtime treats it.

abstract class: Shared Base with Enforced Contract

An abstract class can’t be instantiated directly. It exists to be inherited. It can define shared implementation alongside abstract members that subclasses must implement.

public abstract class Notification
{
    public string Recipient { get; init; }

    // Shared implementation
    public void Log() => Console.WriteLine($"Notifying {Recipient}");

    // Subclasses must implement this
    public abstract Task SendAsync();
}

public class EmailNotification : Notification
{
    public override async Task SendAsync()
    {
        // send email logic
    }
}

Use abstract class when:

  • You have genuine shared behavior to push down to a base type — not just a contract, but real implementation that every subclass needs.
  • You want to enforce that certain methods are overridden by subclasses, but still provide sensible defaults for others.
  • You’re building a framework or extensibility point where the base type owns the control flow (Template Method pattern).

Watch out for:

  • Abstract base classes create tight coupling between the base and all its subclasses. Changes to the base ripple outward.
  • Prefer composition over deep inheritance hierarchies. If you find yourself with three levels of abstract classes, stop and reconsider the design.
  • An interface plus a concrete helper class is often a cleaner alternative when you just need shared utility methods without the inheritance contract.

sealed class: Lock It Down

A sealed class cannot be inherited. It’s the final word in the hierarchy.

public sealed class PaymentProcessor
{
    public void Process(PaymentRequest request) { /* ... */ }
}

Use sealed class when:

  • You explicitly don’t want the type extended — it closes off unintended inheritance that could break invariants.
  • You’re writing a library and want to prevent consumers from subclassing internal implementation details.
  • You want a small JIT optimization: the runtime can devirtualize method calls on sealed types because it knows exactly which method will be called.

Watch out for:

  • Sealing a class is easy; unsealing it later is a breaking change in public APIs. Be intentional about it.
  • Don’t reflexively seal every class. Seal when inheritance would be a correctness or security concern, or when you’re signaling deliberate finality.

sealed also applies to record: public sealed record OrderSummary(...) prevents subclassing and keeps equality semantics straightforward.

static class: No State, No Instances, Just Methods

A static class can’t be instantiated or inherited. Every member must be static. It’s a container for stateless utility behavior.

public static class DateTimeExtensions
{
    public static bool IsWeekend(this DateTime date) =>
        date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
}

Use static class when:

  • You’re writing extension methods — the language requires a static class for these.
  • You have a collection of pure utility functions with no shared state (math helpers, string formatters, guard clauses).
  • You want the compiler to enforce that nobody accidentally creates an instance or inherits from what is fundamentally a toolbox.

Watch out for:

  • Static classes are not injectable. If the logic inside needs to be testable in isolation or swappable, wrap it behind an interface instead.
  • Static state (static fields holding mutable data) in a static class is a concurrency hazard. Pure functions are fine; shared mutable state is not.
  • Overuse of static classes leads to procedural-style code that’s hard to test. Use them for genuinely stateless helpers, not as a shortcut to avoid designing proper types.

record: Value Equality Without the Boilerplate

A record (introduced in C# 9) is a reference type by default, but it overrides Equals, GetHashCode, and == to compare by value. It also gives you a ToString() that shows the property values, and a built-in with expression for non-destructive mutation.

public record Point(double X, double Y);

var a = new Point(1, 2);
var b = new Point(1, 2);

Console.WriteLine(a == b); // True — value equality

Use a record when:

  • The type represents a value — a coordinate, a money amount, a request/response DTO, a configuration snapshot.
  • You want immutability by default. Records defined with positional parameters are immutable out of the box.
  • You need structural equality without hand-writing Equals and GetHashCode.
  • You’re modeling domain concepts where two instances with the same data are the same thing.
  • The type is used as a dictionary key or in a set, where value-based equality matters.
// Non-destructive mutation with 'with'
var moved = a with { X = 5 };
Console.WriteLine(moved); // Point { X = 5, Y = 2 }

Watch out for:

  • Records are still reference types under the hood. If you need stack allocation or want to avoid heap pressure in tight loops, a record struct or plain struct is better.
  • Mutable records (record class with settable properties) exist but undermine the value-equality story. If you need mutability, rethink whether a record is the right choice.
  • Records and inheritance work, but it adds complexity. Records in a hierarchy require care with equality.

There is also record struct (C# 10+), which gives you value-based equality on a stack-allocated value type:

public record struct Point(double X, double Y);

This is useful for small, frequently-used value objects where you want both structural equality and stack allocation.


struct: Stack Allocation, Value Semantics, Real Trade-offs

A struct is a value type. It lives on the stack (when not boxed), gets copied on assignment, and has value semantics by default — though it does not automatically override Equals for member-wise comparison the way a record does.

public struct Point
{
    public double X { get; init; }
    public double Y { get; init; }
}

var a = new Point { X = 1, Y = 2 };
var b = a; // full copy
b = b with { X = 5 }; // requires 'init' or manual copy

Use a struct when:

  • The type is small (the .NET guidance is around 16 bytes or fewer), short-lived, and allocation-heavy in hot paths.
  • You explicitly want copy-by-value semantics — every caller gets their own independent copy.
  • You’re building low-level code, game engine math types, SIMD wrappers, or interop with unmanaged code.
  • You’ve measured heap allocation pressure and confirmed a struct helps.

Watch out for:

  • Structs are easy to misuse. A large struct copied frequently costs more than a heap-allocated class with a reference copy.
  • Boxing happens whenever a struct is assigned to an object, an interface, or stored in a non-generic collection. Boxing allocates on the heap and erases the performance benefit.
  • Mutable structs have notorious pitfalls. Mutating a struct through a read-only reference or a property getter produces silent bugs because you’re mutating a copy.
  • Default equality for plain struct is reflection-based and slow. If you care about performance or correctness in comparisons, override Equals and GetHashCode, or use record struct.

In most application code, struct is not the default choice. It’s a deliberate optimization with known trade-offs.


interface: Define a Contract, Not a Type

An interface doesn’t represent data — it defines a capability or contract that other types fulfill.

public interface IOrderRepository
{
    Task<Order> GetByIdAsync(Guid id);
    Task SaveAsync(Order order);
}

Use an interface when:

  • You’re defining a boundary for dependency injection. Interfaces let you swap implementations without changing callers — critical for testability and flexibility.
  • You want to abstract over behavior that multiple types can implement differently.
  • You need to express that something can do something (IDisposable, IComparable<T>, IAsyncEnumerable<T>), regardless of what it is.
  • You’re building a library or API surface and want to avoid locking consumers into a concrete inheritance chain.

Watch out for:

  • Interfaces define contracts, not defaults. For shared default behavior, consider abstract class or default interface implementations (available since C# 8, but use them carefully).
  • Thin interfaces that wrap a single class without real alternatives often add abstraction noise without value. If there’s only ever one implementation and no test doubles, an interface may not be earning its keep.
  • Interface explosion — a project with dozens of one-off interfaces for every service — creates cognitive overhead without proportional benefit.

A Practical Decision Framework

Here’s how I think through the choice in practice:

Question Points toward
Does identity matter? Two instances with the same data are different things? class
Is the type a pure value — immutable, defined by its data? record or record struct
Is the type small, short-lived, and copied frequently in a hot path? struct or record struct
Am I defining a contract for DI, testing, or abstraction? interface
Does the type need mutable shared state with change tracking? class
Do I need value equality without writing Equals by hand? record
Am I optimizing measured allocation pressure in performance-critical code? struct
Do I have real shared implementation to push down, plus abstract methods subclasses must fill in? abstract class
Should this type be uninheritable — for safety, API clarity, or JIT optimization? sealed class or sealed record
Is this a collection of stateless utility functions or extension methods? static class

Serialization and Records: A Note

Records work well with System.Text.Json and most modern serializers. Immutable records with positional constructors are fully supported as of .NET 6+ with the correct constructor-based deserialization:

public record OrderSummary(Guid Id, string CustomerName, decimal Total);

For DTOs — request models, response models, event payloads — records are often the cleanest choice. They’re immutable, equality-correct, and easy to reason about when tracing data through a pipeline.


How the Type Will Evolve Over Time

One factor that gets overlooked is change over time. Ask yourself:

  • Will this type grow new properties? A record makes that easy. A struct means callers that copy by value get stale copies in subtle ways.
  • Will behavior be injected or swapped? Design toward an interface early.
  • Will this be subclassed? Consider whether you want that. sealed record and sealed class lock the type down and help the JIT optimize.
  • Is this a domain entity or an infrastructure concern? Domain entities tend to be class with identity. Infrastructure value objects tend to be record.

Summary

  • class — identity, mutability, inheritance, DI contracts. The general-purpose reference type.
  • abstract class — shared base with enforced extension points. Use when subclasses genuinely share implementation, not just a contract.
  • sealed class — a class that cannot be inherited. Signals finality and enables JIT devirtualization.
  • static class — stateless utility methods and extension methods. No instances, no inheritance.
  • record — value equality, immutability, DTOs and value objects. Less boilerplate, clearer intent.
  • record struct — value equality on a stack-allocated value type. For small, frequently-used value objects in performance-sensitive paths.
  • struct — small, short-lived value types where stack allocation and copy semantics are deliberate choices.
  • interface — behavioral contracts, abstraction boundaries, testability.

The question isn’t just “what does C# let me use here?” It’s “what semantics am I actually committing to, and will they still be the right ones in six months?” Getting that decision right early saves a surprising amount of refactoring later.

Chris Pietschmann
Chris Pietschmann
Microsoft MVP (Azure & Dev Tools) | HashiCorp Ambassador | IBM Champion | MCT | Developer | Author

I am a solution architect, developer, SRE, trainer, author, and more. With 25 years of experience in the Software Development industry that includes working as a Consultant and Trainer in a wide array of different industries.