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, anOrder, 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
classinstance 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
interfaceplus 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 classfor 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
EqualsandGetHashCode. - 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 structor plainstructis better. - Mutable records (
record classwith settable properties) exist but undermine the value-equality story. If you need mutability, rethink whether arecordis 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
structhelps.
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
structis assigned to anobject, 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
structis reflection-based and slow. If you care about performance or correctness in comparisons, overrideEqualsandGetHashCode, or userecord 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 classor 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
recordmakes that easy. Astructmeans callers that copy by value get stale copies in subtle ways. - Will behavior be injected or swapped? Design toward an
interfaceearly. - Will this be subclassed? Consider whether you want that.
sealed recordandsealed classlock the type down and help the JIT optimize. - Is this a domain entity or an infrastructure concern? Domain entities tend to be
classwith identity. Infrastructure value objects tend to berecord.
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.