Three microservices, two incompatible auth models, one public endpoint, and a Go gateway that turned into a pile of strings.HasPrefix conditionals. Here's the rewrite, in Go + TypeScript, and why it ended up sandboxed across two runtimes.
I've been trying to keep my publishing schedule to one article a week, but this one felt too special to wait.
The article today might be a little boring depending on how much you're into systems and distributed architecture. And if you're already thinking, "Great, another API gateway," I can't really blame you.
But bear with me for a bit: Conduit isn't just another request-forwarding proxy. Along the way, we'll dive into programmable gateways, sandboxed runtimes, Unix sockets, and some of the engineering decisions that made building it surprisingly fun.
TL;DR
- Built a small Go API gateway in front of three services with two different auth schemes (custom HMAC sessions, and JWT).
- It worked. It also became unmaintainable: every routing exception meant editing Go and redeploying.
- Rewrote it as Conduit: Go stays on the network path (routing, proxying, CORS, SSRF guards). A sandboxed Deno runtime runs policy as plain TypeScript plugins, talking to Go over a Unix socket with a strict snapshot-and-patch protocol.
- Net result: auth, logging, and routing rules became files I could edit and save, no recompiling the gateway.
- Full FAQ/defense-of-design-decisions Q&A is here, not in this post.
The setup
Three services, one public API:
- chat-service: real-time messaging
- user-service: profiles, sessions, identity
- admin-service: internal tooling, elevated operations
User and chat traffic ran a custom session + HMAC scheme: Authorization: Session <id>, signed with X-Signature, X-Timestamp, X-Nonce, backed by Redis for sessions and replay protection. Not JWT. Not OAuth. Something built and iterated on for months.
Admin traffic was JWT, verified at the edge, then forwarded with trusted X-Gateway-Admin-* headers so the admin service skipped re-validation.
Different trust models. Different header shapes. Different public-path exceptions. All of it had to land on one hostname, because clients don't care how many services you run behind it.
So I built the obvious thing: a small Go gateway. Service-prefixed paths picked the upstream. A route.Select() function picked the auth mode. A growing stack of prefix conditionals decided which paths were public:
/admin-service/* → admin upstream + JWT (except login, refresh, external-api)
/user-service/* → user upstream + HMAC (except auth, device, internal, …)
/chat-service/* → chat upstream + HMAC for users, JWT for /api/v1/admin/*
It shipped. It worked. It was ugly.
Where the ugliness actually lived
Not in reliability. Requests hit the right upstream, JWT/HMAC validation worked, sessions slid TTL correctly. The ugliness was in extensibility:
res := route.Select(r.URL.Path)
proxy := proxies[res.Backend]
h, ok := applyAuth(w, res.Auth, cfg, sessionStore, nonceStore, proxy)
h.ServeHTTP(w, r)
Clean on paper. But adminAuth(), userAuth(), and chatAuth() were each a wall of strings.HasPrefix branches, each one carrying tribal knowledge about which paths were exceptions. Every new public-path exception meant editing Go and redeploying the gateway. Figuring out why a path behaved a certain way meant reading three functions and cross-referencing a README the size of a small service.
I wasn't failing at microservices. I was failing at boundary discipline: policy had nowhere clean to live except deeper inside the gateway's Go source.
Every new exception made the gateway more specific instead of more general.
That gateway didn't get thrown away. It proved the routing model was right. It just proved the implementation model needed to change. That became Conduit.
Design goals (the actual constraints, not aspirations)
1. Freedom of implementation. If you can write TypeScript, you can extend the gateway. No DSL, no Lua config, no plugin marketplace format: drop a file in ./plugins, export an object with lifecycle hooks:
import type { GatewayContext } from "../runtime/shared/types.ts";
export default {
beforeRequest(ctx: GatewayContext): void {
if (!ctx.request?.headers?.Authorization) {
ctx.reject!(401, "missing token");
}
},
};
2. Low operational overhead. No control plane, no Kubernetes requirement, no sidecar ceremony. Default setup is one Go binary supervising a Deno runtime, reading a JSON config:
go run ./cmd/conduit -config conduit.config.json
3. Explicit separation of concerns.
| Layer | Responsibility |
|---|---|
| Go gateway | HTTP ingress, routing, upstream proxying, CORS, body limits, timeouts, SSRF guards |
| Deno runtime | Sandboxed plugin execution in a warm worker pool |
| Plugins | Business policy: auth, logging, transforms, route control |
Go never gets to know about plugin internals. The runtime never owns routing. Plugins never touch the network boundary directly. These invariants are written down because violating them produces cross-language bugs that tests don't reliably catch.
4. Predictable failure modes. A misbehaving plugin shouldn't take down the gateway. Hook timeouts log and continue. Worker crashes replace the isolate, not the process. If the entire runtime is unreachable, failPolicy decides: closed (503, no proxy) or open (skip plugins, proxy anyway), configurable per route.
5. Invisible in production. The bar I actually cared about: deploy a plugin when policy changes, glance at structured logs when something's off, otherwise forget the thing exists.
Architecture: two processes, one socket
Per request:
- Client hits the Go gateway.
- CORS preflight (
OPTIONS) is answered in Go; plugins never see it. - Go builds a serializable context snapshot from the request.
- Every plugin's
beforeRequesthook runs, in filename order. - Go proxies once to the matched (or plugin-selected) upstream.
- The upstream response gets attached to context.
- Every plugin's
afterResponsehook runs, same order. - Go writes the response to the client.
Upstream services have zero awareness Conduit exists: no SDK, no self-registration. Multi-service routing is config, not code:
{"routes":[{"path":"/user-service/*","upstream":"http://user-service:2001"},{"path":"/admin-service/*","upstream":"http://admin-service:2002"},{"path":"/chat-service/*","upstream":"http://chat-service:2003"}]}
One plugin chain, many backends, policy applied uniformly at the edge.
The actual hard part: getting context across the process boundary
The real problem with the first gateway wasn't routing logic. It was where state lived. Mutable request objects passed through middleware created hidden coupling, and that gets worse the moment you're crossing a process boundary: you cannot hand Deno a live http.Request and hope for the best.
Conduit's contract:
- Go snapshots the request into serializable JSON.
- The snapshot crosses a length-prefixed frame over a Unix socket to Deno.
- The plugin operates on a cloned, hook-local context with a frozen inbound request.
- The plugin returns a patch: a minimal delta, not a mutated object.
Why patch instead of full context on return: sending the complete context back from every hook means re-serializing headers, identity, and potentially large bodies, twice per request, per plugin. The patch engine instead sends only what changed: omitted fields are unchanged, present fields overwrite, null tombstones a deletion. A logging plugin that sets one state field ships a few dozen bytes back, not the whole request graph.
Why bodies never fully cross the wire: anything above 64 KiB stays in a per-request BodyStore in Go. Plugins get a stream:// reference token, not raw bytes. Megabytes stay in Go; metadata crosses the socket.
Plugins don't own the request. They propose changes to it.
That sentence is the whole design. Reject/forward signals, the patch diffing rules, the frozen snapshot: all of it exists to make "propose, don't own" safe and cheap.
The plugin model
Plugins stay deliberately small:
| Hook | When |
|---|---|
onLoad |
Once at startup |
beforeRequest |
Before upstream proxying |
afterResponse |
After upstream response is captured |
onError |
When a hook in this plugin throws unexpectedly |
ctx.reject() is intentional control flow, not an error; onError is for actual surprises.
Plugins load in filename order, full stop:
plugins/
001-auth.ts ← runs first
002-logging.ts ← sees ctx.user from auth
003-header-rewrite.ts
004-route-control.ts
No dependency graph, no hidden priority system. 001-auth.ts runs before 002-logging.ts because of the filename, and that's grep-able at 3am.
The ctx surface is narrow on purpose:
| Member | Role |
|---|---|
ctx.request |
Read-only inbound snapshot |
ctx.response |
Outbound builder (setStatus, setHeader, setBody) |
ctx.user |
Identity set by auth plugins |
ctx.state |
Per-request key/value bag shared across hooks |
ctx.log |
Structured logging with trace correlation |
ctx.reject(status, msg) |
Stop pipeline, return HTTP error |
ctx.forward(url) |
Proxy to an alternate upstream (validated by the gateway) |
ctx.services |
Optional http and cache helpers |
No global singletons, no ambient mutable request object. Auth sets ctx.user; logging reads it. That's the entire coordination contract.
What happened when I actually ran it
The first deployment was personal: three services behind one port, HMAC checks in one plugin file, JWT checks in another, logging in a third. The prefix-matching spaghetti became three files I could read top to bottom.
Then it spread quietly: internal tools needing a stable hostname, admin surfaces needing stricter gating, experimental routes I could add without redeploying backends.
The moment I knew it worked wasn't a benchmark. It was forgetting it was running, then stopping the Docker container to debug something unrelated and watching everything break. That's the success criterion I actually cared about: not feature parity with Kong, invisibility under normal operation.
Failure modes, stated plainly
| Event | Behavior |
|---|---|
| Plugin hook exceeds timeout (default 100ms) | Warning logged; request continues proxying |
| Plugin worker crashes | Isolate replaced; gateway process survives |
| Entire Deno runtime unavailable |
failPolicy: "closed" → 503; "open" → proxy without plugins |
ctx.reject(401) |
Pipeline stops; remaining hooks skipped |
Invalid ctx.forward() target |
Logged, ignored; original route used |
| Upstream failure | Standard 502 propagated |
Hook timeout and runtime death are different failure classes on purpose. A slow logger shouldn't look like a dead security layer.
The riskiest failure mode isn't a crash, though. It's silent semantic change: a plugin that mis-sets ctx.user or swallows a header keeps the system at 200 OK while behavior quietly drifts underneath it. That's why every log line carries [trace:<id>], and why reject and thrown errors are treated as distinct signals.
What it's not
- Not Envoy: no xDS, no WASM filter ecosystem at scale.
- Not Kong: no admin UI, no plugin marketplace.
- Not a service mesh.
- Not for multi-gigabyte streaming responses.
afterResponsebuffers the body so plugins can inspect/rewrite it, which costs memory proportional to response size.
What it gains in exchange: a codebase readable in an afternoon, plugins you edit and save instead of recompile, no cloud dependency to define policy, and bounded IPC cost (one warm Unix socket, patches instead of full payloads, body references instead of raw bytes).
It's lean, not free. Four plugins across two phases is up to eight round trips per request. Sub-millisecond on a local socket. Wrong tool if you need per-byte stream processing at scale; right tool if you've got a handful of services, evolving edge policy, and a team that already writes TypeScript.
What stuck
-
Abstraction is only good if it disappears in daily use. The pieces that survived are the ones I stopped thinking about. The pieces that died were baked into Go: the
adminAuth()/userAuth()/chatAuth()prefix ladders that worked fine until they needed to change without a redeploy. - Cross-process plugin systems are viable if state transfer is strict. Patches, body references, frozen snapshots. Not optimization trivia; the actual precondition for Go and Deno cooperating without shared-memory bugs.
-
Failure design shaped operability more than any plugin API did.
failPolicy, hook timeouts, and thereject/onErrorsplit mattered more than feature count. - "Simple" means boundary discipline, not low line count. Go owns the network. Deno owns execution. Plugins own policy proposals. Upstream owns business logic. Nobody reaches across.
Closing
This wasn't built to be a product. It was a response to a system that stopped fitting in my head: three services, two auth models, one endpoint, and a gateway that grew a new strings.HasPrefix branch every time something shipped.
The goal was never to out-build Envoy or Kong. It was to build something I could forget existed until the day I needed to reason about it. At that point, reasoning about it should be easy: read the plugin files, read the config, follow one request through Go → IPC → Deno → upstream → back.
If you're standing where I was (auth diverging, routing logic leaking into every handler), you might not need another service. You might need a place where policy can live without infecting everything else.
Try it:
go run ./cmd/conduit -config conduit.config.json
export default {
beforeRequest(ctx) { /* policy */ },
afterResponse(ctx) { /* transforms */ },
};
Full design-decision Q&A (why Deno over Node/Lua/WASM, scaling, "when is this the wrong choice," etc.) is in my site, not here.