The thousand-foot view

This project and documentation are work in progress

What is architecture?

Architecture is an idea nearly all software engineers recognise but is quite tricky to define. Usually it is described in terms of designing a system to be easy to extend and modify, simple to reason about, and built to last.

A more precise formulation is to say that software architecture is software development with respect to time. Good architecture saves time and invests in the future. Another is that architecture is all the decisions in a software project that are hard to retrospectively change.

Perhaps a more prosaic interpretation is that architecture is all the stuff a programmers has to think about that isn't in their problem domain: how code is structured, its common interfaces, and the scaffold for applying that domain logic.

NTA is fairly circumscribed in scope: it is a project structure and a set of conventions that should hopefully make your Node apps easier to extend and more robust. Because type systems can help prove robustness (providing they are used properly), NTA is oriented around TypeScript. But if you don't want to use TS or are part of a team that isn't able to deploy TS, you can adapt it to plain JavaScript as well.

NTA is far from the only architecture you can choose. It's just my own suggestion, a collection of habits I've gleaned from writing several Node.js web servers and CLI apps. Feel free to pick and choose parts as they suit your needs.

What are NTA's goals?

  • Be friendly to beginners; aim to help those building larger scale Node apps for the first time

  • To separate domain code - which describes the problem - from "infrastructure" code that performs side effects (e.g. reading or writing to a database), so that infrastructure can be easily swapped in and out

  • To ensure there is no direct dependency from domain code to infrastructure code

    • (dependencies from infrastructure to domain code, however, are OK)

  • Wherever feasible, use functional programming techniques over frameworks, containers, or anything else that might be opaque to the reader of the code

  • To make it obvious where to place new code

  • To check the types of data as it enters the system, so that we know our TypeScript types are an accurate representation of the system and its data at runtime

  • To use custom errors to structure exceptions, and make error handling simpler and encourage the use of meaningful exceptions

What is NTA based on?

This project is inspired by the 'onion architecture', also known as the 'clean architecture', 'hexagon architecture' or 'ports and adapters'.

A key idea is that domain code should not directly depend on side effect code. Instead we should use so-called inversion of control to pass side effect functions to the domain code at runtime.

So rather than this...

// In the file addUser.ts

import { addUserSQL } from './sql' // direct import

type UserParams = { name: string }

export function addUser (params: Params): User {
    addUserSQL(params) // direct dependency on infrastructure!
}

We do this...

// In the file addUser.ts

type UserParams = { name: string }
type Context = { userRepository: Storage }

export function addUser (ctx: Context, params: Params): User {
  ctx.userRepository.add(params) // infrastructure is passed in via the ctx param
}

Removing the context boilerplate

But there's a problem. Now as well as 'params' we have to pass in a 'context' when we want to call 'addUser', with all the infrastructure and database attached. We don't want to have to specify that every time. We could introduce inconsistencies. At the very least, it's messy.

Instead, there needs to be a way to inject the parameter when the application starts up, leaving the 'params' to be filled in later. So we get the benefits of inversion of control, but the cleaner interface of a function without IOC.

// We want the function to take parameters like this...
addUser({ database, eventbus }, { name: 'Alice' })

// But we want to be able to call it like this...
addUser({ name: 'Bob' })

The answer is to use higher order functions and partial application to "adapt" the domain function. Here's a simplified example:

function databaseAdapter (addUserFn) {
  const ctx = {
    userRepository: {
      add: addToDatabase
    }
  }

  return async function (params: UserParams) {
    return addUserFn(ctx, params)
  }
}

However, we don't want to write a databaseAdapter for every domain function (addUser, removeUser, etc...). Instead, NTA provides a really simple way to write a general "adapter", that's still type safe due to the way we define function types in our project. Read on to learn more.

One approach to IOC is to use a dependency injection (DI) container. These sometimes have a reputation for being quite bulky, magical, and tricky to work with. Fortunately for us, we don't need one.

But how do you structure a system to do this? How do you inject parameters without a framework? And what should 'clean' domain code even look like?

Let's start with the basics - how should we structure a project?

Last updated