← All posts
Conduit: Design and Engineering of a Programmable API Gateway

Conduit: Design and Engineering of a Programmable API Gateway

23, Jun, 2026

A programmable API gateway that separates Go edge routing from sandboxed TypeScript policy plugins, and the design tradeoffs that shaped it.

A case study in boundary discipline: how an ugly, working Go gateway became the direct ancestor of a cross-process, plugin-based system, and what each design decision cost and bought.

Repository: github.com/Distributed-Systems-By-Adam/conduit, clone it, run it, and extend request behavior with TypeScript plugins.

Abstract

This case study documents the design and evolution of Conduit, a programmable API gateway built to sit in front of a small set of microservices with heterogeneous authentication models. The motivating problem was not performance or scale, it was maintainability of policy: routing and authorization rules that had to change frequently were implemented in compiled Go, which meant every change required a code edit and a redeploy. The solution separates the system into two cooperating processes, a Go gateway that owns the network boundary, and a sandboxed Deno runtime that executes policy as TypeScript plugins connected by a strict snapshot-and-patch IPC protocol over a Unix domain socket. This document covers the originating problem, the first (rejected) implementation, the five design constraints that shaped the rewrite, the resulting architecture, the engineering decisions behind the IPC layer, observed failure modes, explicit tradeoffs, and a structured set of design-review questions with their answers.

1. Problem Statement

The system in question consisted of three microservices behind a single public API surface:

  • chat-service: real-time messaging
  • user-service: profiles, sessions, identity
  • admin-service: internal tooling and elevated operations

Each service was independently well-behaved. The difficulty arose from their composition. User and chat traffic authenticated via a custom session-and-HMAC scheme: Authorization: Session <id>, signed with X-Signature, X-Timestamp, and X-Nonce, backed by Redis for both session storage and replay protection. This was not JWT, and not a standard OAuth flow, it was a bespoke scheme developed and iterated on over several months. Admin traffic, by contrast, used JWT, verified once at the edge and then forwarded downstream via trusted X-Gateway-Admin-* headers, with the admin service deliberately skipping re-validation.

These two trust models differed in header shape, validation logic, public-path exceptions, and failure semantics. Yet all traffic needed to converge on a single public hostname, client code should never need to know how many backend services exist, only that one endpoint behaves predictably.

The constraint that defines this case study is therefore not "build a fast reverse proxy." It is: how do you keep heterogeneous, frequently-changing authorization policy from becoming permanently embedded in compiled infrastructure code?

2. The First Implementation

The first gateway was a single Go binary: three reverse proxies, shared CORS and logging middleware, and one handler that resolved both backend and auth mode per request.

go
res := route.Select(r.URL.Path)
proxy := proxies[res.Backend]
h, ok := applyAuth(w, res.Auth, cfg, sessionStore, nonceStore, proxy)
h.ServeHTTP(w, r)

route.Select() mapped path prefixes to a backend and an auth mode:

text
/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/*

applyAuth() dispatched to one of adminAuth(), userAuth(), or chatAuth(), each of which encoded its own table of path exceptions as strings.HasPrefix branches.

2.1 What worked

The implementation was reliable. Requests reached the correct upstream. JWT and HMAC validation behaved correctly. Redis-backed sessions extended their TTL on every successful request. Downstream services received pre-validated, trusted headers and did not need to re-implement auth logic themselves.

2.2 What did not work

The system's failure was not correctness, it was extensibility. Four properties compounded into a maintenance burden:

  1. Policy was compiled. Every change to an auth rule, a newly public endpoint, a newly restricted one required editing Go source and redeploying the gateway binary.
  2. Exceptions accumulated as prefix archaeology. Understanding why a given path behaved a certain way required reading three separate *Auth() functions and cross-referencing a route table documented separately, in prose, in a growing README.
  3. There was no composition mechanism. Adding a new cross-cutting concern (e.g., an additional logging step, a new validation pass) meant editing the central handler chain directly, since the system had no notion of independent, addable units of behavior.
  4. Documentation substituted for structure. The system's correctness depended on a human reading and internalizing a document, rather than on the system's own organization making the behavior self-evident.

The deeper diagnosis: this was not a microservices problem, but a boundary discipline problem. The gateway's network-handling logic was sound; what it lacked was any clean place for policy to live that was distinct from the network-handling logic itself. Every new business rule had only one available home deeper inside the same compiled code that handled routing and proxying and that home made each rule progressively more specific and less general.

Every new exception made the gateway more specific instead of more general.

This first implementation was not discarded. It validated the routing model, one entry point, multiple backends, differentiated trust per surface while demonstrating that the implementation model needed to change. That distinction is the origin of Conduit.

3. Design Constraints

Five constraints were established before implementation began, and each subsequent architectural decision in this case study can be traced back to one or more of them.

3.1 Freedom of implementation

Policy authoring should require no more than ordinary TypeScript. There is no proprietary configuration DSL, no embedded scripting language, no vendor-specific plugin format. A plugin is a file in ./plugins exporting an object with a small set of lifecycle hooks:

typescript
import type { GatewayContext } from "../runtime/shared/types.ts";

export default {
  beforeRequest(ctx: GatewayContext): void {
    if (!ctx.request?.headers?.Authorization) {
      ctx.reject!(401, "missing token");
    }
  },
};

The premise is that policy should be ordinary, reviewable, version-controlled code, written in a language the team already uses daily rather than a specialized configuration surface that exists only inside the gateway.

3.2 Low operational overhead

The system must not require a control plane, a Kubernetes deployment, or service-mesh sidecar infrastructure to operate. In its default configuration, Conduit is one Go binary supervising a Deno runtime, configured via a single JSON file:

bash
go run ./cmd/conduit -config conduit.config.json

Docker Compose is convenient but optional. The intended operator is someone who understands HTTP and TypeScript, not someone who specializes in operating a gateway appliance.

3.3 Explicit separation of concerns

LayerResponsibility
Go gatewayHTTP ingress, routing, upstream proxying, CORS, body limits, timeouts, SSRF guards
Deno runtimeSandboxed plugin execution in a warm worker pool
PluginsBusiness policy, authentication, logging, transformation, route control

These boundaries are treated as invariants, not conventions: Go must remain plugin-agnostic, the runtime must not own routing decisions, and plugins must never directly touch the network boundary. They are documented explicitly (in MAINTAINERS.md) because violations of these invariants tend to produce subtle, cross-language failures that automated tests do not reliably catch.

3.4 Predictable failure modes

A misbehaving plugin must not be able to compromise the gateway process. Hook timeouts are logged as warnings, and the request continues proxying. A crashed worker results in isolate replacement, not process termination. When the entire plugin runtime is unreachable, a per-route failPolicy setting determines whether the gateway returns 503 (closed) or bypasses the plugin chain and proxies the request anyway (open). Security-sensitive routes default to closed; liveness/health routes can be configured open. The intent is that every failure behavior is a stated configuration choice, not an emergent accident of implementation.

3.5 Invisibility in production

The design target is that the gateway should not occupy ongoing attention. If it is functioning correctly, an operator deploys a new plugin when policy changes, occasionally inspects structured logs, and otherwise does not think about the system. This framing, invisibility as a success criterion rather than a deficiency recurs throughout the evaluation in Section 7.

The system should not be something you constantly think about while using it.

4. Architecture

At runtime, Conduit consists of two cooperating processes connected by a Unix domain socket.

Loading diagram…

4.1 Request lifecycle

  1. A client connects to the Go gateway on a configured listen address.
  2. CORS preflight (OPTIONS) requests are answered entirely within Go; plugins never observe them.
  3. Go constructs a serializable context snapshot of the inbound request.
  4. Every plugin's beforeRequest hook executes, in deterministic filename order (e.g., 001-auth.ts before 002-logging.ts).
  5. Go proxies the request once, to either the route-matched upstream or a plugin-selected forward target.
  6. The upstream response is captured and attached to the context.
  7. Every plugin's afterResponse hook executes, in the same deterministic order.
  8. Go writes the final response to the client.

Upstream services are entirely unaware that Conduit exists in the request path. They receive standard proxied HTTP with trusted forwarding headers; they do not import a Conduit-specific SDK and do not register themselves with the gateway.

4.2 Routing as configuration

Multi-service routing is expressed declaratively, not implemented as code:

json
{
  "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" }
  ]
}

A single plugin chain applies uniformly across all configured backends. This is the structural fix for the original problem: a routing-table change is a configuration edit, and a policy change is a plugin file edit, neither requires touching the gateway's compiled source.

5. The Central Engineering Decision: IPC via Context Snapshot and Patch

5.1 The problem with shared mutable state

The first gateway's core defect was not its routing logic but the location of its state and policy. Passing a mutable request object through a chain of middleware creates hidden coupling even within a single process, each handler can implicitly depend on side effects from a previous one. Splitting policy execution across a process boundary makes this strictly worse: a live http.Request object cannot be handed to a separate runtime (Deno) and safely mutated in place.

5.2 The protocol

Conduit's IPC layer is built around four steps:

  1. Go snapshots the inbound request into a serializable context object.
  2. The snapshot is transmitted to the Deno runtime as a length-prefixed JSON frame over a Unix domain socket.
  3. Each plugin operates on a cloned, hook-local copy of the context, with the inbound request frozen (read-only).
  4. The plugin returns a patch, a minimal description of the delta it wishes to apply, rather than a mutated object.
Loading diagram…

5.3 Rationale for patches

Returning the entire context object on every hook invocation would require re-serializing request metadata, headers, identity, shared state, and potentially the request or response body, once per plugin, twice per request (beforeRequest and afterResponse). This does not scale linearly with plugin count in any acceptable way.

The patch engine instead computes and transmits only the delta: omitted fields are treated as unchanged, present fields overwrite the corresponding field in the context, and explicit null values act as tombstone deletions within map-like sections. A logging plugin that sets a single timestamp in ctx.state returns a payload of a few dozen bytes, regardless of how large the surrounding request context is.

5.4 Rationale for body references

Request or response bodies exceeding a configurable threshold (default 64 KiB) never cross the IPC boundary as raw bytes. Go retains them in a per-request BodyStore and instead passes a stream:// reference token within the snapshot. Plugins observe only metadata about the body; the bytes themselves remain entirely within the Go process. This decouples the cost of cross-process extensibility from the size of the payload being proxied.

5.5 The governing principle

Plugins don't "own" the request. They propose changes to it.

This single principle underlies the rest of the IPC design: the reject and forward control-flow signals, the modify action triggered by a non-empty patch, and the shallow-diff rules governing what is considered "changed." All of it exists to make the propose-don't-own model both safe (no plugin can corrupt shared state arbitrarily) and efficient (no plugin pays a serialization cost proportional to data it did not touch).

6. The Plugin Model

6.1 Lifecycle hooks

HookWhen it runs
onLoadOnce at startup, warm caches, read configuration
beforeRequestBefore upstream proxying
afterResponseAfter the upstream response is captured
onErrorWhen a hook within this plugin throws unexpectedly

ctx.reject() is treated as intentional control flow rather than an error condition; onError exists specifically for unanticipated failures, and the distinction between the two is preserved throughout logging and observability.

6.2 Pipeline composition

Plugins load in deterministic filename order. Numeric prefixes express sequence where it matters:

text
plugins/
  001-auth.ts           ← runs first
  002-logging.ts        ← sees ctx.user from auth
  003-header-rewrite.ts
  004-route-control.ts

The request flow follows directly from this ordering:

text
001.beforeRequest → 002.beforeRequest → ... → upstream → 001.afterResponse → 002.afterResponse → ...

There is deliberately no hidden priority system and no dependency-injection graph. Filename order is the entire contract, chosen specifically because it is grep-able and diff-able without any additional tooling.

6.3 The ctx surface

MemberRole
ctx.requestRead-only inbound snapshot
ctx.responseOutbound builder (setStatus, setHeader, setBody)
ctx.userIdentity, set by authentication plugins
ctx.statePer-request key/value bag shared across hooks
ctx.logStructured logging with trace correlation
ctx.reject(status, msg)Stops the pipeline and returns an HTTP error
ctx.forward(url)Proxies to an alternate upstream, validated by the gateway
ctx.servicesOptional http and cache helper utilities

Cross-plugin coordination occurs only through this explicit surface. An authentication plugin populates ctx.user; a logging plugin reads it; a header-rewrite plugin operates on ctx.response. There are no global singletons and no ambient mutable request object that plugins might implicitly depend on.

6.4 A representative chain

In a typical deployment, 001-auth.ts gates the request and establishes identity; 002-logging.ts records a start timestamp in ctx.state, logs with the now-available ctx.user, and computes duration in afterResponse; 004-route-control.ts rejects blocked paths or forwards /beta/* traffic to an alternate upstream. Each file performs one task; the pipeline performs the orchestration.

Plugins are intentionally simple. Complexity belongs in composition, not abstraction.

7. Evaluation: Real-World Usage

Conduit was built to resolve a specific operational problem, not as a general-purpose product. Its first deployment was personal: the original three services consolidated behind one port, with HMAC session checks in one plugin file, JWT checks in another, and request logging in a third. The prefix-matching logic that had previously been spread across three Go functions collapsed into three independently readable files.

Adoption then expanded organically, covering internal tools requiring a stable public hostname, admin surfaces requiring stricter gatekeeping than user-facing APIs, and experimental routes that could be introduced without redeploying any backend service. Over time, route configuration moved entirely into JSON, and all policy logic moved entirely into TypeScript; backend service code was no longer touched for edge-level concerns.

The most informative evidence of success was incidental rather than measured. After an extended period of stable operation, auth holding, logs appearing correctly prefixed with [trace:<id>], services deploying independently, the gateway was stopped, as an unrelated debugging step, and every downstream service abruptly became unreachable. The system had become sufficiently unobtrusive that its presence had been forgotten.

I forgot Conduit was even in the stack.

This is presented as the primary success criterion for this case study: not feature parity with established gateway products, but invisibility under normal operating conditions.

8. Failure Modes

A case study limited to the favorable path is closer to marketing than to engineering. This section documents observed and designed-for failure behavior directly.

8.1 Designed behaviors

EventBehavior
Plugin hook exceeds timeout (default 100ms)Warning logged; request continues proxying
Plugin worker crashesIsolate replaced; gateway process survives
Entire Deno runtime unavailablefailPolicy: "closed"503; "open" → proxy without plugins
ctx.reject(401)Pipeline stops; remaining hooks skipped
Invalid ctx.forward() targetLogged; forward ignored; original route used
Upstream failureStandard proxy error (502) propagated

Hook timeout and runtime death are treated as distinct failure classes with distinct policies, because a single slow plugin (e.g., a logging plugin awaiting an external write) should not be indistinguishable from the complete absence of a security layer.

8.2 Real risks

Silent degradation. A plugin that exceeds its timeout is, by default, treated as "continue", traffic keeps flowing even though that plugin's check may not have completed. This is a deliberate tradeoff favoring availability over fail-closed behavior on slow hooks, but it requires operational discipline: hooks must be kept fast, timeout warnings must be monitored, and slow I/O must never be placed in the critical path without a clear understanding of the consequence.

Debugging requires tracing discipline. When several plugins run across two phases over a pooled IPC connection, reconstructing "what happened to this request" depends on consistent trace correlation. Conduit prefixes every relevant log line with [trace:<id>] specifically because, without that discipline, a cross-process pipeline becomes opaque during incident response.

Invisibility can mask logical errors. The most consequential failure mode observed in this system is not a crash but a quiet change in behavior. A plugin that mis-assigns ctx.user, strips an expected header, or misapplies a path prefix will not cause the gateway to fail visibly, it will continue returning 200 OK while the underlying semantics drift, often undetected until a downstream symptom surfaces.

9. Tradeoffs

Honest evaluation requires explicitly naming what was sacrificed in pursuit of the stated design constraints.

9.1 What Conduit is not

  • Not Envoy, no xDS control plane, no WASM filter ecosystem operating at scale.
  • Not Kong, no enterprise administrative UI, no plugin marketplace.
  • Not a service mesh, no mTLS between every service instance, no traffic-shifting primitives.
  • Not a complete edge platform, TLS termination is expected to occur upstream of Conduit, at a load balancer or reverse proxy.

9.2 What was deliberately sacrificed

  • Dashboards and visual route editors.
  • Plugin marketplaces and signed third-party plugin distribution.
  • Protocol support beyond HTTP reverse proxying (no gRPC, WebSocket, or raw TCP routing).
  • Zero-copy response streaming, afterResponse hooks require the full response body to be buffered so they can inspect and modify it.

9.3 What was gained

  • Readability, the system, in its entirety, fits within a codebase that can be read in a single sitting.
  • Hackability, a policy change is a file edit and a save, not a build-and-deploy cycle.
  • Local-first control, policy is defined without dependency on an external cloud control plane.
  • Low cognitive overhead, filename ordering, JSON configuration, and TypeScript hooks require no new mental model beyond what most engineers already carry.
  • Bounded IPC cost, a single Unix socket per request, a warm worker pool, body references in place of raw bytes, and patch-based returns.

9.4 Overhead, stated plainly

Conduit is lean, not free. Each plugin, in each phase, constitutes one IPC round trip with JSON framing; four plugins across two phases amounts to up to eight round trips per request, all on a single reused connection. This is microseconds to low milliseconds of overhead on a local socket, acceptable for edge authentication and transformation work, and explicitly not designed for per-byte stream processing at scale. The architecture assumes plugins represent thin policy logic, not heavy computation.

10. Lessons

Abstraction is only valuable if it disappears in daily use. The components of this system that survived contact with continued operation are precisely the ones that stopped requiring attention: route matching, proxy header hygiene, context snapshotting. The components that were discarded were the ones embedded directly in Go, the adminAuth() / userAuth() / chatAuth() prefix ladders, which functioned correctly until they needed to change without a redeploy.

Cross-process plugin systems are viable when state transfer is strictly governed. The patch model, body references, and frozen request snapshots are not incidental optimizations; they are the precondition that makes safe cooperation between two independent runtimes (Go and Deno) possible without shared-memory hazards.

Failure design has more influence on operability than feature design. failPolicy, hook timeout semantics, worker replacement, and the distinction between reject and onError shaped how much the system could be trusted in production more than any individual plugin capability did. A gateway is trustworthy to the extent that its failure modes are predictable.

"Simple systems" are, in practice, boundary-discipline systems. The simplicity of this design is not a function of total line count; it is a function of a small, consistently enforced set of ownership rules, Go owns the network, Deno owns execution, plugins own policy proposals, and upstream services own business logic.

Low operational overhead is an architectural choice, not a side effect of language selection. Go handles bodies and proxying because that is where performance-sensitive work belongs. Deno executes short policy hooks on warm isolates because that is where flexibility matters more than raw throughput. Large payloads remain in Go; only deltas cross the process boundary. The fast path stays in the fast language by design, not by accident.

11. Conclusion

Conduit was not conceived as a product to compete with established API gateways. It was a direct response to a system that had outgrown what could comfortably be held in one engineer's mental model, three services, two incompatible authentication schemes, a single public endpoint, and a gateway that acquired a new strings.HasPrefix exception with every subsequent release.

The objective was not to outperform existing infrastructure on a benchmark. It was to build a system that could be safely forgotten until the moment it needed to be reasoned about, at which point that reasoning should be straightforward: read the plugin files, read the configuration, and trace a single request through Go, across the IPC boundary, into Deno, out to the upstream service, and back. No control-plane archaeology. No service-mesh topology diagram to reconstruct. A boundary, held.

For an engineer in a comparable position, microservices proliferating, authentication models diverging, routing logic leaking outward into every handler that touches it, the conclusion of this case study is that the missing piece may not be another service. It may be a place where policy can live without infecting everything around it.

12. Design Review: Questions and Answers

This section anticipates the questions a skeptical reviewer, in a design review, an interview, or a production readiness assessment, is likely to raise. Each answer is tied to an intentional design decision documented elsewhere in this case study, not presented as an unresolved gap.

Architecture and language split

Q: Why two runtimes at all? Why not a single language?

A: The gateway performs two functions that pull in opposite directions. Go handles the part of the system that must be fast, predictable, and auditable: HTTP serving, routing, reverse proxying, body limits, CORS, and SSRF guarding. This is infrastructure, and it benefits from being compiled, statically typed, and resistant to surprising behavior under load. Deno/TypeScript handles the part of the system that must change frequently: authentication rules, logging behavior, header transformations, and route-level policy. This is application-level logic operating at the edge, and it benefits from being editable without a recompilation step.

The first gateway demonstrated that the routing model in Go was sound, but also demonstrated that policy embedded in Go does not scale organizationally, every exception required a redeploy. Conduit retains Go for the work Go is suited to, and relocates policy to a surface that is broadly familiar across engineering teams, given how widely TypeScript is used relative to Go in application-level (as opposed to infrastructure-level) development.

A single-language design would force one of two compromises: either policy changes require gateway rebuilds (an all-Go system), or HTTP ingress and proxying execute inside a scripting runtime not designed for that load profile (an all-Deno system). The two-runtime split is the compromise that avoids both failure modes.


Q: Why Go specifically for the gateway layer?

A: Go is well suited to a network-edge process for several concrete reasons: a mature net/http and httputil.ReverseProxy provide battle-tested proxying primitives without requiring reimplementation; the language offers predictable memory and concurrency behavior under connection load; deployment is a single static binary, with no separate Node or Deno process required for the data plane itself; and the supervisor pattern this system relies on, spawning Deno, waiting for the runtime's socket to become available, and restarting with backoff on failure, is straightforward to implement in Go.

The gateway is deliberately not where plugin authors are expected to experiment. It is infrastructure code intended to remain stable, reviewable, and minimal in attack surface, and Go was chosen specifically to support that posture.


Q: Why Deno for plugin execution, rather than Node, Lua, or WASM?

A: Deno was selected for plugin execution specifically, not for gateway-level throughput.

OptionWhy it was not the default
NodeHeavier operational footprint; coarser permission model; plugin sandboxing achieved by convention rather than by default
Lua (OpenResty-style)Capable, but introduces a gateway-specific scripting surface that fewer engineers use day-to-day
WASMStrong isolation properties, but a higher integration cost than warranted by the goal of "drop a .ts file in ./plugins"
DenoTypeScript-native; built-in permission flags (--allow-net); worker-isolate sandboxing; jsr:/npm: dependency resolution without a separate bundling step

The governing principle was: if you can write TypeScript, you can extend the gateway. Deno was the smallest runtime that satisfied that principle while still providing meaningful sandbox controls.


Q: Isn't Deno a scalability bottleneck relative to Go?

A: Deno is deliberately excluded from the throughput-critical path for request and response bodies; this exclusion is the architecture's central premise, not an oversight.

WorkPerformed byScaling mechanism
Accepting connectionsGoHorizontal, additional gateway instances behind a load balancer
Reading/writing bodiesGoBodies above 64 KiB never cross the IPC boundary; they remain in Go's BodyStore
Upstream proxy round tripGo ReverseProxyEquivalent to any standard reverse proxy; Deno is not involved
Plugin hooksDeno worker poolBounded execution time, parallelized across isolates

Deno executes only thin policy checks, token validation, header inspection, ctx.reject() calls, structured logging. The default hook budget is 100ms, with a 200ms hard ceiling, reflecting an assumption that plugin logic operates on the order of microseconds to low milliseconds, not on the order of a database round trip.

If Deno becomes a measured bottleneck in a given deployment, that is generally a sign of misuse rather than a limitation requiring a runtime change. Appropriate remedies include moving expensive work out of hooks entirely (e.g., caching session metadata at onLoad rather than querying Redis per request), scaling pluginPoolSize to match expected concurrent hook execution, scaling gateway instance count horizontally, or consolidating plugin count where multiple independent hops are not strictly necessary. Deno was never intended to proxy traffic; it was intended to answer bounded yes/no questions about requests before Go proxies them, a division of labor analogous to not asking a relational database to serve static assets.


Q: Why not execute plugins within Go itself, via plugin.so or an embedded scripting engine?

A: Go's native dynamic-plugin mechanism is fragile across operating-system and architecture boundaries, unsuitable for safe hot reloading, and still requires a compilation step, which undermines the core goal of edit-and-save plugin authorship. Embedding a scripting language such as Lua or JavaScript directly within the Go process would similarly sacrifice the developer experience this design depends on.

Executing plugins in a separate Deno process instead provides crash isolation (a faulty plugin terminates an isolate, not the gateway process), genuine hot reload (the worker pool can be swapped without restarting Go), and a real permission sandbox (Deno's network allowlist can be derived directly from configured upstream hosts). The IPC overhead is the cost of that isolation, and it is bounded and local, a Unix socket, not a network hop.


IPC, context, and performance

Q: Why JSON-framed IPC? Isn't that comparatively slow?

A: Relative to an in-process function call, yes. Relative to the realistic alternatives this system is evaluated against, no. Unix domain sockets avoid TCP-level overhead, since there is no per-request handshake when connections are pooled. A single connection is reused across all hooks within a given request, rather than dialing once per plugin. The return path transmits patches rather than full context objects, so a small header change does not require re-serializing the entire request graph. Bodies above the configured threshold use reference tokens (stream://N) rather than crossing the boundary as raw bytes.

JSON specifically was chosen for debuggability and contract clarity rather than maximal theoretical throughput: a socket's traffic can be inspected directly (e.g., via tcpdump), and Go and Deno can agree on a schema without code-generation tooling. For edge policy hooks, this tradeoff is appropriate. Systems requiring nanosecond-scale filter chains on every byte are operating in a different problem space, one better served by Envoy than by Conduit.


Q: Why transmit the full context inbound but only a patch on return?

A: Each plugin must observe the current accumulated state of the request, an authentication plugin sets ctx.user, and a subsequent logging plugin must be able to read it. That requirement necessitates a complete snapshot on the inbound side. Patches on the return path avoid re-transmitting unchanged data. The asymmetry is intentional: reads must be complete in order to preserve correctness, while writes can be minimal because most plugins touch only a small portion of the context. Merging a patch into the canonical context in Go (ApplyPatch) is computationally cheap relative to the cost of serializing a large response body multiple times.


Q: Why buffer the entire upstream response rather than streaming it?

A: Because afterResponse hooks are expected to read and, where necessary, modify the response, status code, headers, or body. A transform hook cannot operate on a stream that has already been forwarded to the client. This represents a deliberate capability tradeoff: the system gains the ability for plugins to rewrite response headers, inspect structured error bodies, and attach metrics, at the cost of memory proportional to response size for the duration of the hook chain. For a system whose primary use case is JSON API traffic, this cost is acceptable; for video streaming or multi-gigabyte downloads, this architecture is explicitly the wrong tool, and that limitation should be stated plainly in any design review considering Conduit for such a workload.


Q: Why issue one IPC round trip per plugin rather than batching the entire chain into a single exchange?

A: Batching would reduce the total number of round trips but would introduce meaningful complexity elsewhere: partial failure mid-chain becomes substantially harder to attribute to a specific plugin; per-plugin timeout and onError scoping become ambiguous; and the short-circuiting behavior of ctx.reject() and ctx.forward() is difficult to reconcile with a batched execution model. Conduit optimizes for clarity and debuggability over minimizing IPC count specifically. Four plugins executing across two phases produce eight round trips on a single socket, which remains sub-millisecond for typical snapshot sizes. Workloads requiring on the order of fifty filters per request are, again, better served by a system such as Envoy.


Failure modes and operability

Q: A plugin can time out and the request proceeds regardless, is that not a security risk?

A: Yes, specifically if the timing-out plugin is responsible for authentication, which is precisely why this behavior is documented, logged, and configurable rather than left implicit.

FailurePolicyRationale
Single hook is slow (e.g., logging)Continue proxyingA slow logger should not be able to block legitimate API traffic
Entire runtime is deadfailPolicy: "closed" (default)Security gating must not silently disappear
Health-check route during an outagefailPolicy: "open" per routeLiveness probes must still reach the upstream

Hook timeout and runtime death are treated as distinct failure classes deliberately. Operational mitigation includes keeping authentication logic in fast, synchronous hooks (avoiding unbounded await calls to external services within beforeRequest where continue-on-timeout behavior is unacceptable), monitoring gateway logs for "hook timed out; treating as continue" warnings, and tuning hookTimeoutMs and hardCeilingMs to match the relevant service-level objective, since both are configuration values rather than fixed constants.

Where a hard requirement exists that an authentication hook timeout must always produce a 401 rather than a pass-through, that requirement is implemented at the plugin level (explicit ctx.reject() on missing proof) combined with keeping the hook reliably fast enough to avoid the ceiling, or, alternatively, the deployment accepts that Conduit, by default, prioritizes availability over fail-closed behavior specifically for slow (not absent) hooks.


Q: What happens when a Deno worker crashes mid-request?

A: The affected isolate is marked broken, any pending hook promises associated with it are rejected, the worker pool spawns a replacement, and the gateway discards the broken IPC connection in favor of a freshly acquired one for subsequent hooks. The gateway process itself does not exit, and other in-flight requests being served by other workers are unaffected, which is the specific reason plugin execution occurs in workers rather than on the runtime's main thread.


Q: What happens if the entire Deno runtime dies?

A: The outcome depends on the configured failPolicy. Under the default, closed, the gateway returns 503 plugin runtime unavailable and does not proxy to the upstream; this setting is appropriate wherever plugins are responsible for enforcing security. Under open, the gateway skips the plugin chain entirely and proxies as though no hooks were registered; this setting should be used sparingly, primarily for routes such as /health where the cost of skipping policy is acceptable. This scenario is distinct from a single hook timeout: a dead runtime means the security layer itself is absent, and the default response is to stop serving the affected routes rather than to proceed as though nothing were wrong.


Q: What is the most dangerous failure mode in this system?

A: Not a crash, but a silent semantic change. A plugin that incorrectly sets ctx.user, strips a header it should not have, or times out at a point where authentication was expected to run will not cause the system to fail visibly, it will remain operational, continue returning 200 OK, and allow behavior to drift undetected. This is the specific motivation behind traceId-based log correlation, the explicit distinction between reject and a thrown error, and the deliberate, written documentation of the timeout-then-continue behavior rather than leaving it as an implicit consequence of the implementation.

The most dangerous failure mode is not breaking the system, it's changing behavior without breaking it.


Q: Can a hot plugin reload disrupt in-flight requests?

A: By design, no. The plugin watcher constructs an entirely new worker pool, atomically swaps it in as the active pool, and only then closes the previous pool, allowing in-flight requests on the old pool to complete before it is disposed of. The remaining edge case is a reload occurring mid-hook on a pool that is in the process of being retired; this is bounded by the configured hook timeout. In production, a plugin deployment involving hot reload should be treated with the same caution as a rolling deployment: low-risk in development, but monitored in production.


Security

Q: What is the trust model for plugin code?

A: Conduit does not treat plugins as fully untrusted, arbitrary third-party code; it treats them as an organization's own policy code, while still isolating them on the premise that a bug in that code should not be able to crash the gateway process. The relevant isolation layers are: Deno worker isolates providing per-worker memory separation; an --allow-net allowlist in embedded mode, derived directly from configured upstream hosts; ctx.forward() calls validated in Go against forward.allowedHosts and a blockPrivateNetworks setting, an SSRF guard enforced outside the plugin's own honor system; a Unix socket secured with 0600 permissions, so that local users other than the gateway process cannot connect to the runtime socket directly; and hop-by-hop header stripping combined with trusted X-Forwarded-* header injection performed in Go. This constitutes practical sandboxing appropriate to internally authored policy code, not the security model required of a multi-tenant plugin marketplace.


Q: Why is CORS handled in Go rather than in a plugin?

A: CORS is a contract enforced at the HTTP edge between browsers and servers; it is not business policy, and treating it as such would mean preflight (OPTIONS) requests would traverse authentication hooks, fail in inconsistent ways depending on plugin configuration, and behave differently across deployments. Handling CORS at the gateway level answers preflight requests before the plugin pipeline executes at all, allowing plugins to remain focused exclusively on authorization and transformation concerns. This mirrors the separation already present in the original gateway's middleware layer, relocated, in Conduit, to a boundary that is enforced permanently rather than incidentally.


Design philosophy and fit

Q: Why filename-based ordering for plugins, rather than an explicit dependency graph?

A: This reflects a preference for the explicit over the clever. 001-auth.ts running before 002-logging.ts is immediately grep-able and diff-able, and requires no supporting framework to reason about. A dependency graph would offer more flexibility, at the cost of being substantially harder to reason about during incident response under time pressure, a tradeoff judged unfavorable for this system's intended operating context.


Q: Why not adopt an existing gateway such as Envoy, Kong, or APISIX?

A: Not because these systems are deficient, but because they are designed to solve a different problem at a different operational cost. Conduit targets teams that want policy expressed in TypeScript without operating a control plane, codebases small enough to be read in a single sitting, and a local-first development workflow (go run, followed by saving a plugin file). Envoy and Kong are the stronger choice when requirements include global rate limiting at very high request volumes, a WASM filter ecosystem, enterprise role-based access dashboards, or configuration distribution across multiple clusters. Conduit is the stronger choice when the requirement is a programmable edge that a small team can largely forget about until policy needs to change.


Q: Under what conditions is Conduit the wrong choice?

A: Honesty about fit is part of a credible design review.

RequirementBetter-suited alternative
Multi-gigabyte streaming responsesA CDN, or a direct proxy without response-body capture
Sub-millisecond filter chains at very high QPSEnvoy, or custom in-process Go middleware
Multi-tenant, fully untrusted third-party pluginsA WASM sandbox combined with code-signing infrastructure
gRPC, WebSocket, or raw TCP routingConduit is scoped specifically to HTTP reverse proxying
A requirement for GUI-based route managementKong, or a managed cloud API gateway

Conduit is well suited to a context with a modest number of services, edge policy that evolves regularly, and a team comfortable writing TypeScript, combined with a preference for a gateway that remains unobtrusive in normal operation.


Q: How is Conduit scaled horizontally?

A: In the same manner as any stateless reverse proxy: multiple gateway instances run behind a load balancer; each instance runs its own Go process paired with its own Deno runtime pool; plugins are kept stateless, with any genuinely shared state placed in Redis (via ctx.services.cache) or in an upstream service rather than in plugin memory; and pluginPoolSize is tuned to match expected per-instance concurrency, with instance count scaled to manage aggregate throughput. Go scales connection handling; Deno scales concurrent hook execution via its worker pool. Neither layer requires shared memory between separate gateway instances.


Q: What would be done differently in a second iteration?

A: Three specific changes are worth naming honestly: per-route hook timeout policies, so that authentication routes can fail closed on timeout while logging-only routes continue; an optional response-streaming mode that bypasses body capture entirely when no afterResponse plugins are registered for a given route; and hook batching as an opt-in fast path for deployments where plugin count grows large enough that per-hook round trips become a measurable cost.

What would not change: Go retaining exclusive ownership of the network, plugins proposing patches rather than mutating shared state directly, runtime unavailability defaulting to closed, and Deno remaining entirely off the byte-level proxy path.


Q: Summarize the design rationale in a single sentence for a skeptical reader.

A: Conduit uses Go for everything that must scale with connection count and byte volume, uses Deno exclusively for short policy hooks that scale with worker count rather than data volume, and relies on a strict IPC contract so the two runtimes cooperate without pretending to be a single process, because the underlying goal was a gateway that could be changed without a redeploy and forgotten without risk, not a gateway optimized to win a benchmark.


Appendix: Quick Reference

Run locally

bash
go run ./cmd/conduit -config conduit.config.json

Plugin entry point

typescript
export default {
  beforeRequest(ctx) { /* policy */ },
  afterResponse(ctx) { /* transforms */ },
};

Multi-service configuration

json
{
  "listen": ":8080",
  "pluginsDir": "./plugins",
  "pluginPoolSize": 4,
  "routes": [
    { "path": "/chat-service/*",  "upstream": "http://chat-service:2003" },
    { "path": "/user-service/*",  "upstream": "http://user-service:2001" },
    { "path": "/admin-service/*", "upstream": "http://admin-service:2002" }
  ]
}

Further reading