C# Syntax Evolution: 2002 vs 2025
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
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) |
| Platforms | Windows only | Windows, Linux, macOS | All + 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.