GREEN TEA • FRAMEWORK DESIGN • AUGUST 2026
I Built a Framework on Express. Then I Had to Rebuild It From Scratch.
How req.user exposed the hidden order in Express middleware—and forced a rebuild around a dependency graph.
The whole thesis
req.user was the symptom. A middleware chain is a dependency graph with the edges deleted.
The req.user line that broke me
const user = req.user;
req.user looks harmless. It compiles, TypeScript is
happy, because three files away somebody wrote
declare global { namespace Express { interface Request { user?: User } } }
and made req.user real by decree.
It’s a prayer with a type annotation.
req.user exists if some middleware ran before this
handler. Which middleware? The one in app.use() on line 41
of server.ts. Does it run for this route? Depends
where the router got mounted. Before the body parser or after? Depends
on the line number. Did that analytics package you installed last sprint
call app.use() at import time and land somewhere in the
middle of your chain? It did. Sleep well.
We have a name for the activity of figuring this out. We call it
“reading the code.” What we’re doing is archaeology: digging down
through layers of app.use() to reconstruct what the request
looked like by the time it reached the thing that just returned a
500.
I did that for years without complaining much. Then I did it at 3am
with a pager going off, because req.user was
undefined on exactly one route out of sixty, the one where
somebody had mounted the router two lines above the auth middleware
instead of two lines below.
The fix was moving one line. That’s the part that got me. Not that it broke, but that the fix was a line number. Nothing was wrong with the code. Every function did what it said. The app was still wrong.
That’s when I stopped blaming myself and started blaming the model.
Your API is already a graph. You just wrote it down as a list.
The req.user failure wasn’t really an auth bug. It was
an ordering bug. Here’s the thing nobody says out loud: a middleware
chain is a dependency graph with the edges deleted.
Think about what you actually know when you write a pipeline:
- auth needs a database and the raw request, and produces a user
- billing needs a user, and produces a subscription
- the handler needs a subscription
That’s a graph. Those are facts about your app, and they stay true no
matter what order the file is in, no matter where you called
app.use().
And then we throw all of it away and write this:
app.use(db);
app.use(auth);
app.use(billing);
We flatten the graph into a line, drop the edges, and keep the one
thing that was never the point: the order we happened to type it in.
Then we spend the rest of the project defending that order in code
review. “Don’t move this.” “This has to come after that.” There are
comments like // IMPORTANT: must run before cors holding up
production apps right now, today, in codebases making real money. That
comment is infrastructure. We just don’t call it that.

The order isn’t information. The order is a consequence.
needs and provides are the inputs, ordering is
the output, and the algorithm has been sitting there since Kahn
published it in 1962. Topological sort. It’s about twelve lines.
So what if you wrote down the edges and let the machine do the sorting?
@Step({ provides: 'user', needs: ['db', 'req'] })
class Authenticate {
run(ctx) {
const user = ctx.db.find(ctx.req.headers['x-token']);
if (!user) throw new Unauthorized('invalid token');
return { user };
}
}
No app.use(). No line number. No “put this before that.”
You said what it needs and what it makes, and that’s the entire
contract. Three things fall out of it for free.
The app refuses to boot if nothing provides user. Not a
500 at 3am on the one route nobody tested. A crash at startup, on your
laptop, with the missing key printed by name. req.user
stops being a prayer, because there’s no universe where the app is
running and user is missing. That turns
req.user from a hidden convention into a checked
contract.

Each route runs only its own slice. Your auth step doesn’t execute on
/health, because that handler doesn’t need a user, so it
isn’t in that route’s subgraph. You didn’t configure that anywhere. It’s
just what the graph says.
And you can print the thing. This is the part I didn’t expect to like as much as I do:
console.log(app.explain('/api/users/:id'));

The ordered chain, every step, where each one came from. There’s also
app.graph(), a live diagram at GET /__graph__,
a Mermaid export, and an OpenAPI 3.1 spec, all projected out of the same
metadata. Once you’ve written the graph down you may as well render it
four ways. (NestJS has a graph too. It’s behind a paid Devtools plan.
I’ll leave that there.)
Onboarding stops being archaeology and turns into reading, which is all I ever wanted from it.
The bill: depth costs
Now the part it would be dishonest to skip, since I just spent 600 words selling you the good half.
A graph doesn’t delete work. It orders it. Every step is still a function that runs, and steps aren’t free: on my machine each one costs somewhere around 4,000 req/s, so a five-step chain lands about 22% under the zero-step number. Roughly linear, entirely unsurprising once you say it out loud.
The axis that hurts is depth, not size. Width is cheap. A graph with
forty steps where nothing needs anything is forty independent things,
and any given route only runs the handful it depends on. Depth is the
other story. If d needs c needs b
needs a, that’s a chain, and a chain gets walked in order
because you asked for it to be. Topological sorting finds the order. It
can’t invent parallelism your dependencies forbid.
Worth being clear, though: this isn’t a green-tea tax. Those same four steps cost you the same four steps in Express. You’d just be paying without an itemized bill. The work was always there; what changes is whether you can see it.
And that’s the actual mitigation, not a disclaimer wearing a
mitigation’s clothes. explain() shows you a route’s depth
before it turns into a latency chart you’re squinting at in Grafana.
Per-route slicing means a deep subgraph only bills the routes that
genuinely need it, instead of every request paying for the deepest path
in the app. And when a chain does get too long, “too long” becomes
something you can point at and refactor, instead of a vague feeling you
have about server.ts.
If you build a thirty-step chain where every step needs the one
before it, you’ve built a thirty-step chain, and no framework is going
to save you from that. What green-tea gives you is knowing on day one,
from a console.log, rather than from a postmortem.
“So you wrote another framework.” Yeah. Let me defend that.
I know. There’s a special ring of hell for people who publish JavaScript frameworks in 2026 and I have a reserved seat. I sat on this idea for about a year before writing a line of it, mostly because I knew exactly how it sounds.
My defense isn’t “the existing ones are bad.” I’ve shipped production on all three and I’d do it again tomorrow.
Express made HTTP in Node feel like fifteen lines,
which is why it won and why it’ll outlive all of us. The price of that
simplicity is req: a bag anything can write to, any time,
from anywhere. That’s not a bug nobody got around to fixing. That
is the contract. Which is why Express 5 spent years in the oven
and still couldn’t fix req.user. Nothing can, without
changing what req is.
Fastify is excellent engineering and I don’t say that to be polite. Encapsulated plugins were the right instinct; scoping a plugin’s blast radius beat “everyone shares one chain” by a mile. But ordering is still hook phase plus registration order, and schemas still validate at runtime. You’re maintaining the order by hand. You just have much better tools for doing it.
NestJS gave a whole generation of Node teams structure, and I don’t think that’s a small thing at all. The tradeoff is that its DI resolves tokens at runtime, so a missing provider is a startup error if you’re lucky and a mystery if you’re not, and every new capability shows up as its own subsystem: a WebSocket Gateway with its own adapter, a Microservices transport with its own message patterns. You end up learning “how Nest does this” three or four separate times.
None of that is a failure of taste or effort. They’re all downstream of one decision that was made before any of us were around to argue about it: the pipeline is a sequence, and a sequence can’t tell you what it depends on.
You also can’t patch that from the outside. I know because I tried.
Years ago I wrote expressive-tea: decorators, DI through InversifyJS, boot stages, all sitting on top of Express. The sane move. Don’t rewrite the world, add some structure to the world that already exists. And it worked. People used it. I used it, on real projects, happily.
I later wrote a full
breakdown of where Expressive Tea 2.0 landed. The release got
better; the req.user problem and its underlying sequencing
model did not change.
But when you build on someone else’s chain you inherit their model, all of it. I could put decorators over the middleware chain. I could not make the middleware chain stop being a chain. I could inject dependencies at runtime. I could not make a missing dependency fail at boot, because the thing underneath was still perfectly willing to hand you a mutable bag and wish you luck. Every good idea I had ended in the same sentence: “…but Express won’t let me.”
And then the floor moved. Then it kept moving.
ESM happened. Deno happened, then Bun, then edge runtimes where
there’s no listen(), no filesystem, and your whole mental
model of “a server” is quietly wrong. Web-standard
Request/Response turned into the portable
interface while nobody was announcing it. Standard Schema showed up and
made “bring your own validator” a real option instead of a configuration
nightmare. TC39 decorators reached Stage 3 and, in a twist I’ll come
back to, left parameter decorators out entirely.
You can’t plugin your way out of that. A plugin is a guest in someone else’s house. You can move the furniture, you can’t move a load-bearing wall. Eventually the honest answer was that the abstraction I wanted lived below the one I was standing on, and no amount of clever decorating from up here reaches down there.
Si te mueven el piso, tienes que corretear a la liebre. If they move the ground under you, go chase the hare. Don’t stand there decorating the spot where the floor used to be.
The req.user problem was the smallest visible crack in
that foundation. So green-tea keeps the ideas from expressive-tea and
drops the foundation. No Express, no Inversify. The graph is the core,
not a coat of paint over a chain. One runtime dependency,
reflect-metadata, plus two optional peers you install only
if you use them (ws and busboy). And nothing
underneath assumes Node anymore, which turned out to matter more than I
expected. More on that in a minute.
That’s the whole justification. Not “the others are bad.” Just: the thing I wanted was one floor down, and you can’t get there from up here.
Real-time, without learning a second framework
Quick tangent, because this is where the model paid off in a way I hadn’t planned for.
Most stacks treat “push data over time” as a bolt-on. Express: go
find a ws library. Fastify: a plugin. NestJS: an entire
WebSocket Gateway with its own adapter, its own decorators, its own
lifecycle. A second mental model glued to your first one, with a second
error surface to go with it.
green-tea has one primitive, an AsyncIterable. A
function that produces values over time already is a stream.
All you declare is how it gets framed:
@Route('/live')
class Live {
@Sse('/prices') // one iterable out — each yield is an event
prices() {
return (async function* () {
while (true) { yield { btc: await getPrice() }; await sleep(1000); }
})();
}
@Ws('/echo') // duplex: consume @inbound, return the outbound stream
echo(@inbound() incoming: AsyncIterable<string>) {
const out = channel<string>();
(async () => { for await (const m of incoming) out.push(`echo: ${m}`); out.close(); })();
return out;
}
}
Same @Route, same handler shape. @Sse
frames it as text/event-stream. @Ws gives you
a duplex pair. @Stream negotiates off the client’s
Accept/Upgrade headers, so one handler serves
SSE or ndjson or WebSocket without a single branch in your code.
Backpressure, cleanup and disconnects are handled for you.
The transport is also whatever you declared, never whatever
you happened to return. A buffered route that returns an iterable throws
TransportMismatchError instead of quietly turning into a
stream. Refactoring the inside of a handler can’t change how it talks to
the wire. I’m still a bit proud of that one.
The same app, on Node, Deno and Bun
This is the other half of chasing the hare, and it only worked because I got off Express.
Nothing in the core assumes Node. The request model is web-standard
Request/Response, so an app is really a graph
plus a fetch handler, and the runtime becomes a detail you
pick on the last line of the file:
// Node
app.listen(3000);
// Deno
import { serveDeno } from '@green-tea/core/deno';
serveDeno(app);
// Bun
import { serveBun } from '@green-tea/core/bun';
serveBun(app);
// Cloudflare Workers
import { edgeHandler } from '@green-tea/core/edge';
export default { fetch: edgeHandler(app) };

That’s the diff. Not a port, not a fork, not an #ifdef.
Same modules, same steps, same controllers, one import swapped.
matcha new scaffolds you into any of them and
matcha run works out which one you’re on.
It’s also not the usual “runs on Deno” asterisk, where HTTP works and
everything interesting quietly doesn’t. HTTP, SSE and WebSocket, rooms
and channels included, behave the same on all four, because WebSocket
support lives in a runtime-neutral core with thin adapters rather than a
Node-shaped abstraction wearing a Deno hat. The suite runs separately on
each one (test:deno, test:bun,
test:edge) specifically so “the same” stays a fact instead
of a hope.
Why I care about this beyond the demo value: the graph model isn’t a
bet on one runtime’s future. JavaScript’s floor has moved three times in
five years and it isn’t finished moving. If Bun wins, fine. If Deno
wins, fine. If it all ends up at the edge, mostly fine, and I mean
mostly, so here’s the fine print rather than letting you find
it yourself: Workers have no listen() and no filesystem, so
file-mode @Html and static serving are out, mesh doesn’t
run there at all, and you’ll need the nodejs_compat flag.
Node 18+, Deno and Bun run everything.
I’d rather tell you which door is locked than let you discover it during a deploy.
What it doesn’t have
Better I say this than someone in the comments.
It’s beta, heading for an RC. Express has a decade of ecosystem behind it and NestJS has enterprise tooling plus a plugin catalog you could get lost in for a week. Pick green-tea for the model and the ergonomics, not for the ecosystem. Not yet.
You bring your own auth. It ships transport security (TLS and wss, secure-by-default headers, CORS, size caps, path-traversal guards) but no authentication, authorization, rate limiting, CSRF or sessions. You compose those as steps, which is sort of the point, but there’s nothing off the shelf the way there is on Express. If that’s a blocker for you today, it’s a blocker, and I’d rather you know now.
mesh is alpha and I mean alpha. Distributed DI does work, and it’s
the piece I had the most fun building, which is exactly why I don’t
trust myself about it: @needs('billing') resolves the same
whether billing is in this process or on another node, no
gRPC layer, no message-pattern DSL to learn. But discovery, load
balancing and failover aren’t built, and the wire protocol may still
change. It’s gated behind experimental: true and
createApp throws if you forget the flag. Don’t put it in
production.
Legacy decorators, so you’ll be setting
experimentalDecorators: true. This one is a decision rather
than inertia, and I still get asked about it weekly. The whole
argument-injection API (@param, @query,
@body, @needs, @inbound) runs on
parameter decorators, and the TC39 Stage 3 proposal deliberately leaves
parameter decorators out. There is no standards-track way to write
handler(@param('id') id: string) today. Stage 3 also means
not finalized. If a viable standard path shows up, I’ll take it.
Route matching is a linear scan. Fine for normal route tables, and a radix tree is post-beta work.
Update, August 11: the codebase started answering back
Two people outside the project have already pushed on edges I was too close to see.
@hgshreyas added a concurrent connection ceiling. The change is merged into the public contribution branch and will travel through the project’s promotion flow into the next beta. The useful part was bigger than the option itself: testing the cap made it obvious that Node destroys excess sockets without an HTTP response, so the PR also produced a follow-up design issue.
Yann
Ariel (@YxnnXriel) is working on a timeout for
app.close(). That PR is still open with changes
requested. It already exposed a different gap: the Node shutdown path
owns a server, while the Deno and Bun adapters return their native
servers elsewhere. Accepting one option while silently ignoring it on
two runtimes would violate the portability claim, so the review opened
the
runtime-wide API question instead of hiding it.
That is the kind of contribution I want: not free labor, but another set of eyes finding where the model stops being honest.
Try it
npm install @green-tea/core@beta reflect-metadata
The only thing I’d actually ask you to do: wire up two steps, then run
console.log(app.explain('/your/route'));
If seeing your own request printed out as an ordered chain, with origins, with nothing hidden, doesn’t do anything for you, then this isn’t your framework and that’s a completely fine outcome. Express will be here forever and it’s a good tool.
But if you’ve ever run grep -rn "app.use" src/ at 3am
trying to work out why req.user was undefined,
I wrote this one for you.
- Docs: green-tea.expressive-tea.io/docs
- CLI: matcha.
matcha newscaffolds Node, Deno or Bun. Standalone Rust binary, no JS runtime needed to install it. - Benchmarks, with every caveat spelled out and
npm run benchif you’d rather redo them yourself:BENCHMARKS.md
Less to hold in your head. That’s the tea. 🍵



Leave a Comment