A sustainable way to create services

This post describes a way to organize your programs to make them easily testable and decoupled from libraries. The main objective of this framework is to empower the developers by reducing the complexity of the programs and delaying technical decisions as much as possible. Decoupling libraries from business logic, and by doing so, creating more maintainable and straight forward implementations of services, systems and applications. It borrows concepts and inspiration from the Hexagonal Architecture.

All the examples will be written in TypeScript, but the concepts and ideas may be reused in any language. Particularly dynamic languages like JS, or Clojure.

Start with an use case

  1. Whenever we receive a message on the message queue, we should store it using the message topic as key.
  2. The service must serve the most recient value of every received message key.

To do so, we create two handlers for our business logic:

// caches every incoming message
async function mqHandler(msg: TopicMessage) {
  await cache.set(msg.topic, msg.content)
}

// returns a cached value received from the MQ
async function httpRequestHandler(request: Request) {
  return new Response(cache.get(request.url.pathname))
}

That should be it, that is the whole business.

This specific example is trivial, but it works to make visible some common patterns that can explode in complexity making the whole service hard to test. In this particular case, testing may e complicated because: it assumes that a cache variable exists somehow in a "global" environment.

To make it a little bit more testable we are going to define simple interfaces for our components.

What are components?

A component is a map of functions tied to a context. In languages supporting classes, it could be an instance of a class. Plain objects with functions also work perfectly.

We are going to define a simple interface for our cache component. At this stage, we are not deciding which type of cache we are using. We only describe a protocol, an interface for it.

interface ICacheComponent {
  get(key: string): Promise<string>
  set(key: string, value: string): Promise<any>
}

Then we will provide the cache the handlers

// caches every incoming message
async function mqHandler(cache: ICacheComponent, msg: TopicMessage) {
  await cache.set(msg.topic, msg.content)
}

// returns a cached value received from the MQ
async function httpRequestHandler(cache: ICacheComponent, request: Request) {
  return new Response(cache.get(request.url.pathname))
}

That IS way more testable. cache is no longer a magical global variable. Now it would be trivial to test it with the tool of choice:

test("should store messages", async function () {
  const cache = createCacheInMemoryMock()
  await mqHandler(cache, { topic: "/hi", content: "test" })
  assert(cache.get("/hi") == "test")
})

test("should serve stored messages", async function () {
  const cache = createCacheInMemoryMock()
  cache.set("/hi", "test123")
  const response = await httpRequestHandler(cache, new Request("/hi"))
  assert(response.body == "test123")
})

// helper function to mock the component
function createCacheInMemoryMock(): ICacheComponent {
  const map = new Map()
  return {
    get(key) {
      return map.get(key)
    },
    set(key, value) {
      map.set(key, value)
    }
  }
}

We have implemented all of our business logic without making any technology decision. There is no mechanism in markdown to emphatize this sentence as much as I'd like, this is huge. The whole business logic is easyly testable and completely decoupled from bikeshedding discussions and libraries. We did not talk about which server we are going to use, we don't know if the MQ is Kafka, AMQP, SQS or UDP messages or messaging pidgeons.

Creating a system

We should be able to create, configure and run components in a seamlessly way while keeping the whole thing testable and maintainable. Often projects explode in complexity because they rely on mountains of constructs and abstraction patterns. While leveraging simple constructs like functions and records may make things way simpler.

To create big systems the proposal goes as follows:

  1. Create a set of well-known interfaces for the components.
    That way, you can defer the responsibilitiy of creating the KafkaComponent or KafkaTestComponent to the "kafka-team" and let them maintain the library and do what they do best.
  2. Put all the components in a typed record and pass it over to all the handlers.
    It works best if you also create specialized types or schemas for every specific handler. We will see an example of that later.
  3. Initialize the components at the begining.
    In my personal experience, this works best in an explicit piece of code, a function, to initialize the components. Otherwise you can leverage tools to do the same i.e. stuartsierra/components.
  4. Wire components together, focus on the business logic.
    Business logic is the most important part of any application or service you will ever create. This framework will remove you the load of dealing with technology decisions and will provide you and your team more time to focus on what matters the most.

1. Create a set of well-known interfaces for the components

There are many approaches to do this, you could either create a library for common interfaces that is shared across your team's projects. Or define them manually. In this example, we are going to define the components in a file called well-known-components.ts

// well-known-components.ts

// caché handling
interface ICacheComponent {
  get(key: string): Promise<string>
  set(key: string, value: string): Promise<any>
}

// a handler to consume queues
interface TopicMessage {
  topic: string
  content: string
}

interface IMessageQueue<C> {
  onMessage(componentBag: C, handler: (components: C, msg: TopicMessage) => void): void
}

// a simple handler for http requests
interface IHttpServer<C> {
  onRequest(componentBag: C, handler: (components: C, msg: Request) => Promise<Response>): void
}

// a configuration provider
interface IConfig {
  requireString(key: string): Promise<string>
  requireNumber(key: string): Promise<number>
}

2. Put all the components in a typed record and pass it over to all the handlers

Keep this record visible and available for other files, it will become handy, it describes all the components required and avaliable for your application

type MyAppComponents = {
  config: IConfig
  cache: ICacheComponent
  mq: IMessageQueue<MyAppComponents>
  httpServer: IHttpServer<MyAppComponents>
  // logger
  // db
  // etc
}

3. Initialize the components at the begining

At the beginning of your application or context (probably testing context) you should create all the components required by your application. Often, some components will depend on eachother, like the config component, it may be used by several other components to handle proper initialization. Or you may need to manually access it to configure i.e. the listening port of an http server.

To do so, there are libraries to resolve graphs or inject dependencies. IMO, simple functions handling the component creation are enough in most cases, and also very easy to debug and trace.

// components.ts

// create the production components
export async function initializeComponents(): MyAppCpmponents {
  // initialize config component using the process environment variables
  const config = createConfigProvider(process.env)

  // initialize HTTP server
  const httpServer = createHttpServer(await config.requireNumber("port"))

  // initialize message queue consumer
  const mq = createMq(await config.requireString("mq_url"))

  // initialize in-memory cache
  const cache = createMemoryCache()
  // createRedisCache(await config.requireString('redis_url'))

  // return all the components for the app
  return {
    config,
    httpServer,
    mq,
    cache
  }
}

4. Wire components together, focus on the business logic

Write the glue code using the components to achieve business results. In the hexagonal architecture these are the "adapters", in MVC "controllers".

This framework is heavily inspired by Hexagonal Architecture, where the components are ports, and the business logic are adapters + core logic. To stick with the Hexagonal Architecture, we are going to call them "adapters".

The adapters connect several components together to achieve an use case. The first examples in this document were the adapters. That is the first piece of code I usually write.

The adapters may recieve a specific set of components instead of the full bag of components. That makes testability easier when using static typing, because there is no need to pass unwanted or unused components to a handler.

// adapters.ts

// subset of components for the mq handler
type MqHandlerComponents = Pick<MyAppComponents, "cache">

// subset of components for the http handler
type HttpHandlerComponents = Pick<MyAppComponents, "cache">

function mqHandler(components: MqHandlerComponents, msg: TopicMessage) {
  // caches every incoming message
  await components.cache.set(msg.topic, msg.content)
}

function httpRequestHandler(components: HttpHandlerComponents, request: Request) {
  // returns a cached value received from the MQ
  return new Response(components.cache.get(request.url.pathname))
}

Finally, some code is needed to create the components and wire the application

// main.ts

// initialize and wire components
function app(components: MyAppComponents) {
  components.mq.onMessage(components, mqHandler)
  components.httpServer.onRequest(components, httpRequestHandler)
}

// main entry point of our application
function main() {
  const components = await initializeComponents()
  app(components)
}

// fail if some error is triggered during initialization
main().catch((err) => {
  console.error(err)
  process.exit(1)
})

Usage in the wild

At Nubank, we had more services than engineering employees, one of the fundamental conditions to achieve it was to leverage other teams' work in libraries at scale. Enabling every team to do one component for the rest of the company (Kafka component, http server, configuration manager, logging, metrics).

We managed to make automated bumps of new versions of the libraries without even asking to the original owners of the services with almost zero outages.

Of course, at Nubank we used Clojure + Pedestal + suartsierra/components. Which is a very different language and stack than Typescript. But I had to explain it to non-clojurians and took the chance to write it down.

I hope you find this useful, thanks for reading