594 words, ~3 min read

Backend Web Framework Design Exploration - Part 2

This is the second in a series of articles I am writing in which I do a Design Exploration of a Backend Web Framework with the following constraints.

  • only use functions & data - no classes w/ methods
  • use strong typing (via TypeScript) - no use of any
  • trivially testable via basic unit style state based tests

If you didn't start at the beginning I highly recommend it, Backend Web Framework Design Exploration - Part 1.

Note: Within this article I will include simplified code examples. If however you are interested in the real code & tests I used to do this design exploration you can see them via the commits at my be-wf-design-exploration Git repository.

Dependency Injection for Handlers

Picking up where I left off in the previous article. I need to figure out how this web framework should support injecting dependencies into handlers while still supporting the 3 constraints above.

A very common use case for this would be for a handler to need to interact with a database. This is generally done through a database connection pool. So I am going to use that as an example use case.

Builder Function Technique

In the previous article I used the builder function technique while implementing the router function so that I could give that scope dependencies while keeping the router signature the same (Request) => Route.

I can simply use the same technique here but applied to a handler if it needs a dependency or two like a dbPool or something.

function fooHandler(dbPool: DatabasePool): RouteHandler {
  return (request: Request): Response => {
    let dbConn = dbPool.getConnect();
    let queryResults = dbConn.query("some query");
    ...
    return new Response(...);
  };
}

This changes how I have to use it in the route definitions as we have to pass the dbPool into the fooHandler builder function as follows.

const routes = [
    { pattern: "/foo", handler: fooHandler(dbPool) },
    { pattern: "/bar", handler: barHandler }];

This is functionally just fine. The handler resulting from the fooHandler builder function is trivial to test via state based unit testing. However, if I try to do an end-to-end test lets say as follows.

test("end-to-end request -> response", () => {
  const request: wf.Request = { path: "some/path" };
  const response = process(request, router(routes, notFoundRoute));
  expect(response.status).toBe(200)
});

I would have a big problem because it would be using the real database pool. This is something that I want to be able to stub out in our end-to-end unit tests.

This builder function technique can come in and save the day again. If I apply the concept to building the routes and then use explicit dependency declaration I get something like the following.

function buildRoutes(dbPool: DatabasePool): Routes {
  return [
    { pattern: "/foo", handler: fooHandler(dbPool) },
    { pattern: "/bar", handler: barHandler }];
}

In this case I am not building another function as I have in the past, but instead I am building a collection of routes.

Which now enables the simple unit style testing at the higher end-to-end level while still being able to stub out the dbPool.

test("end-to-end request -> response", () => {
  const request: wf.Request = { path: "some/path" };
  const response = process(request, router(buildRoutes(dbPool), notFoundRoute));
  expect(response.status).toBe(200)
});

The buildRoutes builder function can be used to support other dependencies as well.

Review & Next Up

Looking at things thus far I think design wise it is still in a very good place. This idea of using a builder function to build the routes helps me meet all the constraints. It also provides a natural logical grouping of routes.

For example I could do something like define a builder specific to a particular REST resource and compose its routes together with those of another builder.

const routes = Array().concat(
				buildResourceARoutes(dbConn),
				buildResourceBRoutes(dbConn))
const response = process(request, router(routes, notFoundRoute));

Beyond that it can provide a natural grouping mechanism for dependencies.

const routes = Array().concat(
				buildRoutesNeedingDb(dbConn),
				buildRoutesNeeding(depA, depB, depC))
const response = process(request, router(routes, notFoundRoute));

The above two concat examples don't read the nicest. But that is easy enough to fix with the addition of another function to make composition of routes easier if later on I decide it is worth it.

Next up I am planning on tackeling how I address dependencies that should be constructed fresh for each request.