Context

This project and documentation are work in progress

Example code: src/lib/context

As discussed before, context is a parameter passed to all operations at runtime, providing implementations for the operation's side effects.

It's in the context module that we make all this fit together.

Defining the Context type

Very simply, a context is a dictionary of objects. Each of these objects concerns a particular capability of the app, and typically each one is provided by its own adapter.

For instance, the community library app context looks as follows:

import { BackendCtx } from './backend'
import { EventsCtx } from './events'

export {
  BackendCtx,
  EventsCtx
}

export type Context = {
  backend: BackendCtx,
  events: EventsCtx
}

So the backend is provided by one adapter, the events by another.

And BackendCtx (used for persistence) looks like this:

export type BackendCtx = {
  bookStore: BookStore,
  loanStore: LoanStore,
  userStore: UserStore,
}

export type BookStore = {
  add:            (b: BookInput)     => Promise<Book>,
  find:           (i: UUID)          => Promise<Book|null>
}

export type UserStore = {
  add:            (u: UserInput)     => Promise<User>,
  remove:         (u: User)          => Promise<void>,
  find:           (i: UUID)          => Promise<User|null>
}

export type LoanStore = {
  takeLoan:       (l: LoanInput)     => Promise<Loan>,
  endLoan:        (l: Loan)          => Promise<Loan>,
  getUserLoans:   (u: User)          => Promise<Loan[]>,
  getLoan:        (b: Book)          => Promise<Loan|null>
}

When the lib is assembled into an app, it will require an adapter that provides an implementation of BackendCtx , for instance a MongoDBAdapter.

The operation and adapter types

You don't have to understand these pieces of code to use them... but it will help you develop a mental model of how an NTA app fits together.

Operations should be familiar by now. They are the functions that do useful things with domain entities:

export type Operation <I, O> = (c: Context, i: I) => Promise<O>

That is, given an Input and an Output type, an Operation takes (Context, Input) and returns a Promise of Output. (We know it will be a promise because all operation functions are async).

The adapter type is new:

export type ContextAdapter <C = Context, P = any> =
  <I, O> (op: Operation<I, O>, params?: P) =>
    Promise<{ ctx: Partial<C>, op: Operation<I, O> }>

This looks a tad complicated (luckily we only need write it once). Let's break it down:

  • [Line 1] A ContextAdapter works on types C and P.

    • C is the context being populated (by default the global Context, but can be overriden)

    • P is an object of any params the adapter needs at runtime (optional)

  • [Line 2] A ContextAdapter will be given an operation and those parameters.

    • The operation has an input and output, hence I and O.

    • The adatper's runtime params are optional, hence the ?: P

  • [Line 3] It will return both a ctx (a partial context, for instance { backend } ) and an op function that equals or wraps the operation passed in

    • The result of an adapter is wrapped in a Promise, because adapter functions are async.

What this means is that we can generate a part of the context on demand, when an operation is requested by a user. The adapter function uses any params to help it along, and populates a field in the Context. It can also wrap the operation function with its own code.

Why would an adapter want to wrap an operation? Consider the case of database transactions. You want a way to start a transaction and either commit or rollback, depending on whether the operation throws an error.

Other use cases include error reporting, logging, and any kind of resource allocation you need to do around the operation.

Why would an adapter need runtime parameters? A use case might be something like transaction logging, where the request is assigned an ID and you want your adapter to pass this on to any internal APIs it calls.

Why do adapters return promises? So they can be written as async functions, making it easy to fit in any non-synchronous function calls.

The mergeAdapter function

So, we have various adapters that can populate different fields on the Context. How can we combine them?

We need a mergeAdapter function. This should return a single adapter that, when called, invokes and combines the results of all the adapters originally passed to it.

Here's an example of mergeAdapters written in a recursive style:

export function mergeAdapters <C = Context, P = any> (...args: ContextAdapter<C, P>[]): ContextAdapter<C, P> {
  // Separate out the first adapter
  const [first, ...rest] = args

  return async function adapter (op, params) {
    // The merged adapter
  
    // Evaluate the first adapter
    const result = await first(op, params)

    // No more? Short circuit
    if (rest.length === 0) {
      return result

    } else {
      // Otherwise: merge the rest and call what comes out (recurses)
      const restResult = await (mergeAdapters(...rest)(result.op, params))
      
      // Now merge the results
      return {
        ctx: {
          ...result.ctx,
          ...restResult.ctx
        },
        op: restResult.op // later adapters wrap ops returned by earlier adapters
      }
    }
  }
}

If you find recursion unfamiliar, take a look at this guide by Brandon Morelli.

What's important is that you can call it with a set of individual adapters to create a larger adapter:

const contextAdapter = mergeAdapters(
  postgres, // provides { backend }
  rabbitmq  // provides { events }
)           // provides { backend, events }

Applying the adapter with wrapAdapter

Now we need a way to combine the merged adapter with an operation. We'll use this at the last stage of defining the library. Enter wrapAdapter:

export function wrapAdapter <I, O, CA extends ContextAdapter> (adapter: CA, operation: Operation<I, O>) {
  // Takes an adapter and operation, and returns a function
  // that takes (operation input) and (adapter params)
  
  return async function (input: I, adapterParams?: any) {
    const { ctx, op } = await adapter(operation, adapterParams)
    return op(ctx as Context, input)
  }
}

Again, let's step through this piece by piece:

  • WrapAdapter is a function that operates on types I, O and CA.

    • I is the input to the operation

    • O is the output of the operation (wrapped in a promise)

    • CA is any ContextAdapter

  • It is given an adapter and an operation

  • It returns a function that takes the input of the operation, and any runtime parameters required by the adapter

  • Internally, when called, the function calls the adapter and creates a context and wrapped operation

  • The wrapped operation is called with the context and input, and the result is returned

This is how our dependency injection works. We use higher order functions to replace the boilerplate of generating and passing context ourselves. To the user of an operation wrapped with wrapAdapter, they seem to just be passing an input and receiving an output. They have no idea about the context.

Now we can put all this together to create the library root. Read on!

Last updated