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.