pingedbrain
ES

3 min read Leer en español

.NET 10 for microservices: Why it beats TypeScript and Go in production

After migrating korz.sh's RBAC API from TypeScript to .NET 10, here's what I learned about why the framework makes sense for serious backend work.

A year ago if you asked me what to use for microservices, I’d say TypeScript or Go without thinking. They’re everywhere, they work, and I knew them well.

My real story starts with Go. I used it for korz.sh at the beginning and it worked well for small things. But when the service started scaling — more endpoints, more business logic, more edge cases — I found myself creating my own libs for things any serious framework already has solved. After two weeks I was re-inventing the wheel with something that in .NET comes out of the box.

Then I migrated to TypeScript. Faster to iterate, more libraries available, the frontend team already knew it. TypeScript is fine for prototyping, but when you start needing serious things — validation that’s not a mess, auth that’s not handcrafted, observability that works — you end up gluing many packages and configuring many things manually.

When performance mattered, I migrated to .NET 10.


Validation that actually works

Zod is fine. But the problem isn’t Zod — the problem is that endpoint validation in TypeScript grows messily. You need the schema for type safety, then you need a runtime validator that matches the schema, then the schema gets out of sync with your database model and nobody knows until something breaks in production.

In Go there’s no stdlib for this. You use go-playground/validator or similar, but the DX is verbose.

FluentValidation in .NET changes that:

public class CreateApiKeyValidator : AbstractValidator<CreateApiKeyRequest>
{
    public CreateApiKeyValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Name is required")
            .MaximumLength(100).WithMessage("Name must be 100 characters or less");

        RuleFor(x => x.Scopes)
            .NotNull().WithMessage("Scopes is required");

        RuleFor(x => x.RateLimitRpm)
            .InclusiveBetween(1, 10000)
            .When(x => x.RateLimitRpm.HasValue);
    }
}

Register it in one line and ASP.NET Core injects it into the pipeline automatically:

builder.Services.AddValidatorsFromAssemblyContaining<CreateRoleRequestValidator>();

Auth you don’t have to build from scratch

.NET 10 has JWT Bearer configured out of the box:

services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
        Environment.GetEnvironmentVariable("JWT_SECRET") ?? "default-secret"));

    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = Environment.GetEnvironmentVariable("JWT_ISSUER"),
        ValidAudience = Environment.GetEnvironmentVariable("JWT_AUDIENCE"),
        IssuerSigningKey = key,
        ClockSkew = TimeSpan.FromMinutes(5)
    };
});

Issuer, audience, lifetime — everything validated by default.

Observability with Serilog

Serilog is configured once and it stays:

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .Enrich.FromLogContext()
    .Enrich.WithProperty("Application", "rbac-api")
    .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
    .CreateLogger();

Request logging with enriched context:

app.UseSerilogRequestLogging(options =>
{
    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {
        diagnosticContext.Set("RequestId", httpContext.TraceIdentifier);
        diagnosticContext.Set("TenantId", httpContext.User.FindFirst("tenant_id")?.Value);
    };
});

OpenTelemetry is one line:

builder.AddServiceDefaults(); // Health checks, metrics, tracing — all bundled

Native AOT: cold start that makes you reconsider serverless

.NET 10 brought Native AOT: compile ahead-of-time, no JIT, no runtime compilation overhead. Cold start goes from ~500ms to ~50ms.

<PropertyGroup>
  <PublishAot>true</PublishAot>
  <OptimizationPreference>Speed</OptimizationPreference>
</PropertyGroup>

Wrapping up

I’m not saying .NET 10 is better than everything for everyone. TypeScript has a huge npm ecosystem. Go wins in system tools and CLIs.

But when you have a microservice with business logic that needs clear validation, correct auth, working observability, and tests that actually prove against reality — .NET 10 has a coherence that TypeScript and Go don’t give you without fighting multiple libraries.

After building the RBAC API for korz.sh, what surprised me most was how little I had to Google. Validation? The framework has it. Auth? The framework has it. Logging and tracing? The framework has it.

That’s what .NET 10 does well.


korz.sh — pingedbrain/korz