.NET has a perception problem similar to Java’s. Developers who last encountered it in the mid-2000s remember Windows-only deployment, expensive Visual Studio licenses, and COM interop nightmares. Since Microsoft open-sourced the platform in 2016 and rebuilt it from scratch as .NET Core (now just .NET), that world no longer exists.
Today’s .NET runs natively on Linux and macOS, ships as single self-contained binaries, compiles ahead-of-time to native code with sub-100ms startup times, and consistently ranks at the top of the TechEmpower benchmarks. ASP.NET Core reached 27.5 million requests per second in Round 23 plaintext tests. If you haven’t looked at it since the .NET Framework era, you’re working with a decade-old mental model.
The Release Cadence
.NET follows an annual November release schedule with alternating LTS (Long-Term Support) and STS (Standard-Term Support) releases:
| Version |
Type |
Released |
Support Until |
| .NET 8 |
LTS |
Nov 2023 |
Nov 2026 |
| .NET 9 |
STS |
Nov 2024 |
Nov 2025 |
| .NET 10 |
LTS |
Nov 2025 |
Nov 2028 |
LTS releases get three years of support. STS releases get 18 months. For production systems, target LTS versions (.NET 8 or .NET 10). Feature releases ship with new C# language versions: C# 12 with .NET 8, C# 13 with .NET 9, C# 14 with .NET 10.
C# Language Features That Matter
Records
Like Java’s records, C# records are immutable data types with value semantics:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// Positional record — constructor, properties, equality, ToString all generated
public record User(string Name, string Email, UserRole Role);
// With custom validation via compact constructor
public record User(string Name, string Email, UserRole Role)
{
public User
{
ArgumentException.ThrowIfNullOrEmpty(Name);
if (!Email.Contains('@')) throw new ArgumentException("Invalid email", nameof(Email));
Name = Name.Trim();
}
public bool IsAdmin => Role == UserRole.Admin;
}
// Non-destructive mutation
var admin = new User("Alice", "alice@example.com", UserRole.Viewer);
var promoted = admin with { Role = UserRole.Admin };
// alice is unchanged; promoted is a new record
|
Records support both positional syntax (above) and property syntax for more control:
1
2
3
4
5
6
|
public record OrderSummary
{
public required int Id { get; init; }
public required decimal Total { get; init; }
public string? Note { get; init; } // optional
}
|
Primary Constructors (C# 12)
Classes and structs can declare parameters directly in the class declaration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// Before C# 12
public class OrderService
{
private readonly IOrderRepository _repo;
private readonly ILogger<OrderService> _logger;
public OrderService(IOrderRepository repo, ILogger<OrderService> logger)
{
_repo = repo;
_logger = logger;
}
}
// C# 12: primary constructor
public class OrderService(IOrderRepository repo, ILogger<OrderService> logger)
{
public async Task<Order?> GetAsync(int id)
{
logger.LogInformation("Fetching order {Id}", id);
return await repo.GetByIdAsync(id);
}
}
|
The parameters repo and logger are captured and available throughout the class body. No explicit field declarations needed for constructor injection.
Nullable Reference Types
Opt-in compile-time null safety (enabled by default in new projects via <Nullable>enable</Nullable> in the project file):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// string is non-nullable — compiler ensures it's never null
public string Name { get; init; }
// string? is nullable — must null-check before use
public string? MiddleName { get; init; }
string Display(User user)
{
// Compiler error: user.MiddleName might be null
return user.MiddleName.ToUpper();
// Correct approaches:
return user.MiddleName?.ToUpper() ?? "N/A"; // null-conditional + null-coalescing
// or
if (user.MiddleName is string middle) return middle.ToUpper(); // pattern matching
}
|
This catches null reference exceptions at compile time with zero runtime overhead—the annotations are erased at runtime.
Pattern Matching
C# pattern matching is mature and expressive:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// Type patterns
string Describe(object obj) => obj switch
{
int n when n > 0 => $"positive int: {n}",
int n when n < 0 => $"negative int: {n}",
int => "zero",
string { Length: 0 }=> "empty string",
string s => $"string: \"{s}\"",
null => "null",
_ => $"other: {obj.GetType().Name}"
};
// Positional patterns with records
record Point(int X, int Y);
string Quadrant(Point p) => p switch
{
( > 0, > 0) => "first",
( < 0, > 0) => "second",
( < 0, < 0) => "third",
( > 0, < 0) => "fourth",
_ => "on axis"
};
// List patterns (C# 11+)
string DescribeList(int[] nums) => nums switch
{
[] => "empty",
[var x] => $"single: {x}",
[var x, var y] => $"pair: {x}, {y}",
[var first, .., var last] => $"starts {first}, ends {last}"
};
|
Collection Expressions (C# 12)
A unified syntax for initializing any collection type:
1
2
3
4
5
6
7
8
9
10
11
|
int[] array = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3];
// Spread operator
int[] first = [1, 2, 3];
int[] second = [4, 5, 6];
int[] all = [..first, ..second]; // [1, 2, 3, 4, 5, 6]
// Works with any type that has a collection initializer
HashSet<string> roles = ["admin", "viewer", "guest"];
|
ASP.NET Core: Minimal APIs
Minimal APIs define endpoints directly in Program.cs without controller classes. Microsoft recommends them for new projects:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
var builder = WebApplication.CreateBuilder(args);
// Register services
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("Database")));
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
app.UseSwagger().UseSwaggerUI();
// Define endpoints
var users = app.MapGroup("/users").WithTags("Users");
users.MapGet("/", async (IUserService svc) =>
Results.Ok(await svc.GetAllAsync()));
users.MapGet("/{id:int}", async (int id, IUserService svc) =>
await svc.GetByIdAsync(id) is User user
? Results.Ok(user)
: Results.NotFound());
users.MapPost("/", async (CreateUserRequest req, IUserService svc) =>
{
var user = await svc.CreateAsync(req);
return Results.Created($"/users/{user.Id}", user);
})
.WithRequestValidation<CreateUserRequest>(); // custom filter
users.MapPut("/{id:int}", async (int id, UpdateUserRequest req, IUserService svc) =>
await svc.UpdateAsync(id, req) is User updated
? Results.Ok(updated)
: Results.NotFound());
users.MapDelete("/{id:int}", async (int id, IUserService svc) =>
{
await svc.DeleteAsync(id);
return Results.NoContent();
});
app.Run();
|
Endpoints receive dependencies via parameter injection—the framework resolves them from the DI container automatically.
Middleware Pipeline
1
2
3
4
5
6
7
8
9
|
var app = builder.Build();
// Middleware runs in registration order
app.UseExceptionHandler("/error"); // catch unhandled exceptions
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseOutputCache(); // cache responses
app.MapControllers(); // or MapEndpoints for minimal APIs
|
Custom middleware:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
app.Use(async (context, next) =>
{
var sw = Stopwatch.StartNew();
await next(context);
sw.Stop();
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogInformation("{Method} {Path} {StatusCode} {ElapsedMs}ms",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
sw.ElapsedMilliseconds);
});
|
Dependency Injection
ASP.NET Core has a built-in DI container. Three service lifetimes:
1
2
3
|
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>(); // new instance every time
builder.Services.AddScoped<IUserService, UserService>(); // one per HTTP request
builder.Services.AddSingleton<ICache, MemoryCache>(); // one for app lifetime
|
For complex registrations:
1
2
3
4
5
6
7
8
9
10
11
|
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
})
.AddResilienceHandler("github", builder =>
{
builder.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 3 });
builder.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions());
builder.AddTimeout(TimeSpan.FromSeconds(10));
});
|
Output Caching
Built-in response caching reduces redundant database calls:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
builder.Services.AddOutputCache();
// ...
app.MapGet("/products", async (IProductService svc) =>
Results.Ok(await svc.GetAllAsync()))
.CacheOutput(p => p.Expire(TimeSpan.FromMinutes(5)).Tag("products"));
// Invalidate cached responses when data changes
app.MapPost("/products", async (CreateProductRequest req, IOutputCacheStore cache, ...) =>
{
var product = await svc.CreateAsync(req);
await cache.EvictByTagAsync("products", default);
return Results.Created($"/products/{product.Id}", product);
});
|
Entity Framework Core
EF Core is the standard ORM for .NET. Code-first workflow: define your model in C#, generate migrations, deploy.
Defining the Model
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
public class Order
{
public int Id { get; set; }
public string CustomerId { get; set; } = null!;
public decimal Total { get; set; }
public OrderStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
// Navigation properties
public List<OrderItem> Items { get; set; } = [];
}
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Order>(e =>
{
e.HasKey(o => o.Id);
e.Property(o => o.Total).HasPrecision(18, 2);
e.Property(o => o.CreatedAt).HasDefaultValueSql("NOW()");
e.HasMany(o => o.Items).WithOne().HasForeignKey(i => i.OrderId);
});
}
}
|
Migrations:
1
2
|
dotnet ef migrations add InitialSchema
dotnet ef database update
|
Querying
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
public class OrderRepository(AppDbContext db)
{
// Basic query
public Task<List<Order>> GetActiveOrdersAsync() =>
db.Orders
.Where(o => o.Status == OrderStatus.Active)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
// Projection — only load what you need
public Task<List<OrderSummary>> GetSummariesAsync() =>
db.Orders
.Select(o => new OrderSummary(o.Id, o.CustomerId, o.Total))
.ToListAsync();
// No-tracking for read-only queries — skips change tracking overhead
public Task<Order?> GetForDisplayAsync(int id) =>
db.Orders
.AsNoTracking()
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id);
// Compiled query — parsed once at startup, reused on every call
private static readonly Func<AppDbContext, int, Task<Order?>> GetByIdQuery =
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
ctx.Orders.FirstOrDefault(o => o.Id == id));
public Task<Order?> GetByIdAsync(int id) => GetByIdQuery(db, id);
// Raw SQL for complex queries EF can't express efficiently
public Task<List<CustomerRevenue>> GetTopCustomersAsync(int limit) =>
db.Database
.SqlQuery<CustomerRevenue>($"""
SELECT customer_id, SUM(total) as revenue
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
ORDER BY revenue DESC
LIMIT {limit}
""")
.ToListAsync();
}
|
Use AsNoTracking() for any query that feeds a read-only response. EF’s change tracker adds overhead that’s only useful when you plan to save modifications.
Migrations in CI/CD
1
2
3
4
5
|
# Generate a migration bundle — a self-contained executable
dotnet ef migrations bundle --output ./efbundle
# In your deployment pipeline
./efbundle --connection "$DATABASE_URL"
|
Migration bundles are the recommended deployment approach: a single binary that applies pending migrations, no dotnet ef tooling required at runtime.
Span<T> and Memory<T>
Span<T> is a stack-allocated view over a contiguous region of memory. It enables slicing and processing without heap allocations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// Parsing key=value pairs — no heap allocations
public static Dictionary<string, string> ParseConfig(string input)
{
var result = new Dictionary<string, string>();
ReadOnlySpan<char> remaining = input;
while (!remaining.IsEmpty)
{
int newline = remaining.IndexOf('\n');
var line = newline >= 0 ? remaining[..newline] : remaining;
int eq = line.IndexOf('=');
if (eq > 0)
{
var key = line[..eq].Trim().ToString();
var value = line[(eq + 1)..].Trim().ToString();
result[key] = value;
}
remaining = newline >= 0 ? remaining[(newline + 1)..] : default;
}
return result;
}
|
For async code where Span<T> can’t cross await boundaries, use Memory<T>:
1
2
3
4
5
6
7
8
9
|
async Task ProcessChunksAsync(Memory<byte> buffer)
{
while (!buffer.IsEmpty)
{
var chunk = buffer[..Math.Min(4096, buffer.Length)];
await WriteChunkAsync(chunk);
buffer = buffer[chunk.Length..];
}
}
|
System.Text.Json
The built-in JSON library is roughly half the execution time and one-ninth the memory of Newtonsoft.Json:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// Source generation — analyzes types at compile time, no reflection at runtime
[JsonSerializable(typeof(User))]
[JsonSerializable(typeof(List<User>))]
[JsonSerializable(typeof(ApiResponse<User>))]
public partial class AppJsonContext : JsonSerializerContext {}
// Register globally
builder.Services.ConfigureHttpJsonOptions(opts =>
{
opts.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});
// Serialize/deserialize directly
var json = JsonSerializer.Serialize(user, AppJsonContext.Default.User);
var user = JsonSerializer.Deserialize(json, AppJsonContext.Default.User);
|
Source-generated serialization is especially important for Native AOT, where reflection-based serialization doesn’t work.
FrozenDictionary and FrozenSet (.NET 8)
For lookup tables that are populated once at startup and read frequently:
1
2
3
4
5
6
7
8
9
10
|
// Regular dictionary: mutable, general-purpose hashing
var regular = new Dictionary<string, Config>(configs);
// FrozenDictionary: higher creation cost, optimized for reads
// Use for reference data loaded at startup
var frozen = configs.ToFrozenDictionary(c => c.Key, c => c.Value);
// Lookup is measurably faster — the frozen structure is analyzed
// at creation time and optimized for the specific key set
var config = frozen["feature-flags"];
|
SearchValues (.NET 8)
Vectorized character set searching — significantly faster than IndexOfAny for larger sets:
1
2
3
4
5
6
7
8
9
10
|
// Create once, reuse — analyzed at creation time for SIMD optimization
private static readonly SearchValues<char> SpecialChars =
SearchValues.Create("<>&\"'/");
public static bool ContainsSpecialChar(ReadOnlySpan<char> input) =>
input.ContainsAny(SpecialChars);
// For strings — find first occurrence of any special character
public static int FindFirstSpecial(string input) =>
input.AsSpan().IndexOfAny(SpecialChars);
|
Native AOT
.NET 8+ supports ahead-of-time compilation to native binaries, similar to GraalVM native-image:
1
2
|
<!-- In your .csproj -->
<PublishAot>true</PublishAot>
|
1
2
|
dotnet publish -c Release -r linux-x64
# Produces a self-contained native binary
|
Performance comparison for a minimal ASP.NET Core app:
|
JIT (.NET) |
Native AOT |
| Startup time |
300–800ms |
10–50ms |
| Memory footprint |
80–200 MB |
20–60 MB |
| Binary size |
Runtime separate |
10–30 MB self-contained |
| Peak throughput |
100% (warms up) |
~90% (no JIT optimization) |
Constraints with Native AOT:
- Reflection works but requires explicit metadata hints (trimming analysis)
- Dynamic code generation (
System.Reflection.Emit) not supported
- Some NuGet packages aren’t yet AOT-compatible — check before committing
ASP.NET Core and System.Text.Json with source generation are fully AOT-compatible. For greenfield APIs, AOT is production-ready.
Testing
Unit Tests with xUnit and FluentAssertions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
public class OrderServiceTests
{
[Fact]
public async Task CreateOrder_ValidRequest_ReturnsCreatedOrder()
{
// Arrange
var repo = Substitute.For<IOrderRepository>(); // NSubstitute
var logger = Substitute.For<ILogger<OrderService>>();
var service = new OrderService(repo, logger);
var request = new CreateOrderRequest("CUST-1", 99.99m);
repo.AddAsync(Arg.Any<Order>())
.Returns(new Order { Id = 42, Total = 99.99m });
// Act
var result = await service.CreateOrderAsync(request);
// Assert — FluentAssertions
result.Should().NotBeNull();
result.Id.Should().Be(42);
result.Total.Should().Be(99.99m);
await repo.Received(1).AddAsync(Arg.Any<Order>());
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-100)]
public async Task CreateOrder_NegativeTotal_ThrowsValidationException(decimal total)
{
var service = new OrderService(
Substitute.For<IOrderRepository>(),
Substitute.For<ILogger<OrderService>>());
await service.Invoking(s => s.CreateOrderAsync(new("CUST-1", total)))
.Should().ThrowAsync<ValidationException>();
}
}
|
[Theory] with [InlineData] is xUnit’s table-driven test equivalent—run the same test with multiple inputs.
Integration Tests with WebApplicationFactory
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
public class OrderApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client = factory
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace real DB with in-memory for tests
var descriptor = services.Single(d =>
d.ServiceType == typeof(DbContextOptions<AppDbContext>));
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(opt =>
opt.UseInMemoryDatabase("TestDb"));
});
})
.CreateClient();
[Fact]
public async Task GetOrder_ExistingId_ReturnsOk()
{
var response = await _client.GetAsync("/orders/1");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var order = await response.Content.ReadFromJsonAsync<Order>();
order!.Id.Should().Be(1);
}
[Fact]
public async Task CreateOrder_ValidPayload_Returns201()
{
var payload = new CreateOrderRequest("CUST-1", 49.99m);
var response = await _client.PostAsJsonAsync("/orders", payload);
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
}
}
|
WebApplicationFactory spins up the real application with real routing, middleware, and DI, just with swapped-out infrastructure. This tests the full stack without a real database.
dotnet CLI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# New project
dotnet new webapi -n MyApi --use-minimal-apis
dotnet new classlib -n MyApi.Core
# Packages
dotnet add package Microsoft.EntityFrameworkCore.Npgsql
dotnet add package FluentValidation.AspNetCore
# Run
dotnet run --project src/MyApi
# Watch mode (rebuilds on file change)
dotnet watch run --project src/MyApi
# Publish self-contained native binary
dotnet publish -c Release -r linux-x64 --self-contained -p:PublishAot=true
# EF migrations
dotnet ef migrations add AddOrderStatus
dotnet ef database update
dotnet ef migrations bundle --output ./migrate
|
IDE Options
JetBrains Rider is the dominant choice for professional .NET development on macOS and Linux. ReSharper-level refactoring, excellent debugger, and the best EF Core tooling outside Windows.
Visual Studio 2022 remains the most powerful option on Windows—the profiler, diagnostic tools, and Live Unit Testing are unmatched.
VS Code with the C# Dev Kit extension is viable for cross-platform development if you prefer a lighter editor. The extension brings solution explorer, run/debug, and basic refactoring.
SDKMAN for .NET
1
2
3
4
5
6
7
8
|
# Install .NET versions via SDKMAN
sdk install dotnet 10.0.100
# Or use the official installer
curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- --version 10.0.100
# Or use mise
mise use dotnet@10
|
The Ecosystem
SignalR: Real-Time Communication
1
2
3
4
5
6
7
8
9
10
|
// Hub definition
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message) =>
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
// Register
builder.Services.AddSignalR();
app.MapHub<ChatHub>("/chat");
|
SignalR abstracts WebSockets with automatic fallback to Server-Sent Events and long polling. Used internally for Blazor Server.
.NET Aspire: Cloud-Native Orchestration
Aspire replaces docker-compose.yml and Kubernetes manifests with C# for local development and deployment:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.WithDataVolume()
.WithPgAdmin();
var redis = builder.AddRedis("cache");
var api = builder.AddProject<Projects.MyApi>("api")
.WithReference(postgres)
.WithReference(redis)
.WithReplicas(2);
builder.AddProject<Projects.MyWorker>("worker")
.WithReference(postgres)
.WithReference(api);
builder.Build().Run();
|
1
2
|
dotnet run --project AppHost
# Opens a dashboard showing all services, logs, traces, and metrics
|
Aspire wires up connection strings, OpenTelemetry, health checks, and the Aspire dashboard automatically. It’s closer to Docker Compose in feel but with type-safe configuration and deep integration with .NET observability.
Orleans: Distributed Actors
Orleans provides a virtual actor model for distributed systems. Each “grain” is an actor with a unique identity, persisted state, and location transparency across a cluster:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// Grain interface
public interface IUserGrain : IGrainWithStringKey
{
Task<UserState> GetStateAsync();
Task UpdateEmailAsync(string email);
}
// Grain implementation
public class UserGrain(
[PersistentState("user", "users")] IPersistentState<UserState> state)
: Grain, IUserGrain
{
public Task<UserState> GetStateAsync() =>
Task.FromResult(state.State);
public async Task UpdateEmailAsync(string email)
{
state.State = state.State with { Email = email };
await state.WriteStateAsync();
}
}
// Client usage
var user = client.GetGrain<IUserGrain>("user-123");
await user.UpdateEmailAsync("newemail@example.com");
|
Orleans handles cluster membership, grain placement, reactivation after failures, and distributed state without any infrastructure code. Used at Microsoft for Xbox Live and Skype at scale.
When to Choose .NET
Choose .NET when:
-
Azure is your cloud. The Azure SDK for .NET is a first-class integration. Azure Functions, App Service, AKS, Service Bus—all have deep .NET support. If your infrastructure is Azure-native, .NET is the path of least resistance.
-
You need the full-stack option. Blazor lets you write frontend components in C# that run either server-side (Blazor Server) or in the browser via WebAssembly (Blazor WASM). One language end-to-end.
-
Performance and startup time both matter. Native AOT delivers sub-50ms startup and ~50 MB memory, while JIT mode delivers peak throughput for long-running services. You pick per deployment.
-
Windows enterprise environment. Active Directory integration, Windows Authentication, COM interop for legacy systems—.NET handles these better than anything else.
-
Real-time features. SignalR is the easiest real-time solution in any backend ecosystem.
Choose Java when the team has deep Spring expertise or you’re integrating with the broader JVM/big data ecosystem (Spark, Kafka, etc.). Choose Go when building infrastructure tools or services where simplicity and deployment ease outweigh ecosystem richness. Choose Python when data science or ML workloads are central.
The honest framing: .NET is a strong general-purpose backend platform that’s often overlooked by developers outside the Microsoft ecosystem. If you have no existing investment either way, it’s worth evaluating on its merits. The performance, tooling, and language quality are genuinely competitive.
Quick Reference
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
// Records
record User(string Name, string Email);
var user = new User("Alice", "alice@example.com");
var updated = user with { Email = "new@example.com" };
// Primary constructors (C# 12)
class Service(IRepo repo, ILogger<Service> logger) { }
// Pattern matching
string Classify(object obj) => obj switch
{
int n when n > 0 => "positive",
string { Length: 0 } => "empty string",
null => "null",
_ => "other"
};
// Nullable reference types
string? maybeNull = GetValue();
string definitelyNotNull = maybeNull ?? throw new InvalidOperationException();
// Minimal API
app.MapGet("/items/{id}", async (int id, IItemService svc) =>
await svc.GetAsync(id) is Item item ? Results.Ok(item) : Results.NotFound());
// EF Core
var orders = await db.Orders
.AsNoTracking()
.Where(o => o.Status == Active)
.Select(o => new OrderDto(o.Id, o.Total))
.ToListAsync();
// async/await
public async Task<Result> ProcessAsync(Request req, CancellationToken ct)
{
var data = await _client.FetchAsync(req.Id, ct);
return await _processor.TransformAsync(data, ct);
}
|
.NET in 2026 is cross-platform, fast, and genuinely modern. The C# language has evolved substantially—records, pattern matching, nullable reference types, and primary constructors have eliminated most of the boilerplate complaints that were legitimate a decade ago. If you’re making a greenfield technology choice for a backend service and haven’t evaluated .NET recently, it deserves a serious look.
Comments