DEV Community

nanasi
nanasi

Posted on

I stopped trusting middleware for everything (almost)

Not because middleware is bad.

But because I was using it for things it was never meant to guarantee.


The pattern we all write

app.use(authMiddleware)

app.get("/me", (c) => {
  return c.json(c.get("user"))
})
Enter fullscreen mode Exit fullscreen mode

This works. It’s simple. It’s familiar.

But it relies on an assumption:

user will be there when I need it.”

And nothing actually enforces that.


Where things break

A few very normal mistakes:

  • You forget to apply middleware to a route
  • You register it in the wrong order
  • You refactor something and break the chain

Everything still compiles. The app still runs.

The failure shows up later — usually when it matters.


Middleware isn’t the problem

Frameworks like Hono and Elysia do middleware really well:

  • Hono keeps things minimal and close to Web standards
  • Elysia pushes type safety further than most frameworks

And middleware itself is great for:

  • logging
  • compression
  • request/response transformations

That’s exactly what it’s designed for.


The real issue

The problem is when we use middleware for something else:

data dependencies

When a handler depends on user, that dependency is implicit.

It’s not declared anywhere. It’s just assumed.


What I tried instead

Instead of replacing middleware, I separated concerns:

  • Middleware → handles flow
  • Relics → enforce what must exist

Defining a contract

const UserCtx = token<{ id: string }>("user")

const authRelic = relic(UserCtx, async (ctx) => {
  const user = await verify(ctx.req)
  if (!user) return err(Unauthorized)
  return user
})
Enter fullscreen mode Exit fullscreen mode

This says:

“I provide UserCtx, or I fail.”


Using it in routes

app.scope("/user", authRelic, (r) => {
  r.get("/me", (ctx) => {
    return ctx.json(ctx.relic(UserCtx))
  })
})
Enter fullscreen mode Exit fullscreen mode

Now the handler doesn’t assume anything.

If it runs, the dependency is already satisfied.


What changed

Before:

“this should exist”

Now:

“this must exist”

And if it doesn’t, the app fails at startup.


Comparing approaches (in good faith)

Concern Middleware (Hono / Elysia) Relics (Tomoe)
Flow control Excellent Not the goal
Cross-cutting concerns Excellent Not the goal
Data dependencies Implicit Explicit
Guarantees None Enforced
Failure timing Runtime Startup

They’re not competing tools — they solve different problems.


Why this felt better

Most of my bugs weren’t about routing or performance.

They were about assumptions.

Middleware made those assumptions easy to write.

Relics made them explicit.


Final thought

I didn’t replace middleware.

I stopped asking it to do something it was never designed to do.


I’m building this idea into a small framework called Tomoe.

Still early, but I’d love feedback:

👉 https://github.com/Project-Tomoe/tomoe

Top comments (0)