Day 18 of 100·Project Deep-Dive Day·537 words

Middleware chains — Chi vs Express, with your real orderings

The exact middleware stacks of both backends, why each element sits where it sits, and the traps you avoided.

Know your own system

Middleware is where cross-cutting concerns live — and where ordering bugs live too. Own both stacks line by line.

Zoth (Go, Chi) — every binary's router

r := chi.NewRouter()
r.Use(middleware.Recovery)              // 1. panics → 500, never drop conn
if otelEnabled { r.Use(otelhttp.NewHandler(next, "http.server")) } // 2. span per request
r.Use(middleware.Logging)               // 3. structured slog + OTel bridge
r.Use(middleware.CORS)                  // 4. allowlist origins, no credentials
r.Use(middleware.Auth)                  // 5. JWT/API-key → context
r.Use(middleware.Tenant)                // 6. assert tenant present
mount subrouters; policy/integration mutations add middleware.RequireRole(RoleAdmin)

Why this order: Recovery outermost so everything below it is spanned and logged even while failing; auth before tenant because the tenant ID comes from the verified token; role gates on sub-routers (one Use per route group) rather than repeated per handler — centralize authorization by route group, the same principle as traverse's route-level authenticateAdmin.

Details that show depth:

  • The in-memory token-bucket rate limiter exists (middleware/ratelimit, per-tenant buckets, 10-minute cleanup loop) and is unit-tested but not mounted in the router — a gap you should state unprompted ("wired but not enabled; enabling is one r.Use away — the honest reason is it wasn't needed at demo scale and untested against real traffic patterns").
  • Role resolution per request: authenticateJWT hits userRepo.GetByID on every request — deliberately rejecting tokens of deleted users immediately. Cost: one indexed PK lookup per request (~sub-ms); benefit: no stale-role window. That's a consistency/performance tradeoff you made consciously.

Traverse (Express) — the mount order in src/index.ts

app.set('trust proxy', 1)          // unwrap exactly ONE proxy hop
app.disable('x-powered-by')
app.use(cookieParser())            // parse auth_token cookie
app.use(requestContext)            // OTel span + req.log + requestId
app.use('/api/recon', reconRoutes) // BEFORE json parser — stream to disk
app.use(express.json({ limit:'10mb', verify: rawBody }))
app.use('/api/', generalLimiter)   // 100 req/min
app.use('/api/auth/login|register|change-password|password-reset', strictLimiter) // 5/15min
GET /health, /api/health, /api/version, /api/health/db  (before auth)
...route mounts...
404 handler → { error: 'Route not found' }
global error handler → 500 { error: 'Internal server error' }

The three load-bearing orderings (interview gold, all commented in the file):

  1. /api/recon before express.json — capture bundles are multi-MB and must stream to disk raw; the 10 MB JSON limit would 413 them before the route ran.
  2. verify: rawBody — Razorpay HMAC needs exact bytes (Day 17).
  3. requestContext before everything else — so every log line downstream carries requestId + trace context; correlation is free.

Also: health routes sit outside the rate-limited /api/ mount (uptime checks must never be throttled), and the global error handler is deliberately dumb — every intentional error was already converted to a safe response in sendError; anything that reaches the global handler is by definition unexpected, so it gets a flat 500 and a server-side log. Zero information leakage.

The comparison line

"Chi middleware = function decorators around an http.Handler; Express middleware = an onion of (req,res,next) callbacks. Same mental model, different type systems. What matters is neither framework — it's the ordering decisions: recovery outermost, context before logging, auth before authorization, parsers after streaming routes, and one global error funnel with a strict allow-list of what may reach the client."

Bulletproof your resume

Say these answers aloud before moving on

These are the follow-ups an interviewer will actually ask about your projects.

01

Why resolve the user's role from the database on every request instead of trusting the JWT claim?

The claim is a snapshot from issuance time (1-hour expiry); a demotion or deletion wouldn't take effect until expiry — in an IAM platform that's a real privilege-persistence window. One indexed PK read per request is sub-millisecond. The tradeoff I knowingly accepted: the API does one extra DB round-trip per request, which matters exactly never at human-paced QPS. If it did, the fix is a short-TTL role cache with invalidation on role change — and I'd measure before adding it.

02

Your rate limiter is in-memory in both projects. What breaks at 2 replicas?

Limits become per-process: 100/min becomes 200/min effective, and a user pinned to different pods gets inconsistent buckets. The fix is a shared store — Redis token bucket or sliding window (zoth's limiter is already per-tenant keyed, so swapping the bucket map for Redis is localized). Not done yet because both deployments are single-process — it's the first thing I'd change before horizontally scaling either.

03

What does requestContext give you that plain console.log wouldn't?

It opens an OTel span per request and attaches req.log and req.requestId, so every subsequent log carries the same trace context — and logs exported through the OTel log pipeline correlate with traces automatically in Better Stack. That correlation is literally the 'alert-to-trace' claim on my Popin resume: an alert on a failing endpoint links straight to the exact trace and its log lines, which is what collapsed MTTD.