C# Classes and Structs
Classes
Classes are reference types that encapsulate data and behavior. They support inheritance and can be allocated on the heap.
Basic Class Structure
public class Customer
{
// Fields (private by default)
private readonly int id;
private string name;
// Constructor
public Customer(int id, string name)
{
this.id = id;
this.name = name ?? throw new ArgumentNullException(nameof(name));
}
// Properties
public int Id => id;
public string Name
{
get => name;
set => name = value ?? throw new ArgumentNullException(nameof(value));
}
// Methods
public void DisplayInfo()
{
Console.WriteLine($"Customer {Id}: {Name}");
}
}
Constructors
public class Order
{
public int Id { get; }
public DateTime CreatedAt { get; }
public string CustomerName { get; set; }
public OrderStatus Status { get; private set; }
// Primary constructor (parameterless)
public Order()
{
CreatedAt = DateTime.UtcNow;
Status = OrderStatus.Pending;
}
// Parameterized constructor
public Order(int id, string customerName) : this()
{
Id = id;
CustomerName = customerName;
}
// Constructor chaining with 'this'
public Order(int id) : this(id, "Unknown")
{
}
// Static constructor - runs once per type
static Order()
{
Console.WriteLine("Order type initialized");
}
}
Primary Constructors (C# 12)
Simplified syntax for capturing constructor parameters directly in the class declaration.
// Traditional approach
public class Product
{
private readonly string name;
private readonly decimal price;
public Product(string name, decimal price)
{
this.name = name;
this.price = price;
}
public string Name => name;
public decimal Price => price;
}
// Primary constructor (C# 12)
public class Product(string name, decimal price)
{
public string Name => name;
public decimal Price => price;
public decimal CalculateDiscount(decimal percentage) =>
price * (1 - percentage);
}
// Primary constructor parameters are available throughout the class
public class OrderProcessor(ILogger logger, IEmailService emailService)
{
public async Task ProcessAsync(Order order)
{
logger.LogInformation("Processing order {Id}", order.Id);
await emailService.SendConfirmationAsync(order);
}
}
Properties
public class Product
{
// Auto-implemented property
public string Name { get; set; }
// Read-only auto-property
public int Id { get; }
// Init-only setter (C# 9.0) - settable only during initialization
public string Sku { get; init; }
// Computed property
public decimal Price { get; set; }
public decimal Tax => Price * 0.08m;
public decimal TotalPrice => Price + Tax;
// Property with backing field
private string description;
public string Description
{
get => description ?? "";
set => description = value?.Trim();
}
// Property with validation
private int quantity;
public int Quantity
{
get => quantity;
set
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(value));
quantity = value;
}
}
// Required property (C# 11)
public required string Category { get; set; }
}
// Using init-only and required
var product = new Product
{
Id = 1, // Error: read-only
Sku = "ABC123", // OK: init-only
Name = "Widget",
Category = "Hardware" // Required: must be set
};
Object Initializers
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
public string Country { get; set; } = "USA";
}
// Object initializer syntax
var address = new Address
{
Street = "123 Main St",
City = "Seattle",
PostalCode = "98101"
// Country uses default
};
// Nested object initializers
public class Customer
{
public string Name { get; set; }
public Address Address { get; set; } = new();
}
var customer = new Customer
{
Name = "Alice",
Address =
{
Street = "456 Oak Ave",
City = "Portland"
}
};
// Collection initializers
public class OrderList
{
public List<string> Items { get; } = new();
}
var orderList = new OrderList
{
Items = { "Item1", "Item2", "Item3" }
};
Structs
Structs are value types allocated on the stack (when local) or inline (when in arrays or as fields). They’re copied on assignment.
Basic Struct
public struct Point
{
public double X { get; }
public double Y { get; }
public Point(double x, double y)
{
X = x;
Y = y;
}
public double DistanceFromOrigin() =>
Math.Sqrt(X * X + Y * Y);
public Point Translate(double dx, double dy) =>
new Point(X + dx, Y + dy);
}
// Value semantics
var p1 = new Point(3, 4);
var p2 = p1; // Copy
// Modifying p2 doesn't affect p1
Readonly Structs (C# 7.2)
Enforce immutability at compile time.
public readonly struct Vector3
{
public double X { get; }
public double Y { get; }
public double Z { get; }
public Vector3(double x, double y, double z)
{
X = x;
Y = y;
Z = z;
}
// All methods must be readonly (implicit in readonly struct)
public double Magnitude() =>
Math.Sqrt(X * X + Y * Y + Z * Z);
public Vector3 Normalize()
{
var mag = Magnitude();
return new Vector3(X / mag, Y / mag, Z / mag);
}
// Operator overloading
public static Vector3 operator +(Vector3 a, Vector3 b) =>
new Vector3(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
}
Record Structs (C# 10)
Value types with record semantics.
// Record struct - value type with value equality
public readonly record struct Coordinate(double Latitude, double Longitude);
var coord1 = new Coordinate(47.6062, -122.3321);
var coord2 = new Coordinate(47.6062, -122.3321);
Console.WriteLine(coord1 == coord2); // true - value equality
// With expressions for non-destructive mutation
var coord3 = coord1 with { Longitude = -122.5 };
Choosing Between Structs and Classes
The decision hinges on semantics, size, and identity.
Use a struct when:
- The type represents a single value (like a number or coordinate)
- Instances are small (16 bytes or less)
- The type is immutable (or should be)
- You’re creating many short-lived instances in performance-critical code
- Identity doesn’t matter—two instances with the same values should be considered equal
Use a class when:
- Identity matters—two objects with the same data are still distinct entities
- The type manages resources or has complex lifecycle
- Inheritance is needed
- The type has many fields or contains reference type fields
- Instances will be passed around extensively (avoiding copy overhead)
Why 16 bytes matters: Value types are copied on assignment and when passed to methods. A struct larger than 16 bytes often performs worse than a class because the copying overhead exceeds heap allocation cost. The runtime can also pass small structs in registers.
Why identity matters: A Customer with ID 42 is a specific entity. Even if you create another object with the same data, they represent different things conceptually. A Point(3, 4) is just a value, and any Point(3, 4) is interchangeable with any other.
| Factor | Struct | Class |
|---|---|---|
| Size | Small (≤16 bytes ideal) | Any size |
| Semantics | Value (copy on assign) | Reference (share) |
| Mutability | Prefer immutable | Either |
| Inheritance | Cannot inherit | Can inherit |
| Allocation | Stack/inline | Heap |
| Nullability | Not null by default | Can be null |
| Use case | Coordinates, colors, small data | Entities, services, complex objects |
// Good struct candidates
public readonly struct Color(byte R, byte G, byte B);
public readonly struct DateRange(DateTime Start, DateTime End);
public readonly struct Money(decimal Amount, string Currency);
// Should be classes
public class Customer { } // Identity matters
public class OrderService { } // Has behavior/dependencies
public class FileStream { } // Manages resources
Records (C# 9.0+)
Records provide value-based equality and immutability by default.
Record Classes
// Positional record - generates constructor, properties, deconstruction
public record Person(string FirstName, string LastName);
var person1 = new Person("John", "Doe");
var person2 = new Person("John", "Doe");
Console.WriteLine(person1 == person2); // true - value equality
// Deconstruction
var (first, last) = person1;
// With expression for non-destructive mutation
var person3 = person1 with { LastName = "Smith" };
// person1 unchanged, person3 is "John Smith"
// Record with additional members
public record Employee(string Name, string Department)
{
public DateTime HireDate { get; init; }
public int YearsEmployed =>
(DateTime.Now - HireDate).Days / 365;
}
// Inheritance
public record Manager(string Name, string Department, int TeamSize)
: Employee(Name, Department);
Record Classes vs Record Structs
// Record class (reference type)
public record Customer(int Id, string Name);
// Record struct (value type) - C# 10
public record struct Point(double X, double Y);
// Readonly record struct
public readonly record struct ImmutablePoint(double X, double Y);
| Feature | Record Class | Record Struct |
|---|---|---|
| Type | Reference | Value |
| Inheritance | Yes | No |
| Null | Can be null | Not null |
| Allocation | Heap | Stack/inline |
| Default mutability | Immutable (init) | Mutable |
| With expressions | Yes | Yes |
Sealed Classes
Prevent inheritance.
public sealed class Configuration
{
public string ConnectionString { get; init; }
public int Timeout { get; init; }
}
// Cannot inherit from Configuration
// public class ExtendedConfig : Configuration { } // Error
// Sealing improves performance (enables devirtualization)
// Seal classes when inheritance isn't intended
Abstract Classes
Cannot be instantiated; provide base for derived classes.
public abstract class Shape
{
public string Color { get; set; }
// Abstract method - must be implemented
public abstract double CalculateArea();
// Virtual method - can be overridden
public virtual void Draw()
{
Console.WriteLine($"Drawing {Color} shape");
}
// Regular method - inherited as-is
public void Describe()
{
Console.WriteLine($"A {Color} shape with area {CalculateArea()}");
}
}
public class Circle : Shape
{
public double Radius { get; set; }
public override double CalculateArea() =>
Math.PI * Radius * Radius;
public override void Draw()
{
base.Draw(); // Call base implementation
Console.WriteLine($"Circle with radius {Radius}");
}
}
Partial Classes
Split a class across multiple files.
// Customer.cs
public partial class Customer
{
public int Id { get; set; }
public string Name { get; set; }
}
// Customer.Validation.cs
public partial class Customer
{
public bool IsValid() =>
Id > 0 && !string.IsNullOrEmpty(Name);
}
// Customer.Persistence.cs (generated code often uses partial)
public partial class Customer
{
public void Save() { /* ... */ }
}
Static Classes
Cannot be instantiated; contain only static members.
public static class MathHelper
{
public const double Pi = 3.14159265358979;
public static double Square(double x) => x * x;
public static double Cube(double x) => x * x * x;
public static bool IsEven(int n) => n % 2 == 0;
}
// Usage
double area = MathHelper.Pi * MathHelper.Square(radius);
Nested Classes
Classes defined within other classes.
public class LinkedList<T>
{
private Node? head;
public void Add(T value)
{
var newNode = new Node(value);
newNode.Next = head;
head = newNode;
}
// Private nested class - implementation detail
private class Node
{
public T Value { get; }
public Node? Next { get; set; }
public Node(T value)
{
Value = value;
}
}
}
// Public nested class for related types
public class Order
{
public List<LineItem> Items { get; } = new();
public class LineItem
{
public string ProductName { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; }
}
}
var item = new Order.LineItem { ProductName = "Widget" };
Version History
| Feature | Version | Significance |
|---|---|---|
| Auto-properties | C# 3.0 | Simplified property syntax |
| Object initializers | C# 3.0 | Declarative object creation |
| Readonly structs | C# 7.2 | Enforced immutability |
| Init-only setters | C# 9.0 | Immutable after construction |
| Records | C# 9.0 | Value equality for reference types |
| Record structs | C# 10 | Value equality for value types |
| Required members | C# 11 | Enforced initialization |
| Primary constructors | C# 12 | Simplified class definitions |
Key Takeaways
Default to classes: Use classes for most types. Use structs only for small, immutable value objects.
Prefer immutability: Use init setters, readonly structs, and records to create types that can’t be accidentally modified.
Use records for DTOs and value objects: Records provide value equality, with expressions, and clean syntax for data-carrying types.
Seal classes intentionally: If a class isn’t designed for inheritance, seal it. This documents intent and enables compiler optimizations.
Keep structs small: The copy-on-assignment semantics of structs make large structs expensive to pass around.
Found this guide helpful? Share it with your team:
Share on LinkedIn