How adapters work

This project and documentation are work in progress

Example code: src/adapters

Adapters are functions that do two things:

  • they create part of the Context used by operations

  • they can wrap operations in their own functions (optional)

In the case of the PostgreSQL adapter, we do both, creating a ctx with database functions, and an op that wraps a given operation in a transaction:

export async function $adapter (): Promise<ContextAdapter> { 
  const pool = createConnectionPool()

  return async function adapter <I, O> (op: Operation<I, O>) {

    const client = await pool.connect()

    return {
      op: (ctx, input: I) => wrapTransaction(client, () => op(ctx, input)),
      ctx: {
        backend: {
          bookStore: {
            add:          withClient(client, Repository.Book.add),
            find:         withClient(client, Repository.Book.find)
          },

          ...etc
        }
      }
    }
  }
}

Let's walk through this code sample:

  • The adapter is wrapped in $adapter - in this codebase a dollar signifies a partially applied / higher order function. So $adapter() returns the function adapter.

  • In the closure of the partially applied function, we create a database connection pool. This will be shared by all invocations of adapter.

  • The adapter is called with an op (operation)

    • Note that if the adapter needed runtime parameters, they would be the second argument

  • The adapter then awaits on a database connection

  • We return a record with two fields:

    • an op that wraps the provided operation in a database transaction;

    • a ctx that exposes functions which ultimately run SQL statements

You'll notice that the context functions are themselves the product of another function called withClient. We'll talk about this further down the page.

Sharing resources between adapter calls

The reason we use a partially applied function called $adapter is because that provides the perfect environment for us to share things between adapter calls. Remember that the adapter is called every time a user request comes in. The more work you can do in the outer closure, the better.

A note on performance

You might wonder about the performance of re-creating the context on every request. Doesn't recreating all those objects and functions create loads of overhead? In practice, however, V8 is pretty smart about reusing things like anonymous functions and structs with a stable shape (provided they come from the same site in the source text).

Node optimisations can mean that certain performance 'tricks' have unexpected effects. For instance, you might be forgiven for assuming that creating a single function and re-calling Function.prototype.bind would be much more efficient than re-constructing the whole anonymous function. Surely, you'd think, doesn't it save a load of allocation? Actually, usually the latter is more performant, both from a runtime and a GC perspective, because it's an optimisation path V8 recognises and can inline away.

For this reason, be sure to accompany any performance tuning you do with actual tests.

How are adapters written?

Ultimately it's up to you how you want to structure your adapters, but NTA does offer a suggestion:

  • write 'core functions' that have dependencies injected, just like operation functions

  • write 'wrapper functions' that can inject those dependencies into the core methods

  • use a folder structure that matches the structure of your adapter context object

For instance, an adapter 'core function' might look like this one, taken from the MemoryDB adapter in the example project:

// Repository.book.find
// Finds a book
export async function find (db: MemoryDB, input: UUID): Promise<Book|null> {
  for (const book of db.books) {
    if (book.id === input) {
      return castBook(book)
    }
  }
  return null
}

Just like with an operation, we don't directly depend on our storage concern. Instead findBook has it injected at a higher level of the adapter. To provide it we create a withDB function:

export function withDB <I, O> (db: MemoryDB, fn: (db: MemoryDB, input: I) => Promise<O>): (input: I) => Promise<O> {
  return function (input: I) {
    return fn(db, input)
  }
}

What is a MemoryDB? It's just a type defined inside the adapter folder itself

export type MemoryDB = {
  books: Set<Book>,
  loans: Set<Loan>,
  users: Set<User>,
}

The advantage of this is that it makes findBook very easy to test:

describe('findBook', () => {
  it('Returns a book of the correct ID', () => {
    const db = { books: new Set() }
    const book = { id: 'the_book' }
    db.books.add(book)
    
    const result = findBook(db, 'the_book')
    expect(result).toBe(book)
  })
})

Then in our adapter code, we can use withDB to do the dependency injection:

export async function $adapter (): Promise<Ctx.ContextAdapter> {
  const db = createDB()
  const backend = {
    bookStore: {
      add:          withDB(db, Repository.Book.add),
      find:         withDB(db, Repository.Book.find)
    },

    loanStore: {
      takeLoan:     withDB(db, Repository.Loan.takeLoan),
      endLoan:      withDB(db, Repository.Loan.endLoan),
      getUserLoans: withDB(db, Repository.Loan.getUserLoans),
      getLoan:      withDB(db, Repository.Loan.getLoan)
    },

    userStore: {
      add:          withDB(db, Repository.User.add),
      remove:       withDB(db, Repository.User.remove),
      find:         withDB(db, Repository.User.find)
    }
  }

  // etc.
}

Last updated