Operations

This project and documentation are work in progress

Example code: src/lib/operations

Now we have entities, we need a way to perform operations on them.

Real world systems will usually have many such actions:

  • getting items from a shopping basket

  • adding items to a shopping basket

  • registering a user

  • cancelling an order

  • publishing an article

  • looking up a user's address

In our architecture, these operations are written as functions in the src/lib/operations folder.

What do operations look like?

Let's take a look at one operation in our sample project - adding a book:

export async function addBook (ctx: Context, bookInput: BookInput): Promise<Book> {
  const {
    backend: { bookStore },
    events
  } = ctx

  const book = await bookStore.add(bookInput)

  await events.onBookAdded({
    bookId: book.id,
    name: book.name
  })

  return book
}

So, the addBook operation:

  • destructures the ctx to get its helper functions

  • adds a book to the storage layer (which could end up being a database, filesystem, HTTP call...)

  • waits for that to complete (using async/await)

  • dispatches a message to an event bus (which could also have any implementation)

  • waits for the event dispatch to finish

  • finally, returns the book

As you can see, all operations in NTA have the following type signature:

type OperationFn <Input, Output> = (ctx: Context, input: Input) => Promise<Output>

Let's go over these.

The context parameter

Operations first take a context, which is how dependencies are passed into the function. Note that as far as reasonably possible, an operation never relies on anything not passed into the function itself (i.e. variables in the closure around it). This is important. It means that all dependencies are passed via the context, which has three benefits:

  1. It's easy to test a function like this.

  2. If context comes from adapters, that means we can swap in and out adapters to create new applications, using the same domain code.

  3. It's easier to keep things like database concerns outside of the operation. In fact we don't even talk about databases in the operation, just the abstract concept of 'storage'.

Benefit no. 1 means that tests are very clean and simple to write. You don't have to do extensive mocking.

Benefit no. 2 means an application written in this architecture to be easily converted from e.g. a REST app speaking to a database to a CLI app speaking to an API.

And benefit no. 3 means that, if you're disciplined, you can separate your concerns.

The input parameter

Operations secondly take an input. Often this will be an object containing all the non-context parameters, and will be specified in the entities. Having an io-ts codec for these types means you can easily validate inputs to your operations.

It's important that operations just take two arguments, because as we'll see, the way contexts are supplied at runtime assumes that all operations have this particular type signature.

Operations should be async

This helps simplify the types and makes it low-effort to retrofit async code to otherwise synchronous functions.

Last updated