A practical guide for developers transitioning from Node.js, Flask, or other frameworks
After years of building web APIs in everything from Express to Flask to Rails, my first two weeks in .NET 8 felt like learning to drive on the other side of the road. The idioms are different—not better or worse, just a mental model shift. Here’s what I wish I’d known on day one.
Part 1: C# 12 Patterns You'll Actually Use
Records: Your New DTO Best Friend
In Node.js, you'd use plain objects or classes. In C# 12, records are value-based, immutable-ish types perfect for DTOs. They work seamlessly with JSON serialization and give you value equality out of the box.
// Before: Class boilerplate
public class UserDto
{
public string Id { get; set; }
public string Email { get; set; }
public string Name { get; set; }
// Have to implement equality, ToString, etc.
}
// After: Record in one line
public record UserDto(string Id, string Email, string Name);
// Usage - immutability by default
var user = new UserDto("123", "dev@example.com", "Sarah Chen");
// Non-destructive mutation creates new instance
var updated = user with { Name = "Sarah Miller" };
// Value equality works automatically
var user1 = new UserDto("123", "dev@example.com", "Sarah Chen");
var user2 = new UserDto("123", "dev@example.com", "Sarah Chen");
Console.WriteLine(user1 == user2); // true, not reference equality
// Complex records with collections
public record OrderDto(
string OrderId,
List<OrderItem> Items,
decimal Total,
DateTime CreatedAt
);
var order = new OrderDto(
"ORD-123",
new List<OrderItem> { new("Widget", 2) },
29.99m,
DateTime.UtcNow
);
Key mental shift: Think of records as "data containers" that happen to have behavior, not objects with complex state. They're particularly powerful for API request/response models and event messages.
Pattern Matching: The Expressive Switch
Forget messy if/else chains. Pattern matching in C# 12 is like destructuring on steroids.
public record Payment(decimal Amount, string Type); // "card" or "crypto"
public string ProcessPayment(Payment payment) => payment switch
{
{ Amount: < 0 } => throw new ArgumentException("Amount must be positive"),
{ Type: "card", Amount: < 10.00m } => "Small card payment - no signature needed",
{ Type: "card", Amount: > 1000.00m } => "Large card payment - manual review required",
{ Type: "crypto" } => "Crypto payment - pending blockchain confirmation",
{ Type: var t } when t.StartsWith("wire") => "Wire transfer - processing",
_ => "Unknown payment type" // Discard pattern, like default case
};
// With type patterns (discriminated union pattern)
public record PhysicalProduct(string Sku, decimal Weight);
public record DigitalProduct(string LicenseKey);
public string GetShippingInfo(object product) => product switch
{
PhysicalProduct p when p.Weight > 50 => "Freight shipping required",
PhysicalProduct => "Standard shipping",
DigitalProduct => "Instant download",
null => throw new ArgumentNullException(nameof(product)),
_ => throw new ArgumentException("Unknown product type")
};
// List patterns (C# 12)
public string AnalyzeOrderSize(int[] items) => items switch
{
[] => "Empty order",
[var first] => $"Single item: {first}",
[_, _, ..] => "Multiple items", // Matches 2+ items
[.., var last] => $"Order ends with: {last}" // Can slice and capture
};
When to use: API input validation, business rule engines, and anywhere you'd use a switch statement on complex data.
Nullable Reference Types: Your Compiler as Safety Net
This is .NET's answer to "undefined is not a function." Enable it in your .csproj:
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
// The compiler now tracks null state
public class UserService
{
// Non-nullable: must be initialized
private readonly ILogger<UserService> _logger;
// Nullable: marked with ?
private string? _cacheKey;
public UserService(ILogger<UserService> logger)
{
// Compiler enforces initialization of non-nullable fields
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public UserDto? GetUser(string id) // Can return null
{
if (string.IsNullOrEmpty(id))
{
_logger.LogWarning("Invalid user ID");
return null;
}
// Compiler warns: "Dereference of a possibly null reference"
// return _db.Users.Find(id).ToDto();
// Safe approach with null-conditional operator
var user = _db.Users.Find(id);
return user?.ToDto(); // Returns null if user is null
}
public async Task<string> GetUserNameAsync(string id)
{
var user = await GetUser(id);
// Null-forgiving operator (use sparingly!)
// When you KNOW it's not null but compiler doesn't
return user!.Name; // "Dammit, compiler, trust me"
}
}
// API endpoint with null-safety
[ApiController]
public class UsersController : ControllerBase
{
[HttpGet("{id}")]
public ActionResult<UserDto> Get(string id)
{
var user = _service.GetUser(id);
return user is null ? NotFound() : Ok(user);
}
}
Mental model: Think of it like TypeScript's strict null checks, but enforced at compile time and deeper in the type system.
Async/Await Idioms: The .NET Way
In Node.js, everything is async by default. In .NET, you opt in, but the patterns are rich and structured.
// Correct async disposal
public async Task ProcessLargeFileAsync(string path)
{
// using var = async disposal when done
await using var stream = new FileStream(path, FileMode.Open);
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
await ProcessLineAsync(line);
}
} // Both disposed here, stream asynchronously
// Parallel async operations (like Promise.all)
public async Task<List<UserDto>> GetMultipleUsersAsync(string[] ids)
{
var tasks = ids.Select(id => GetUserAsync(id));
// WhenAll for concurrency, not awaiting in loop
var users = await Task.WhenAll(tasks);
// Filter out nulls
return users.Where(u => u != null).ToList()!;
}
// Async streams (IAsyncEnumerable) for large datasets
public async IAsyncEnumerable<OrderDto> GetRecentOrdersAsync(
int batchSize = 100)
{
int page = 0;
bool hasMore;
do
{
var batch = await _db.Orders
.Skip(page * batchSize)
.Take(batchSize)
.ToListAsync();
foreach (var order in batch)
{
yield return order.ToDto();
}
hasMore = batch.Count == batchSize;
page++;
} while (hasMore);
}
// Consumer
await foreach (var order in GetRecentOrdersAsync())
{
Console.WriteLine($"Processing {order.OrderId}");
// Memory-efficient: only one batch in memory at a time
}
Critical pattern: Always use ConfigureAwait(false) in library code (not in UI apps):
await _db.SaveChangesAsync().ConfigureAwait(false);
This prevents deadlocks when called from sync contexts—think of it as .then() without capturing the entire thread context.
Part 2: Built-in Dependency Injection (No Framework Needed)
Node.js has npm install awilix or NestJS's decorator magic. .NET has it built into the runtime. The container is created in Program.cs and follows explicit registration patterns.
// Program.cs - The composition root
var builder = WebApplication.CreateBuilder(args);
// Service registration by lifetime
builder.Services
// Singleton: one instance for entire app lifetime (like a module export)
.AddSingleton<ICacheService, RedisCacheService>()
// Scoped: one instance per request (most common for business logic)
.AddScoped<IUserRepository, UserRepository>()
.AddScoped<IUserService, UserService>()
// Transient: new instance every time (lightweight, stateless)
.AddTransient<IEmailSender, SmtpEmailSender>()
// HttpClient with Polly policies built-in
.AddHttpClient<IPaymentGateway, StripeGateway>(client =>
{
client.BaseAddress = new Uri("https://api.stripe.com");
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddPolicyHandler(Policy.TimeoutAsync<HttpResponseMessage>(10));
var app = builder.Build();
Constructor Injection: The Only Pattern You Need
public class OrderService : IOrderService
{
// Dependencies as constructor parameters
private readonly IOrderRepository _orderRepo;
private readonly ILogger<OrderService> _logger;
private readonly IMapper _mapper;
// No [Inject] attributes or property injection
public OrderService(
IOrderRepository orderRepo,
ILogger<OrderService> logger,
IMapper mapper)
{
_orderRepo = orderRepo ?? throw new ArgumentNullException(nameof(orderRepo));
_logger = logger;
_mapper = mapper;
}
public async Task<OrderDto> CreateAsync(CreateOrderRequest request)
{
// All dependencies are guaranteed available
var order = _mapper.Map<Order>(request);
await _orderRepo.AddAsync(order);
_logger.LogInformation("Created order {OrderId}", order.Id);
return _mapper.Map<OrderDto>(order);
}
}
Scopes in Action: The "Request Scope" Mental Model
public class RequestScopeDemo
{
private readonly IServiceProvider _sp;
public RequestScopeDemo(IServiceProvider sp) => _sp = sp;
public void DemonstrateScopes()
{
// Root scope (app lifetime)
var rootScope = _sp.CreateScope();
// Simulate two concurrent requests
using (var requestScope1 = rootScope.ServiceProvider.CreateScope())
using (var requestScope2 = rootScope.ServiceProvider.CreateScope())
{
var db1 = requestScope1.ServiceProvider.GetRequiredService<AppDbContext>();
var db2 = requestScope2.ServiceProvider.GetRequiredService<AppDbContext>();
// db1 and db2 are DIFFERENT instances
// Each tracks its own entity changes
} // Both contexts disposed here
// DON'T do this: capturing scoped service in singleton
// builder.Services.AddSingleton(sp =>
// new BadService(sp.GetRequiredService<AppDbContext>())); // Context never disposed!
}
}
Part 3: Middleware Pipeline vs Express/Flask Decorators
Express has app.use(). Flask has @app.route(). .NET has a composable pipeline where order matters absolutely.
The Pipeline Mental Model
// Program.cs - Middleware is registered in execution order
var app = builder.Build();
// 1. Exception handling FIRST (wraps everything after)
app.UseExceptionHandler("/error");
// 2. Static files (short-circuit for images, CSS)
app.UseStaticFiles();
// 3. Authentication (who are you?)
app.UseAuthentication();
// 4. Authorization (what can you do?)
app.UseAuthorization();
// 5. Custom middleware (your logic)
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseMiddleware<TenantResolutionMiddleware>();
// 6. Endpoint routing (last!)
app.MapControllers();
app.MapHealthChecks("/health");
// Pipeline executes in this EXACT order for every request
Custom Middleware: Your "Express Middleware"
// Reusable middleware component
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
public RequestLoggingMiddleware(
RequestDelegate next,
ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
var requestId = Guid.NewGuid().ToString();
// Add correlation ID to response
context.Response.Headers["X-Request-ID"] = requestId;
_logger.LogInformation(
"Request started {Method} {Path} {RequestId}",
context.Request.Method,
context.Request.Path,
requestId);
// Call next middleware in pipeline
await _next(context);
stopwatch.Stop();
_logger.LogInformation(
"Request finished {StatusCode} {Duration}ms {RequestId}",
context.Response.StatusCode,
stopwatch.ElapsedMilliseconds,
requestId);
}
}
// Extension method for clean registration
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app)
{
return app.UseMiddleware<RequestLoggingMiddleware>();
}
}
// Program.cs
app.UseRequestLogging();
Endpoint Filters: The "Decorator" Equivalent
// Instead of Flask's @require_auth, use endpoint filters
public class ValidateModelFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
// Runs before endpoint
var userId = context.HttpContext.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userId))
{
return Results.Unauthorized();
}
// Add to route values for handler to access
context.Arguments[0] = userId;
return await next(context);
}
}
// Program.cs - Apply to specific endpoints
app.MapGet("/api/profile/{userId}", async (string userId, AppDbContext db) =>
{
return await db.Users.FindAsync(userId);
})
.AddEndpointFilter<ValidateModelFilter>()
.RequireAuthorization(); // Built-in auth filter
Part 4: Configuration - The Options Pattern
Stop using process.env. .NET's configuration system merges appsettings, environment variables, and secrets seamlessly.
The Hierarchy
// appsettings.json (base)
{
"Stripe": {
"SecretKey": "sk_test_default",
"WebhookSecret": "whsec_default"
},
"Cache": {
"Redis": {
"ConnectionString": "localhost:6379",
"Timeout": 30
}
}
}
// appsettings.Development.json (overrides base)
{
"Stripe": {
"SecretKey": "sk_test_dev_key"
}
}
// Environment variable (highest precedence)
// Format: Section__Key (double underscore)
// Stripe__SecretKey=sk_test_prod_key
Strongly-Typed Options (Do This)
// Define your configuration shape
public class StripeSettings
{
public const string SectionName = "Stripe";
[Required] // Enforced at startup
public string SecretKey { get; set; } = string.Empty;
public string WebhookSecret { get; set; } = string.Empty;
}
public class RedisSettings
{
public const string SectionName = "Cache:Redis";
public string ConnectionString { get; set; } = "localhost:6379";
public int Timeout { get; set; } = 30;
}
// Program.cs - Register options
builder.Services
.AddOptions<StripeSettings>()
.BindConfiguration(StripeSettings.SectionName)
.ValidateDataAnnotations() // Throws at startup if invalid
.ValidateOnStart(); // Validate immediately, not first use
builder.Services
.AddOptions<RedisSettings>()
.BindConfiguration(RedisSettings.SectionName);
// Inject IOptions<T> into services
public class PaymentService
{
private readonly StripeSettings _stripeSettings;
public PaymentService(IOptions<StripeSettings> options)
{
_stripeSettings = options.Value; // Access the POCO
}
public async Task ChargeAsync(decimal amount)
{
StripeConfiguration.ApiKey = _stripeSettings.SecretKey;
// ...
}
}
// API controller with options
[ApiController]
public class ConfigController : ControllerBase
{
private readonly RedisSettings _redisSettings;
public ConfigController(IOptions<RedisSettings> redisOptions)
{
_redisSettings = redisOptions.Value;
}
[HttpGet("config")]
public IActionResult GetConfig()
{
// Expose safe config values (never secrets!)
return Ok(new {
RedisTimeout = _redisSettings.Timeout,
RedisServer = _redisSettings.ConnectionString.Split(':')[0]
});
}
}
Secrets Management (No .env files!)
# Use .NET's secrets manager (dev only)
dotnet user-secrets init
dotnet user-secrets set "Stripe:SecretKey" "sk_test_dev_value"
# Stored in: ~/.microsoft/usersecrets/{project_id}/secrets.json
// Program.cs - automatically loads secrets in Development
if (builder.Environment.IsDevelopment())
{
builder.Configuration.AddUserSecrets<Program>();
}
// Azure Key Vault for production
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"),
new DefaultAzureCredential()
);
Part 5: Logging - Structured and Correlated
Forget console.log. .NET's ILogger is structured, performant, and IDE-integrated.
Basic Logging with Context
public class OrderProcessingService
{
private readonly ILogger<OrderProcessingService> _logger;
public OrderProcessingService(ILogger<OrderProcessingService> logger)
{
_logger = logger;
}
public async Task ProcessOrderAsync(OrderDto order)
{
// Structured logging with templates
_logger.LogInformation(
"Processing order {OrderId} for {CustomerEmail} with {ItemCount} items",
order.Id,
order.CustomerEmail,
order.Items.Count);
try
{
await _paymentGateway.ChargeAsync(order.Total);
}
catch (Exception ex)
{
// Log exception with context
_logger.LogError(
ex,
"Payment failed for order {OrderId}. Amount: {Amount}",
order.Id,
order.Total);
throw;
}
// Log levels
_logger.LogTrace("Low-level debugging info");
_logger.LogDebug("Internal state: {@Order}", order); // @ = destructuring
_logger.LogInformation("Business event happened");
_logger.LogWarning("Something unexpected but recoverable");
_logger.LogError("Failure in current operation");
_logger.LogCritical("Application-wide crisis");
}
}
Correlation IDs: Tracking Requests End-to-End
// Middleware to generate and propagate correlation ID
public class CorrelationIdMiddleware
{
private const string CorrelationIdHeader = "X-Correlation-ID";
private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext context)
{
// Get or generate correlation ID
var correlationId = context.Request.Headers[CorrelationIdHeader]
.FirstOrDefault() ?? Guid.NewGuid().ToString();
// Add to logging scope - EVERY log includes this
using (_logger.BeginScope("{CorrelationId}", correlationId))
{
context.Response.Headers[CorrelationIdHeader] = correlationId;
// You can also store in AsyncLocal for access anywhere
CorrelationContext.Set(correlationId);
await _next(context);
}
}
}
// Usage in service - correlation ID automatically included
public class UserService
{
public async Task<UserDto> GetUserAsync(string id)
{
// This log will include CorrelationId from middleware
_logger.LogInformation("Fetching user {UserId}", id);
var user = await _db.Users.FindAsync(id);
// Pass correlation ID to downstream service
_httpClient.DefaultRequestHeaders.Add(
"X-Correlation-ID",
CorrelationContext.Get());
var externalData = await _httpClient.GetAsync("api/permissions");
return user.ToDto();
}
}
// appsettings.json - Configure structured logging
{
"Logging": {
"Console": {
"FormatterName": "json",
"FormatterOptions": {
"SingleLine": true,
"IncludeScopes": true // Include correlation IDs
}
}
}
}
Semantic Logging with Event IDs
public static class LoggingEvents
{
public static readonly EventId OrderCreated = new(1001, "OrderCreated");
public static readonly EventId PaymentProcessed = new(1002, "PaymentProcessed");
public static readonly EventId ShippingFailed = new(4001, "ShippingFailed");
}
public class OrderService
{
public async Task<OrderDto> CreateAsync(CreateOrderRequest request)
{
_logger.LogInformation(
LoggingEvents.OrderCreated,
"New order created {OrderId} for {CustomerId}",
order.Id,
order.CustomerId);
// Now you can filter logs by event ID
// dotnet trace collect --providers Microsoft.Extensions.Logging.EventSource:1:5:0xF001
}
}
The Mental Model Shift: A Summary
| Concept | Node/Flask Mental Model | .NET 8 Mental Model |
|---|---|---|
| DI |
npm install awilix, manual binding |
Built-in, explicit lifetime management |
| Middleware |
app.use() in Express |
Strictly ordered pipeline, UseMiddleware<T>()
|
| Config |
process.env, .env files |
Hierarchical: JSON → Env Vars → Key Vault |
| Logging |
winston, console.log
|
Structured ILogger<T>, correlation built-in |
| DTOs | Plain objects | Records with value equality |
| Async | Everything is async | Opt-in, but rich patterns (IAsyncEnumerable) |
| Null Safety | Runtime checks | Compile-time tracking with ?
|
After two weeks, the patterns click. The .NET SDK feels less magical than NestJS and less verbose than Java Spring—and that's exactly the point. It's explicit, compiler-safe, and surprisingly elegant once you realign your mental model.
Next week: Want me to dive deeper into any of these topics?
Top comments (0)