DEV Community

Cover image for Application Bootstrapping with fp-ts
Aldwin Vlasblom
Aldwin Vlasblom

Posted on • Updated on

Application Bootstrapping with fp-ts

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>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

πŸ§‘β€πŸ’» 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});
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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})
      ))
    ))
  ))
));
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>>
)
Enter fullscreen mode Exit fullscreen mode

πŸŒ• 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 using Cont.
  • πŸ“¦ 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);
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
avaq profile image
Aldwin Vlasblom

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.