Modern C# Versus Legacy Code
The C# language you learned five years ago looks different from the C# you write today. Earlier versions required verbose ceremony for even simple programs. You needed explicit Main methods, lengthy namespace declarations, and manual null checking everywhere. Every program started with dozens of lines of boilerplate before you could write actual logic.
Modern C# eliminates this friction. Starting with C# 9 and accelerating through C# 12 in .NET 8, the language adopted features that reduce boilerplate while improving safety and expressiveness. Top-level statements let you start coding immediately. Record types handle immutable data with minimal syntax. Pattern matching replaces verbose conditional logic. Nullable reference types catch null errors at compile time instead of runtime.
This guide shows you the essential modern C# features you need today. You'll learn when to use records versus classes, how pattern matching simplifies conditionals, and why nullable reference types prevent crashes. Each section builds on the previous, taking you from basic syntax to practical patterns you'll use in production code.
Simplified Program Structure with Top-Level Statements
Earlier C# versions forced you to wrap code in a class and Main method, even for simple programs. This created unnecessary ceremony for console applications, scripts, and learning exercises. You spent lines of code on structure before writing your first meaningful statement.
Top-level statements remove this requirement. Write your code directly at the file level, and the compiler generates the necessary wrapping automatically. This works for console apps, web apps with minimal APIs, and any scenario where explicit structure adds no value.
Here's how a traditional program compares to the modern approach. Notice how much cleaner the modern version reads.
using System;
namespace MyApplication
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
ProcessData();
}
static void ProcessData()
{
Console.WriteLine("Processing data...");
}
}
}
Console.WriteLine("Hello, World!");
ProcessData();
void ProcessData()
{
Console.WriteLine("Processing data...");
}
The modern version eliminates eight lines of boilerplate. You still define local functions like ProcessData when needed, but you skip the class and Main ceremony. This makes console applications and scripts far more approachable. You can access command-line arguments through the implicit args parameter.
Working with Record Types for Data
Classes have always worked well for objects with behavior, but they require significant code for simple data containers. You write properties, override Equals and GetHashCode, implement IEquatable, and add ToString implementations. Records provide all this automatically with a single declaration.
Records are reference types optimized for immutable data. They provide value-based equality by default, meaning two record instances with the same property values are considered equal. This makes them perfect for data transfer objects, API responses, and any scenario where you care about data content rather than object identity.
Here's a comparison showing how records simplify data type definitions.
public class Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
public int Age { get; init; }
public override bool Equals(object? obj)
{
return obj is Person other &&
FirstName == other.FirstName &&
LastName == other.LastName &&
Age == other.Age;
}
public override int GetHashCode()
{
return HashCode.Combine(FirstName, LastName, Age);
}
public override string ToString()
{
return $"Person {{ FirstName = {FirstName}, LastName = {LastName}, Age = {Age} }}";
}
}
public record Person(string FirstName, string LastName, int Age);
// Usage example
var person1 = new Person("John", "Smith", 30);
var person2 = new Person("John", "Smith", 30);
Console.WriteLine(person1 == person2); // True - value equality
Console.WriteLine(person1); // Person { FirstName = John, LastName = Smith, Age = 30 }
// Create modified copy with 'with' expression
var person3 = person1 with { Age = 31 };
Console.WriteLine(person3); // Person { FirstName = John, LastName = Smith, Age = 31 }
Output:
True
Person { FirstName = John, LastName = Smith, Age = 30 }
Person { FirstName = John, LastName = Smith, Age = 31 }
The record definition replaces 25 lines of class code with a single line. Records automatically implement value equality, provide a readable ToString, and support non-destructive mutation through with-expressions. This syntax dramatically reduces boilerplate for data-focused types.
Pattern Matching for Cleaner Conditionals
Traditional if-else chains and switch statements become unwieldy when checking types, values, and properties simultaneously. You end up with nested conditionals, repeated type casts, and verbose null checks. Pattern matching consolidates these checks into concise, readable expressions.
Modern C# supports patterns in switch expressions, if statements, and is expressions. You can match on types, check properties, combine conditions, and extract values all in a single pattern. This makes code that checks complex conditions far more maintainable.
Here's an example showing traditional conditionals versus pattern matching.
// Example with different shape types
abstract record Shape;
record Circle(double Radius) : Shape;
record Rectangle(double Width, double Height) : Shape;
record Triangle(double Base, double Height) : Shape;
// Traditional approach
double CalculateAreaOld(Shape shape)
{
if (shape is Circle)
{
var circle = (Circle)shape;
return Math.PI * circle.Radius * circle.Radius;
}
else if (shape is Rectangle)
{
var rect = (Rectangle)shape;
return rect.Width * rect.Height;
}
else if (shape is Triangle)
{
var tri = (Triangle)shape;
return 0.5 * tri.Base * tri.Height;
}
return 0;
}
// Modern pattern matching approach
double CalculateArea(Shape shape) => shape switch
{
Circle { Radius: var r } => Math.PI * r * r,
Rectangle { Width: var w, Height: var h } => w * h,
Triangle { Base: var b, Height: var h } => 0.5 * b * h,
_ => 0
};
// Usage
var shapes = new Shape[]
{
new Circle(5),
new Rectangle(4, 6),
new Triangle(3, 8)
};
foreach (var shape in shapes)
{
Console.WriteLine($"{shape} has area: {CalculateArea(shape):F2}");
}
Output:
Circle { Radius = 5 } has area: 78.54
Rectangle { Width = 4, Height = 6 } has area: 24.00
Triangle { Base = 3, Height = 8 } has area: 12.00
The pattern matching version eliminates explicit casts and nested if blocks. Each pattern checks the type and extracts properties in one expression. The switch expression is also an expression that returns a value, making it more functional in style. This approach scales better as you add more shape types.
Preventing Null Errors with Nullable Reference Types
Null reference exceptions have plagued C# since its creation. The language treated all reference types as nullable by default, meaning any variable could be null at any time. Developers had to defensively check for null everywhere, and missing even one check could crash production applications.
Nullable reference types change this by making reference types non-nullable by default. When you declare a string variable, the compiler expects it to never be null. To allow null, you explicitly mark it with the ? symbol. The compiler analyzes your code and warns when you might dereference a null value.
This example demonstrates how nullable reference types catch potential errors at compile time.
public class UserService
{
// Non-nullable string - must always have a value
public string GetUserName(int userId)
{
if (userId > 0)
{
return $"User{userId}";
}
// Compiler warning: possible null reference return
// return null; // Won't compile without suppression
return "Unknown"; // Must return a non-null value
}
// Nullable string - can return null
public string? FindUserEmail(int userId)
{
if (userId == 1)
{
return "user@example.com";
}
return null; // Allowed because return type is string?
}
public void ProcessUser(int userId)
{
string name = GetUserName(userId);
Console.WriteLine(name.ToUpper()); // Safe - name can't be null
string? email = FindUserEmail(userId);
// Console.WriteLine(email.ToLower()); // Compiler warning!
// Must check for null first
if (email != null)
{
Console.WriteLine(email.ToLower()); // Safe after null check
}
}
}
The compiler tracks nullability throughout your code. Methods returning string must never return null. Methods returning string? can return null, and the compiler forces you to check before dereferencing. This catches null reference bugs during development instead of after deployment.
Try It Yourself
This complete example combines modern C# features into a practical application. It demonstrates top-level statements, records, pattern matching, and nullable reference types working together.
// Top-level statements - no Main method needed
Console.WriteLine("=== Modern C# Demo ===\n");
// Record types for data
record Product(int Id, string Name, decimal Price, string? Description);
// Create some products
var products = new List<Product>
{
new(1, "Laptop", 999.99m, "High-performance laptop"),
new(2, "Mouse", 29.99m, null),
new(3, "Keyboard", 79.99m, "Mechanical keyboard"),
new(4, "Monitor", 299.99m, null)
};
// Pattern matching in action
foreach (var product in products)
{
var category = product.Price switch
{
< 50 => "Budget",
>= 50 and < 200 => "Mid-range",
>= 200 => "Premium",
_ => "Unknown"
};
Console.WriteLine($"{product.Name} ({category})");
// Nullable reference type handling
if (product.Description != null)
{
Console.WriteLine($" Description: {product.Description}");
}
}
// Calculate total using LINQ
var total = products.Sum(p => p.Price);
Console.WriteLine($"\nTotal value: ${total:F2}");
// With-expression for non-destructive updates
var discountedLaptop = products[0] with { Price = 799.99m };
Console.WriteLine($"\nDiscounted: {discountedLaptop.Name} - ${discountedLaptop.Price}");
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Output:
=== Modern C# Demo ===
Laptop (Premium)
Description: High-performance laptop
Mouse (Budget)
Keyboard (Mid-range)
Description: Mechanical keyboard
Monitor (Premium)
Total value: $1409.96
Discounted: Laptop - $799.99
This example shows how modern C# features work together naturally. The code is concise, type-safe, and handles nullability explicitly. Pattern matching makes categorization readable, and records eliminate boilerplate for the Product type.
Choosing the Right Approach
Modern C# gives you options, and choosing the right feature for each scenario matters. Use records when you need value semantics and immutability. Data transfer objects, API models, and configuration classes work great as records. Use classes when you need reference semantics, inheritance hierarchies, or mutable state that changes over the object's lifetime.
Pattern matching shines when you have multiple conditions to check or when working with discriminated unions. For simple null checks or single conditions, traditional if statements remain perfectly fine. Don't force pattern matching into every scenario just because it's available.
Nullable reference types require an adjustment period. Start with new projects where you can enable nullability from day one. For existing projects, enable it incrementally by file or namespace. The compiler warnings catch real bugs, but you'll need to add null checks or change return types to address them properly.
Top-level statements work best for simple programs and APIs with minimal startup code. For applications with complex initialization, multiple types, or when you need more control over startup, the traditional Main method still makes sense. The goal is reducing ceremony, not eliminating all structure.