Getting Started with Modern C# for .NET Development

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.

Traditional Program.cs (C# 8 and earlier)
using System;

namespace MyApplication
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello, World!");
            ProcessData();
        }

        static void ProcessData()
        {
            Console.WriteLine("Processing data...");
        }
    }
}
Modern Program.cs (C# 9+)
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.

Traditional Class Approach
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} }}";
    }
}
Modern Record Declaration
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.

Program.cs
// 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.

Program.cs
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.

Program.cs
// 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}");
ModernCSharp.csproj
<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.

Frequently Asked Questions (FAQ)

What are the key features of modern C# compared to older versions?

Modern C# includes top-level statements for simpler program structure, record types for immutable data, pattern matching for concise conditionals, nullable reference types for better null safety, and file-scoped namespaces. These features reduce boilerplate code and make programs more expressive and safer.

Should I enable nullable reference types in my projects?

Yes, enabling nullable reference types helps catch null reference errors at compile time. New .NET projects enable this by default. You'll need to annotate reference types explicitly as nullable with the ? symbol. This catches potential null errors during development instead of at runtime.

When should I use record types instead of classes?

Use records for data transfer objects, value-like types, and any data that should be immutable by default. Records provide built-in value equality, deconstruction, and with-expressions for non-destructive mutation. Use classes when you need reference equality or mutable state.

How do I migrate existing C# code to use modern features?

Start by updating your .csproj to target net8.0 and enable nullable reference types. Replace traditional Main methods with top-level statements in new files. Convert DTOs to record types where immutability makes sense. Replace verbose if-else chains with pattern matching. Migrate incrementally, testing after each change.

Back to Articles