The dotnet service that finally moved behind the same gateway

The pricing service had been written in .NET Core in 2018 because the person who built it knew it well and because the calculation was genuinely CPU-bound. Three years later it was the only thing on a separate hostname, with a separate authentication scheme, a separate log format and a deploy process nobody else had ever run.

The symptom

# everything else
https://api.example/v1/orders        Bearer token, JSON logs

# and this
https://pricing.example/calculate    an API key in a query
                                     string, plain-text logs

# the consequences, in a single incident:
#   a slow request in the API could not be traced into it
#   its logs were on a different host, in a different format
#   the API key had been in a config file since 2018
#   only one person had ever deployed it

None of those is about C# and all of them made the service harder to operate than the language could account for. The bus factor was the reason it finally got attention.

Why it happens

A service in a second language is built by whoever proposed it, to the conventions they know, at a moment when the conventions of the main application are not written down anywhere. Every subsequent difference is inherited rather than chosen.

The fix

The gateway is where the differences stop

location /v1/pricing/ {
    # one hostname, one TLS termination, one access log format
    proxy_pass http://pricing-service:8080/;

    proxy_set_header X-Request-Id      $request_id;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header Authorization     $http_authorization;

    proxy_connect_timeout 2s;
    proxy_read_timeout    8s;
}

Moving it behind the same hostname removes the CORS configuration, the separate certificate, the separate DNS record and the second set of firewall rules — four pieces of infrastructure that existed only because of a path decision made in an afternoon in 2018.

Forwarding the Authorization header rather than translating it is what lets the service validate the same token the API validates, which removes the API key entirely. That was the largest single change and it is a change to the service rather than to the gateway.

One token, validated in two runtimes

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = configuration["Auth:Issuer"];
        options.Audience  = "api.example";
        options.TokenValidationParameters = new()
        {
            ValidateIssuer = true, ValidateAudience = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30),
        };
    });

Both runtimes fetching the signing keys from the same discovery endpoint means the rotation happens in one place and both follow it, which is the property that makes a shared token scheme worth the migration. The clock skew allowance is small and deliberate — the default of five minutes is generous enough to matter for a short-lived token.

The service validates the token itself rather than trusting the gateway, which is more work and is the only arrangement that survives somebody reaching the service directly. A gateway-only check is a network-topology assumption written as a security control.

Logs that can be read together

// the same JSON shape, the same field names
Log.Logger = new LoggerConfiguration()
    .Enrich.FromLogContext()
    .Enrich.WithProperty("service", "pricing")
    .Enrich.WithProperty("version", ThisAssembly.InformationalVersion)
    .WriteTo.Console(new CompactJsonFormatter())
    .CreateLogger();

// and the middleware that puts the incoming id in scope
app.Use(async (context, next) =>
{
    var id = context.Request.Headers["X-Request-Id"].FirstOrDefault()
             ?? Guid.NewGuid().ToString();

    using (LogContext.PushProperty("trace_id", id)) { await next(); }
});

Agreeing the field names across two runtimes is a half-hour conversation and is the thing that makes a single query across both possible. The names have to be identical rather than similar — trace_id and traceId are two fields in any log store.

# the query that was impossible before
$ jq -r 'select(.trace_id=="9c1f4a7e-3b2d") |
         "(.timestamp) (.service)t(.message)"' *.log | sort
09:41:02.104  api      order.create.started
09:41:02.118  api      pricing.request.sent
09:41:02.121  pricing  calculate.started
09:41:04.882  pricing  calculate.completed   duration_ms=2761
09:41:04.889  api      pricing.request.completed

The same health check, and the same deploy

services.AddHealthChecks()
    .AddNpgSql(connectionString, name: "database")
    .AddRedis(redisConnection, name: "cache");

app.UseHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = WriteJsonResponse,   // the agreed shape
});

// { "status": "ok",
//   "checks": { "database": { "status": "ok", "ms": 2 } },
//   "version": "2021.4.2", "commit": "a41f2b8" }

The agreed response shape means one dashboard, one alert rule and one runbook step rather than two of each. The version and commit fields are what answer “which build is running” during an incident without shell access, and they are worth more than the status field on a service somebody else deployed.

The deploy moved into the same pipeline with the same stages, which is where the bus factor actually got fixed — not by anybody learning C#, but by the deploy being a job in a workflow file that four people can read.

What stayed different, legitimately

made the same:
  hostname, TLS, auth, log format, correlation id,
  health endpoint, metrics names, deploy pipeline,
  image base, secret handling

left different, deliberately:
  the language and its idioms
  the test framework
  the dependency manager
  the code review conventions

the rule: everything OPERATIONAL is shared. everything
internal is the team's own business.

Drawing the line at the operational surface is what makes a polyglot arrangement survivable, and trying to unify further is where it becomes an argument. Nobody needs to agree on how to write a test in order to read a log line.

Verifying it worked

$ curl -sH "Authorization: Bearer $TOKEN" 
    https://api.example/v1/pricing/calculate -d @body.json | jq .total
4900

$ curl -s https://api.example/v1/pricing/calculate
{"type":"...","title":"Unauthenticated","status":401}

$ grep -rn 'PRICING_API_KEY' . ; echo $?
1

# and the one that mattered: a deploy run by somebody
# who has never opened a .cs file
$ gh workflow run deploy.yml -f service=pricing -f ref=v2021.4.2

The API key not existing anywhere in the codebase is the security outcome and is easy to check. The deploy being runnable by anybody on the team is the organisational outcome and is the one that motivated the work.

The end-to-end trace across the two services is the operational outcome, and testing it means making a request and finding both halves — which is a manual check the first time and belongs in the smoke suite afterwards.

What this costs

The gateway is now on the critical path for a service that previously had its own. That is a real reduction in independence: a gateway misconfiguration takes down both, where previously it took down one. The trade is worth it here because the gateway was already on the critical path for everything else, and running two of them was not buying redundancy — it was buying two things to misconfigure.

The second language remains a genuine cost that none of this removes. On-call still needs somebody who can read it at three in the morning, dependency updates need somebody who knows the ecosystem, and a security advisory needs somebody to assess it. Keeping such a service small and boring is the only real mitigation, and this one is four hundred lines that calculate a price — which is why it survived the review that ended the .NET work elsewhere.