This article assumes familiarity with fp-ts and/or functional programming with algebraic data types.
UPDATE: I've released a library that packages the approach described below, and solves the problem mentioned at the end: fp-ts-bootstrap
🛫 Application Bootstrapping 🛬
I'm using the term bootstrapping here to refer to the "start-up phase" (and "shut-down phase", as we'll get to) of a piece of software. This is any of the code that runs once, just after booting a system, to ensure that everything needed for the program to do its job is in place. 🐣
This task commonly involves setting up database connection (pools), file handlers, socket bindings, timing intervals, etc.
Managing the complexity of such a task is no easy feat, especially when you consider that:
- resource acquisition could be asynchronous;
- some resources may depend on others;
- acquisition of a resource may fail; and
- a process may eventually want to de-allocate / dispose the resources after it completed the task that needed them.
There are a lot of solutions out there to help you manage this complexity. Typically, you'll find dependency injection frameworks utilizing some form of Inversion of Control. In this space you'll find factories, singletons, decorators, middleware, and any pattern we can throw at it. That's because there are demanding requirements for these tools. Here's my wishlist for a bootstrapping solution:
- 🛬 Resources should be disposed gracefully, so that I don't have to kill my process to restore it to a clean state after consuming a resource.
- 🧑🤝🧑 My resource disposal logic should live nearby my resource acquisition logic.
- 🚦 It should be easy to manage asynchronous acquisition and disposal of resources.
- 🏗 When one resource depends on another, I want to express that dependency in a convenient way, and the system should take care of sequencing acquisition and disposal between the two resources in the correct order.
- 🪂 When acquisition of a resource fails, the resources it depended on should be neatly disposed.
- 📦 The program that consumes my resources should be defined separately from the bootstrapping logic, so that I can run the program with a custom set of resources (for example mocks).
- 🚚 I want to be able to use a "stack" of resources and expand upon it / use it as a resource within another stack.
🧃 Solving it with Monads 😎
fp-ts, a library that caters to functional programming in TypeScript, comes with some micro-abstractions that already solve a few of our needs.
- The Task Monad can help us sequence asynchronous operations, like the "acquisition ➡ consumption ➡ disposal" of resources.
- The Either Monad can model failure. Failure to acquire resources, consume resources, or dispose resources.
- The combination of Task and Either give us the TaskEither Monad which we'll get back to because it comes with a very useful utility for what we're trying to do.
- The Reader Monad is all about managing injected dependencies, so we can use it to model the requirement for service dependencies.
- There's one more Monad we'll use to tie it all together, but I'll keep you in suspense as we build up to it! 👀
Bracket
We'll start with that useful TaskEither utility I mentioned: I was referring to TaskEither.bracket
, of course!
declare const bracket: <E, A, B>(
acquire: TaskEither<E, A>,
use: (a: A) => TaskEither<E, B>,
release: (a: A, e: E.Either<E, B>) => TaskEither<E, void>
) => TaskEither<E, B>
The bracket
function is basically our "acquisition ➡ consumption ➡ disposal" need completely met 💪! Let's look at how we can utilize it. In the example below, I'm wrapping a Node.js HTTP server in a bracket
call that handles its acquisition and disposal. The use
function is where we consume the resource, and in this case we're just delaying the program for 30 seconds so that the resource is "held" for a bit before it's disposed.
import * as HTTP from 'node:http';
import * as E from 'fp-ts/lib/Either';
import * as T from 'fp-ts/lib/Task';
import * as TE from 'fp-ts/lib/TaskEither';
// These are the dependencies of our http server. Eventually,
// we'd want these to live elsewhere in our codebase, but for
// now we'll just define them here.
const port = 3000;
const app = (_: unknown, response: HTTP.ServerResponse) => {
response.writeHead(200);
response.end('hello world\n');
};
// We define the acquisition of our http server as a TaskEither.
// That's a lazy Promise that resolves with an Either.
const acquireServer: TE.TaskEither<Error, HTTP.Server> = () => (
new Promise(res => {
const server = HTTP.createServer(app);
server.once('error', e => res(E.left(e)));
server.listen(port, () => res(E.right(server)));
})
);
// The consumption of our server is just a TaskEither that delays
// the program for 30 seconds. Note that we're not using the
// server here, but we could!
const consumeServer = () => T.delay(30000)(TE.of('done'));
// To dispose of our server, we unbind event listeners and close the server.
const disposeServer = (server: HTTP.Server): TE.TaskEither<Error, void> => (
() => new Promise(res => {
server.removeAllListeners('error');
server.close((e: unknown) => res(
e instanceof Error ? E.left(e) : E.right(undefined)
));
})
);
// We can now use bracket to sequence the acquisition,
// consumption, and disposal of our http server. The
// result is a TaskEither that resolves with the result
// of the consumption.
const program = TE.bracket(
acquireServer,
consumeServer,
disposeServer
);
// Since all of our functions are lazy, we need to run
// the program to get a Promise that resolves with the
// result of the consumption. The Promise only rejects
// in case of an unexpected runtime error. Otherwise, it
// resolves with the Either that represents the success or
// failure of the program.
program().then(E.fold(console.error, console.log), console.error);
🧑💻 If you want to follow along, you can run the code snippet above by saving it to example.ts
, installing fp-ts with npm i fp-ts
, and running it with npx ts-node example.ts
Running the code above will cause our "hello world"-app to run for 30 seconds, during which curl localhost:3000
should output hello world
. After 30 seconds, the server
resource consumption is done, which causes it to be disposed. We know that this cleaned everything up properly because afterwards, the process exits. When there are no remaining resources in use, Node just exits, because there's nothing left to do or to cause any events.
At the end, we see done
in the terminal because that's the value returned from the consumption, and logged on the last line.
The type of our program is Task<Either<Error, string>>
. We can handle asynchronous acquisition, consumption, and disposal of resources through the Task
Monad, handling possible failure along the way with the Either
Monad.
🌗 We're halfway to a solution, but there's a few things missing:
Injecting Dependencies
In our example above, the app
and port
are baked into the acquireServer
function. But if they themselves were resources, we'd want to inject them into acquireServer
instead. We can do that with the Reader Monad:
import * as R from 'fp-ts/lib/Reader';
import {pipe} from 'fp-ts/lib/function';
// We capture the dependencies from earlier in a type.
type Dependencies = {
app: HTTP.RequestListener,
port: number,
};
// And we use the Reader Monad to inject them into our
// acquireServer function. A Reader is really just a standard
// function. We use the Reader type alias to encourage users
// to use the Reader Monad's composition utilities.
const acquireServer: R.Reader<Dependencies, TE.TaskEither<Error, HTTP.Server>> = (
deps => () => new Promise(res => {
const server = HTTP.createServer(deps.app);
server.once('error', e => res(E.left(e)));
server.listen(deps.port, () => res(E.right(server)));
})
);
// For example, here we can use Reader.map to defer the
// injection of the dependencies to the caller of the withServer
// function.
const withServer = pipe(
acquireServer,
R.map(acquire => TE.bracket(acquire, consumeServer, disposeServer))
);
// Now, to define our program, we first need to inject the
// dependencies, and they'll be threaded along to the
// acquireServer function.
const program = withServer({app, port});
The type of withServer
has become Reader<Dependencies, Task<Either<Error, string>>>
. We can now inject dependencies, and threading them along is made easy using the Reader
Monad!
🌖 We're almost there, but there's one more thing missing:
De-coupling the Consumption
The bracket
function takes acquire, consume, and dispose all at the same time. If we want to de-couple the consumption from the acquisition and disposal, we can create a curried a version of bracket
that takes the consume function separately:
const createService = <E, Resource, Output = unknown>(
acquire: TE.TaskEither<E, Resource>,
dispose: (a: Resource) => TE.TaskEither<E, void>
) => (consume: (a: Resource) => TE.TaskEither<E, Output>) => (
TE.bracket(acquire, consume, dispose)
);
We can use createService
to create a withServer
function that takes a consume
function and returns a program
function that can be run to acquire, consume, and dispose the server:
const withServer = pipe(
acquireServer,
R.map(acquire => createService<Error, HTTP.Server, string>(acquire, disposeServer))
);
const program = withServer({app, port})(consumeServer);
You can imagine how one module might export a withServer
function, and another module might import it to create a program
function that uses it.
Tying it Together with the Cont Monad
In the previous step, we curried the bracket
function and created something that doesn't really look like it'll compose nicely. What if we want to use multiple resources in our program? We will find ourselves in a kind of callback hell, and our consumption function is nested inside again:
// Imagine we have these functions:
const program = withLogger({transport})(logger => (
withDatabase({url, logger})(database => (
withCache({database, logger})(cache => (
withServer({app, port, logger})(server => (
consumeServices({database, server, logger, cache})
))
))
))
));
But we missed an opportunity when we curried bracket
! If we look closely at its type, we can see that it actually returns a Cont
Monad. fp-ts
doesn't have a Cont
Monad built in, so we can use fp-ts-cont
:
import * as C from 'fp-ts-cont/lib/Cont';
const createService = <E, Resource, Output = unknown>(
acquire: TE.TaskEither<E, Resource>,
dispose: (a: Resource) => TE.TaskEither<E, void>
): C.Cont<TE.TaskEither<E, Output>, Resource> => consume => (
TE.bracket(acquire, consume, dispose)
);
This new createService
definition has the same type as the old one, but it uses the Cont
type alias to signal that it returns a Cont
Monad which we can use to compose our programs. As with the Reader
type we used earlier, it's just an alias that encourages users to use the Cont
Monad's composition utilities.
One of these composition utilities is the set of functions used fo Do-notation in FP-TS. Using bindTo
and bind
, we can flatten our nested services, combining them into a new service that we'll call withServices
:
const withServices = pipe(
withLogger({transport}),
C.bindTo('logger'),
C.bind('database', ({logger}) => withDatabase({url, logger})),
C.bind('cache', withCache),
C.bind('server', ({logger}) => withServer({app, port, logger})),
);
const program = withServices(consumeServices);
With the new createService
function, the type of our services is now:
type Service<Dependencies, Resource, Output = unknown> = (
Reader<Dependencies, Cont<Task<Either<Error, Output>>, Resource>>
)
🌕 Despite being a pretty simple type (a function that takes dependencies and an async callback), it becomes powerful because we've identified a Monad instance for each of its components. We can use those Monad instances to compose our services together, manage their dependencies, and control their asynchronous flow, all using the same interface. Let's check our requirements:
- 🛬 Resources are disposed gracefully after consumption thanks to
bracket
. - 🧑🤝🧑 Definition of a service with its acquisition and disposal lives in isolation from its consumption, thanks to
createService
. - 🚦 Managing asynchronous resources is easy thanks to
TaskEither
. - 🏗 Building larger services out of several smaller ones is easy thanks to
Cont
, and sequencing of acquisition and disposal is handled automatically. - 🪂 When a service fails to acquire, the whole program fails, and the disposals of all acquired services are run, thanks to how we've built up our program by composing
bracket
calls usingCont
. - 📦 The consumption of my final service is fully decoupled, and I could easily swap consumotion functions, or swap service definitions.
- 🚚 The larger services built using
Cont
are themselves services which could be composed into even larger services, and so on.
Conclusion
I've been using this approach to application bootstrapping in my projects for some time now, and I'm very happy with it. It gives me the flexibility to split up my application into smaller services, and compose them into larger services, all while allowing each service to be used and tested in isolation.
I hope you find this approach useful, and I'd love to hear your feedback!
Here's the full, executable code after making all the changes suggested above:
import * as HTTP from 'node:http';
import * as E from 'fp-ts/lib/Either';
import * as T from 'fp-ts/lib/Task';
import * as TE from 'fp-ts/lib/TaskEither';
import * as R from 'fp-ts/lib/Reader';
import * as C from 'fp-ts-cont/lib/Cont';
import {pipe} from 'fp-ts/lib/function';
const createService = <E, Resource>(
acquire: TE.TaskEither<E, Resource>,
dispose: (a: Resource) => TE.TaskEither<E, void>
) => <T>(consume: (resource: Resource) => TE.TaskEither<E, T>) => (
TE.bracket(acquire, consume, dispose)
);
type Dependencies = {
app: HTTP.RequestListener,
port: number,
};
const acquireServer: R.Reader<Dependencies, TE.TaskEither<Error, HTTP.Server>> = (
deps => () => new Promise(res => {
const server = HTTP.createServer(deps.app);
server.once('error', e => res(E.left(e)));
server.listen(deps.port, () => res(E.right(server)));
})
);
const disposeServer = (server: HTTP.Server): TE.TaskEither<Error, void> => (
() => new Promise(res => {
server.removeAllListeners('error');
server.close((e: unknown) => res(
e instanceof Error ? E.left(e) : E.right(undefined)
));
})
);
const withServer = pipe(
acquireServer,
R.map(acquire => createService(acquire, disposeServer))
);
// Possibly in another module:
const port = 3000;
const app = (_: unknown, response: HTTP.ServerResponse) => {
response.writeHead(200);
response.end('hello world\n');
};
const program = withServer({app, port})(() => T.delay(30000)(TE.of('done')));
program().then(E.fold(console.error, console.log), console.error);
This approach is based on the booture library that I created for Fluture some time ago, with its ideas ported to fp-ts
and fp-ts-cont
.
Hey! You're still here? If you paid a lot of attention, you might have noticed the bit of type-cheating I did. You see, due to the Output
type being embedded in the Service
type, our service consumption isn't truly decoupled from our service definition: The service definition is already deciding what the consumption must return. I'm working on some ideas to fix this. In the meantime, I've been just setting Output
to unknown
, and not using the output after disposal of the service.
UPDATE: fp-ts-bootstrap was released, which solves this!
Top comments (1)
I've updated the text above to reflect the fact that I've released fp-ts-bootstrap. This packages the ideas stipulated above, and solves the problem regarding the Output type.