Skip to content

Instantly share code, notes, and snippets.

@jmjf
Created June 7, 2025 12:11
Show Gist options
  • Select an option

  • Save jmjf/e6434b716a94ec7451dbbb9188bb687c to your computer and use it in GitHub Desktop.

Select an option

Save jmjf/e6434b716a94ec7451dbbb9188bb687c to your computer and use it in GitHub Desktop.
Testing ideas for request handler/controller separation
// Testing ideas for request handler/controller separation
// Simulate framework
type RawRequest = {
headers: unknown;
query: unknown;
body: unknown;
}
type RawReply = {
setType: (resType: string) => void;
setCode: (resCode: number) => void;
send: (body: any) => void
}
const reply = {
setType: (resType: string) => console.log('resType', resType),
setCode: (resCode: number) => console.log('resCode', resCode),
send: (body: any) => console.log('body', body),
}
// Common core code
type RequestParts = {
headers?: unknown;
query?: unknown;
body?: unknown;
}
abstract class Controller {
protected abstract execImpl(opts: RequestParts): {resCode: number, resType: string, resBody: unknown};
public execute(req: RequestParts, reply: RawReply): unknown {
const {resCode, resBody, resType} = this.execImpl({ headers: req.headers, query: req.query, body: req.body});
reply.setType(resType);
reply.setCode(resCode);
return reply.send(resBody)
}
// could build methods like replyOk, replyAccepted, etc. for different response codes and types
// but that would require passing reply or maybe putting it in async context and extracting it
}
// Implementation for a specific controller
type RealQuery = {
userId?: string,
petId?: string,
}
class TestController extends Controller {
protected execImpl(opts: { query: RealQuery }) {
const { query } = opts;
const dto = {
customerId: query.userId ?? ''
}
const result = testUC(dto);
return { resCode: 200, resType: 'text/string', resBody: result.value}
}
}
// Implementation for a specific UC
type DTOType = {
customerId: string;
}
function testUC(dto: DTOType) {
console.log('UC customerId', dto.customerId);
return { value: {msg: 'hello', petCount: 1, petTypes: ['dog']} }
}
// Route handler (can make this generic for all route handlers and generate with a factory)
const ctrl = new TestController();
function handler(req: RawRequest, reply: RawReply) {
return ctrl.execute({headers: req.headers, query: req.query, body: req.body}, reply)
}
// simulate framework calling the route handler
handler({headers: {Authorization: 'blah', 'x-special-header': 'something'}, query: {userId: 'fred', something: 'blah'}, body: {}}, reply)
@jmjf
Copy link
Author

jmjf commented Jun 7, 2025

Syntactically, this works. Code runs in TypeBox playground with the following output, as expected.

[LOG]: "UC customerId",  "fred" 
[LOG]: "resType",  "text/string" 
[LOG]: "resCode",  200 
[LOG]: "body",  {
  "msg": "hello",
  "petCount": 1,
  "petTypes": [
    "dog"
  ]
}

This approach lets the controller implementation (TestController) type expected and needed inputs in the function signature, avoiding const query = req.query as ExpectedType type assignments. It assumes requests are processed with some kind of validator to ensure they comply with a schema/spec so the controller implementation doesn't need to validate.

If the request handler adds reply to an async context, helper methods on the abstract Controller could get that reply and use it to set status code, response type, etc., if needed. Or it could have setStatusCode and setContentType methods that accept values from a list of valid values. (Declare a const object with values and use it.) Or maybe it's better to require those values as returns from execImpl, as shown here, so it's harder to forget them.

This approach lets us write a controller, with all the real handler logic, in a way that doesn't care about the framework. To switch between Fastify, Express, raw Node HTTPServer, or any other framework that serves a request and reply with expected parts to a request handler, you just need a different route handler. Since route handlers are generic, they can come from a factory function that takes the controller they need to call.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment