Backend Web Framework Design Exploration - Part 3
This is the third 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.
Dependencies Constructed per Request
Picking up where I left off in the previous article. I need to figure out how this web framework should support Dependencies that need to be constructed fresh per Request. This is something that most web frameworks support via their dependency injection frameworks. Of course I have to manage this while following the 3 key constraints above.
Previously I had the fooHandler
with a dependency of dbPool
.
export function fooHandler(
dbPool: DatabasePool
): wf.RouteHandler {
return function (request: wf.Request): wf.Response {
return { status: 200, body: dbPool.id };
};
}
I am going to expand on this example with the DatabasePool
by saying that our
fooHandler
also needs another database pool. However, this one needed to be
constructed fresh with each request.
Dependency Injection Frameworks
Looking at all the dependency injection frameworks I have worked with. They are effectively a set of functions that are registered often by type or name. Then at some later point in time you call a dependency injection framework function to instantiate an instance by either a type or name. Often times the registered function is executed only the first time and then memoized as a singleton. Effectively making them global singletons.
Functions as Parameters
Thinking about dependency injection frameworks and the fact that I need
something that can at some later point in time give me back in instance of this
other DatabasePool
. It sounds very much to me like a function that is simply
responsible for knowing how to construct this other DatabasePool
is the key.
In fact it can just be an explicit dependency of the fooHandler
.
This aligns at a high level with how common Dependency Injection frameworks work to some extent while also abiding by the constraints. Specifically the explicit dependency declaration constaint. Which in turn supports the trivial testing requirement.
Applying this concept to a handler might look something like this.
export function fooHandler(
dbPool: DatabasePool,
getOtherDbPool: () => DatabasePool
): wf.RouteHandler {
return function (request: wf.Request): wf.Response {
const otherDbPool = getOtherDbPool();
return { status: 200, body: `${dbPool.id} & ${otherDbPool.id}` };
};
}
With the usage in the routes builder looking as follows:
export function buildRoutes(dbPool: DatabasePool, getOtherDbPool: () => DatabasePool): wf.Routes {
return [
{ pattern: "/foo", handler: fooHandler(dbPool, getOtherDbPool) },
{ pattern: "/bar", handler: barHandler() },
{ pattern: "/health", handler: healthHandler() },
];
}
Maybe use Environment
Some frameworks I have worked with use a concept known as Environments for dependency injection. Basically, it is just a formalized Type that you explicitly pass in as a parameter to your function that houses all your dependency injection dependencies.
The environment for fooHandler
might look something like the following.
class FooEnvironment {
dbPool: DatabasePool;
getOtherDbPool: () => DatabasePool;
}
Then I could use it in place of the parameters of fooHandler
.
export function fooHandler(environment: FooEnvironment): wf.RouteHandler {
return function (request: wf.Request): wf.Response {
const otherDbPool = environment.getOtherDbPool();
return { status: 200, body: `${environment.dbPool.id} & ${otherDbPool.id}` };
};
}
Looking at this I think the real value is that it facilitates grouping of dependencies together. I think this would be valuable for grouping dependencies maybe at a module level. I guess the only other value I would see from this on a handler basis is just to provide a more defined convention for how a user would want get dependencies into the handler. However, I could also just provide examples probably without the need for this.
Review & Next Up
Looking at all of the above. The passing a function as an explicit dependency to get get an instance of a something per request seems like the most direct solution. It also meets all the constraints.
The Environment concept might make more sense if later on I decided that requiring some modularization makes sense and therefore grouping dependencies by module makes sense. But until then I think starting with the function paramater technique makes the most sense.
Both of these techniques enable more precision in terms of instance creation than being bound to a Request. This is because the handler can call the function multiple times.
It is worth recognizing that it is really the builder function technique that is still facilitating all of this. It is just that being able to pass a function as a parameter combined with that approach facilitates this specific use case.
This concept of builder function is used generally to implement the concept of currying in function programming.
Next up is figuring out midddleware & guards and how they might work in this framework.