Build Your Own Bedrock · Part 11
The server fulfills the contract
In Part 9 you made the API contract a runtime value — method, route, and three decoders — that both sides import. Now build the side that fulfills it. The design goal: your business logic should not know a web framework exists. Express is an implementation detail you could swap for Fastify on a Tuesday afternoon without touching a single handler.
A handler is params in, Result out
Here is the entire interface between your domain code and the HTTP layer:
// Api/src/Api/PublicApi.ts
export type PublicHandler<P, E, T> = (params: P) => Promise<Result<E, T>>
// Api/src/Api/Public/Login.ts
export const contract = API.contract
export async function handler(
params: API.BodyParams,
): Promise<Result<API.ErrorCode, API.Payload>> {
const { email, password } = params
const userRow = await UserRow.getByEmail(email)
if (userRow == null) {
return err("USER_NOT_FOUND")
}
const isValidPassword =
await Hash.verify(password.unwrap(), userRow.password)
if (isValidPassword === false) return err("INVALID_PASSWORD")
return ok(await loginPayload(userRow))
} No req, no res, no status codes, no throw. The handler receives already-decoded params — email is an Email, password is a Password, both opaque — and returns a Result whose error side is the contract's typed ErrorCode union. Testing it is calling a function with values and asserting on the returned value. No supertest, no mock request objects.
The adapter
One generic function marries a contract to its handler and mounts the pair onto Express:
// Api/src/Api/PublicApi.ts
export function publicApi<
ApiMethod extends Method,
Route extends string,
UrlParams extends UrlRecord<Route>,
RequestBody,
ErrorCode,
Payload,
>(
app: Express.Express,
api: {
contract: Api<
ApiMethod, Route, UrlParams, RequestBody, ErrorCode, Payload
>
handler: PublicHandler<UrlParams & RequestBody, ErrorCode, Payload>
},
): void {
const { contract, handler } = api
const { method, route, urlDecoder, bodyDecoder } = contract
const expressRoute = removeQuery(route)
const handlerRunner = catchCallback((req, res) => {
const paramsResult = decodeParams(req, urlDecoder, bodyDecoder)
return paramsResult._t === "Ok"
? runPublicHandler(paramsResult.value, handler, res)
: internalErr500(
res,
paramsResult.error,
decoderErrorMessage(req.query, paramsResult.error),
)
})
switch (method) {
case "GET":
app.get(expressRoute, handlerRunner)
break
case "POST":
app.post(expressRoute, handlerRunner)
break
// … DELETE, PATCH, PUT
}
} Everything comes off the contract: the HTTP method picks the Express registration, the route string becomes the Express route (query tokens stripped by removeQuery), and the two request decoders guard the door. The type parameters force the pairing — hand publicApi a handler whose params don't match the contract's UrlParams & RequestBody and it won't compile.
Decode before the handler sees anything
// Api/src/Api.ts
export function decodeParams<UrlParams, RequestBody>(
req: Express.Request,
urlDecoder: JD.Decoder<UrlParams>,
bodyDecoder: JD.Decoder<RequestBody>,
): Result<Annotation, UrlParams & RequestBody> {
const urlResult = fromDecodeResult(
urlDecoder.decode({ ...req.query, ...req.params }),
)
if (urlResult._t === "Err") return err(urlResult.error)
const bodyResult = fromDecodeResult(bodyDecoder.decode(req.body))
if (bodyResult._t === "Err") return err(bodyResult.error)
return ok({ ...urlResult.value, ...bodyResult.value })
} Query params and path params are merged into one record and decoded together — the handler never cares which part of the URL a value rode in on. Note what a decode failure maps to: a 500, not a 400. A client built from the same contract cannot send params that fail this decoder, so a failure here means the contract itself is broken between deployed versions — an internal error by definition, one you want logged loudly.
Every exit is typed
// Api/src/Api/PublicApi.ts
async function runPublicHandler<ErrorCode, Params, Payload>(
params: Params,
handler: PublicHandler<Params, ErrorCode, Payload>,
res: Express.Response<ResponseJson<ErrorCode, Payload>>,
): Promise<void> {
return handler(params)
.then((result) => {
return result._t === "Ok"
? ok200(res, result.value)
: err400(res, result.error)
})
.catch((error) => {
return internalErr500(
res,
error,
internalErrMessage("Handler Uncaught Exception", params, error),
)
})
} The Result maps mechanically onto the wire shape from Part 9: Ok → 200, Err → 400 with the typed code. The response object is typed as Express.Response<ResponseJson<ErrorCode, Payload>>, so even the JSON you write is contract-checked. And because promises still reject and libraries still throw, two nets catch what escapes: .catch here for the handler, and catchCallback wrapping the whole route — Express callbacks swallow synchronous throws, so the try/catch must wrap the function itself. Both nets funnel into internalErr500, which logs the full message under an errorID = md5(errorMessage).slice(0, 9) and sends only { _t: "ServerError", errorID } to the user. The user reports a nine-character code; you grep the logs; no stack trace leaks.
One line per endpoint
// Api/src/Route/User.ts
export function userRoutes(app: Express): void {
publicApi(app, Login)
publicApi(app, RefreshToken)
authApi(app, Logout)
authApi(app, Profile)
authApi(app, UpdateProfile)
} Each endpoint module exports contract and handler; registering it is passing the module. The authApi variant (Api/src/Api/AuthApi.ts) is the same machine with one extra step: it verifies the Bearer token, loads the user row, and calls an AuthHandler that takes authUser as its first argument — your handler receives a proven user, never a token. Count where Express appears: the two adapter files, the shared Api.ts helpers, and the route registrations. No handler imports it. That's the quarantine — the framework serves your functions, not the other way around.
In your language
The pattern is "hexagonal architecture" compressed to one function type: pure core, thin adapter. In Go, write handlers as func(params P) (T, error) and one adapter that decodes the request and switches on the error to pick a status — the handler never touches http.ResponseWriter. In Kotlin with Ktor, handlers return Arrow's Either<E, T> and a single extension function folds it into a response. In Rust, axum already leans this way: handlers take extracted, typed params and return Result, with the framework mapping both arms to responses. Haskell's servant goes furthest — the contract is a type, and the compiler derives the router, rejecting any handler set that doesn't fulfill it.