The Evolution of .NET: Why Modern C# Development Feels Like a Different Language

If you’ve been writing C# for more than a decade, you’ve witnessed something remarkable: the language you learned in the early 2000s bears only a superficial resemblance to what we write today. Modern C# development feels like a different language entirely.

C# Syntax Evolution: 2002 vs 2025

C# Evolution Comparison

The Transformation Journey

When .NET Framework first appeared, we built applications with ceremony. Configuration files sprawled across XML documents, dependency injection was a pattern we implemented manually, and running .NET on Linux seemed like science fiction. Fast forward to .NET 9, and we’re writing cross-platform applications with minimal boilerplate, leveraging native AOT compilation, and deploying to containers with the same ease as any other modern stack.

Modern C# Features Timeline

C# Features Timeline

The API Layer Revolution

Perhaps nowhere is the evolution more apparent than in how we build APIs. Traditional Web API controllers served us well, but Minimal APIs (introduced in .NET 6) changed the conversation entirely.

Minimal API Example

// Program.cs - Complete API in ~10 lines

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IUserService, UserService>();



var app = builder.Build();



app.MapGet("/api/users/{id}", async (int id, IUserService svc) =>

    await svc.GetUserAsync(id) ?? Results.NotFound());



app.MapPost("/api/users", async (User user, IUserService svc) =>

{

    var created = await svc.CreateUserAsync(user);

    return Results.Created($"/api/users/{created.Id}", created);

});



app.Run();

Modern Controller with Primary Constructor

[ApiController]

[Route("api/[controller]")]

public class UserController(

    IUserService service,

    ILogger<UserController> logger) : ControllerBase

{

    [HttpGet("{id}")]

    public async Task<ActionResult<User>> Get(int id) =>

        await service.GetUserAsync(id) is { } user

            ? Ok(user)

            : NotFound();



    [HttpPost]

    public async Task<ActionResult> Create(User user)

    {

        var created = await service.CreateUserAsync(user);

        return CreatedAtAction(nameof(Get),

            new { id = created.Id }, created);

    }

}

gRPC for Service-to-Service Communication

// Proto definition

syntax = "proto3";



service UserService {

    rpc GetUser (UserRequest) returns (UserResponse);

    rpc StreamUsers (Empty) returns (stream UserResponse);

}



// Implementation

public class UserServiceImpl : UserService.UserServiceBase

{

    public override async Task<UserResponse> GetUser(

        UserRequest request, ServerCallContext context)

    {

        var user = await _repository.GetAsync(request.Id);

        return new UserResponse { Id = user.Id, Name = user.Name };

    }



    public override async Task StreamUsers(

        Empty request,

        IServerStreamWriter<UserResponse> stream,

        ServerCallContext context)

    {

        await foreach (var user in _repository.GetAllAsync())

        {

            await stream.WriteAsync(new UserResponse

            {

                Id = user.Id,

                Name = user.Name

            });

        }

    }

}

Data Access: EF Core vs Dapper

Entity Framework Core has matured into a genuinely capable ORM. The performance improvements in recent versions, combined with features like compiled queries and split queries, have addressed many historical criticisms.
// EF Core with compiled query

private static readonly Func<AppDbContext, int, Task<User?>>

    _getUserQuery = EF.CompileAsyncQuery(

        (AppDbContext db, int id) =>

            db.Users

                .Include(u => u.Orders.Take(10))  // Limit included

                .AsSplitQuery()  // Separate queries

                .FirstOrDefault(u => u.Id == id));



public async Task<User?> GetUserAsync(int id)

    => await _getUserQuery(_context, id);



// Dapper for raw SQL performance

public async Task<IEnumerable<UserSummary>> GetTopUsersAsync()

{

    const string sql = @"

        SELECT u.Id, u.Name, COUNT(o.Id) as OrderCount

        FROM Users u

        LEFT JOIN Orders o ON u.Id = o.UserId

        GROUP BY u.Id, u.Name

        ORDER BY OrderCount DESC

        LIMIT 100";



    return await _connection.QueryAsync<UserSummary>(sql);

}

CQRS with MediatR

// Query

public record GetUserQuery(int Id) : IRequest<User?>;



public class GetUserHandler(

    IUserRepository repo) : IRequestHandler<GetUserQuery, User?>

{

    public async Task<User?> Handle(

        GetUserQuery request,

        CancellationToken ct)

        => await repo.GetByIdAsync(request.Id, ct);

}



// Command

public record CreateUserCommand(string Name, string Email)

    : IRequest<User>;



public class CreateUserHandler(

    IUserRepository repo) : IRequestHandler<CreateUserCommand, User>

{

    public async Task<User> Handle(

        CreateUserCommand cmd,

        CancellationToken ct)

    {

        var user = new User { Name = cmd.Name, Email = cmd.Email };

        await repo.AddAsync(user, ct);

        return user;

    }

}



// Usage in controller

[HttpGet("{id}")]

public async Task<ActionResult<User>> Get(int id)

    => await _mediator.Send(new GetUserQuery(id))

        is { } user ? Ok(user) : NotFound();

Modern C# Language Features

Records for Immutable Data

// C# 9+: Records

public record User(int Id, string Name, string Email)

{

    public string FullName => $"{Name} ({Email})";

}



// Immutable updates with 'with'

var user = new User(1, "John", "john@example.com");

var updated = user with { Name = "Jane" };



// Value equality

var user1 = new User(1, "John", "john@example.com");

var user2 = new User(1, "John", "john@example.com");

Console.WriteLine(user1 == user2);  // True!

Pattern Matching

// Modern pattern matching

public string GetDiscount(Customer customer) => customer switch

{

    { Premium: true, YearsActive: > 5 } => "30% off",

    { Premium: true } => "20% off",

    { YearsActive: > 3 } => "15% off",

    { YearsActive: > 1 } => "10% off",

    _ => "5% off"

};



// List patterns (C# 11+)

if (numbers is [var first, .., var last])

    Console.WriteLine($"First: {first}, Last: {last}");

Performance: .NET Framework vs .NET 9

Metric.NET Framework 4.8.NET 9.NET 9 + AOT
Startup time~3 seconds~1 second~50ms (60x faster!)
Memory baseline~30 MB~15 MB~5 MB (-83%)
Throughput (req/s)~50K~100K~150K (3x)
Deployment size~50 MB~70 MB~10 MB (self-contained)
PlatformsWindows onlyWindows, Linux, macOSAll + ARM64

Best Practices for Modern .NET

  • Use Minimal APIs for microservices and simple endpoints
  • Use Controllers for complex APIs with extensive model binding
  • Adopt gRPC for service-to-service communication
  • Leverage EF Core for most data access with compiled queries
  • Use Dapper for performance-critical raw SQL
  • Implement CQRS with MediatR for separation of concerns
  • Enable nullable reference types for better null safety
  • Use records for immutable data transfer objects
  • Adopt primary constructors to reduce boilerplate
  • Consider AOT compilation for containers and serverless

Looking Forward

The evolution of C# and .NET is not slowing down. With each release, the platform becomes more performant, more concise, and more cross-platform. If you haven’t explored modern .NET in the past few years, you’re in for a pleasant surprise. The ceremony is gone, the performance is exceptional, and the developer experience rivals any modern stack.

References


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.