Assembling apps

This project and documentation are work in progress

So you've

  • created a set of entities to model your domain

  • written operations that describe domain-level actions on those entities

  • defined a 'context' type that operations can depend on for causing side effects

  • written adapters and bindings for your apps

Now we're ready to put it all together.

1. Instantiate the adapters

Call the $adapter functions to create your adapters ready-to-use. This step will reserve any resources used across adapter calls.

For example:

const postgres = await $postgres()
const rabbitmq = await $rabbitmq()

2. Combine the adapters

Remember that a library only takes a single adapter, it's up to us to write and use code that can combine adapters into a single function. Our library exports a mergeAdapters function for this purpose:

// Combine the adapters
const adapters = mergeAdapters(
  postgres,
  rabbitmq
)

3. Pass the adapters to the library

This is done using the createLibrary function:

const library = createLibrary(adapters)

4. Bind the instantiated library

In our example code, we're using an Express HTTP binding:

binding(library)

That's it. Your app is ready.

Going the other way: what happens when a request comes in?

This section is purely optional, but will help you reason about how everything fits together

So what steps execute when the app receives a HTTP request? Let's take POST /book as an example.

An express route handler is invoked

This is defined in our binding layer. It calls castBookInput to check that the incoming POST body is well-formed, then calls book.add on the library that was passed in step 4.

The library function is called

Let's have another look at what the library defined here:

return {
  book: {
    add: wrapAdapter(adapter, Operations.addBook)
  },
  loan: {
    take: wrapAdapter(adapter, Operations.loanBook),
    return: wrapAdapter(adapter, Operations.returnBook)
  },
  ... etc.
}

So lib.book.add is wrapAdapter(adapter, Operations.addBook) - let's remind ourselves what this does:

export function wrapAdapter <I, O, CA> (adapter: CA, operation: Operation<I, O>) {
  // This is the resulting lib function:
  return async function (input: I, adapterParams?: any) {
    const { ctx, op } = await adapter(operation, adapterParams)
    return op(ctx as Context, input)
  }
}

So it's the function returned above at line 3, which takes the operation's input and any params required by the adapters. It calls the adapters to get a context and wrapped operation, then calls the operation with context and input.

The adapter is prepared

We invoke the function returned by mergeAdapters earlier and this gives us a combined context.

If re-creating the context on every request becomes too inefficient, we can rewrite the context to be evaluated 'lazily', e.g. using getters to construct parts of the context tree on demand. Generally this won't be a problem though.

Alternatively we can look to move work out from the 'inner' adapter function into the 'outer' $adapter function that was called in step 1.

The operation is invoked

The operation function here might itself wrap the original operation function - in the case of a PostgreSQL backend, this is the point where we would begin a database transaction, ensuring that any errors mid-operation cause changes to roll back.

The operation uses the context, entity codecs and error constructors to perform side effects, return data and / or throw an exception.

The operation's result is returned

Which then goes back to the original Express binding function. This decides whether the result was a success (resulting in e.g. HTTP 200), an error (resulting in a different code), and how to format the result data to the user.

Last updated