skip to content

The Dismal *Amazing* Thoughts and Adventures of

James Gardner

Modular Fastify - Part 1

Modular Fastify - Part 1

/ 7 min read

Stepping Beyond the Basics

Lately, I’ve been talking about some of the basic stuff you’ll already find in the Fastify documentation. Really, it’s been about show-casing how quick and easy it is to get up and running with Fastify. I haven’t dived much into topics like how to organize your project or different methodologies. You know, I often get a bit frustrated when I come across articles that present simple examples, which don’t relate to the complexities of real-world scenarios. In these posts, I’m aiming to share a bit more about the concepts I’ve found super useful and talk about how I’ve been putting them into practice. As we delve deeper, let’s explore how these concepts apply to different architectural styles, starting with one of the most debated: monolithic architectures.

Monoliths

Monolithic architectures aren’t inherently bad; like anything, they have both advantages and disadvantages. They’re often said to be challenging to scale, and some might argue they’re tough to maintain. However, I believe the maintenance challenges mainly arise from changes that compound existing complexities. In any large codebase, it’s crucial to ensure that any changes you make contribute positively to reusability and maintainability. This is a fundamental principle, although it can sometimes be a source of frustration, particularly when working under tight deadlines. Let’s unpack these complexities and see how Fastify can be effectively utilized in a monolithic setup.

I chose to start with monoliths, primarily because of how simple they are to begin with. In my experience, I’ve seen numerous projects hastily transition to event-driven architectures or adopt them as a quick-fix solution for scaling issues in neglected monolithic systems. It’s a misconception to assume that breaking down a system automatically simplifies it. Let’s start with something simple, that has the flexibility built in to break out as and when needed.

Example: A School Management System

For the purpose of example all of these posts are going to iterate on the same project: a backend system for schools which involves the management of:

  • Students
  • Members of Staff (Administrators, Receptionists and Teachers)
  • A School or Multiple Schools
  • Courses
  • Enrollment

For the stack I’ll use the following:

  • Fastify: With Node 20.x.
  • Postgres with Slonik: I’ll be covering ORMs and the pg library in future posts. I absolutely love Slonik because it lets me write SQL which is great for visibility.
  • Zod: Slonik uses Zod for query result validation and type inference. It makes sense for now to use the same library for route schematics. See: Running With Fastify and Zod.

The goal is to create a modular system that leverages Fastify’s plugin architecture with a focus on domain-driven design. Let’s get the stack installed and set up fastify.

Project Setup

In a folder of your choice:

  npm init -y
  npm install convict convict-format-with-validator fastify fastify-type-provider-zod slonik slonik-interceptor-field-name-transformation zod
  npm install -D @tsconfig/recommended @types/convict tsx typescript
  mkdir src
  touch tsconfig.json
  touch .env

Update your tsconfig.json as follows:

{
	"extends": "@tsconfig/recommended/tsconfig.json",
	"compilerOptions": {
		"outDir": "./build",
		"target": "ES2022"
	}
}

And add the following to the scripts segment in package.json:

 "dev": "tsx watch --env-file=.env src/index.ts",

You’ll also need a running copy of Postgres. See: start a Postgres instance if you’re up for using Docker.

Configuration

I typically use convict to house my configuration. It’s an awesome library that lets me define documented configuration properties with validation. It also plays really nicely with Typescript so I get intellisense whenever I need to reference a property. What I love the most is that you can nest configurations. As you can see below, I’ve given my Postgres properties their own segment so I can either pull out the entire set or parts of it when needed. I can also override properties with environment variables or CLI arguments as and when needed.

Create a config.ts in /src with the following:

import convict from "convict";

convict.addFormat(require("convict-format-with-validator").ipaddress);

const config = convict({
	ip: {
		doc: "The IP address to bind.",
		format: "ipaddress",
		default: "127.0.0.1",
		env: "IP_ADDRESS",
	},
	port: {
		doc: "The port to bind.",
		format: "port",
		default: 3000,
		env: "PORT",
	},
	postgres: {
		host: {
			doc: "database hostname",
			default: "localhost",
			env: "PGHOST",
		},
		port: {
			doc: "database port",
			format: "port",
			default: 5432,
			env: "PGPORT",
		},
		username: {
			doc: "database user",
			default: "postgres",
			env: "PGUSER",
		},
		password: {
			doc: "database user password",
			default: undefined,
			env: "PGPASSWORD",
		},
		databaseName: {
			doc: "database name",
			default: "postgres",
			env: "PGDATABASE",
		},
	},
});

export default config;

Create an Entrypoint

With that out of the way we can create our applications entrypoint in src/index.ts:

import fastify from "fastify";
import { serializerCompiler, validatorCompiler } from "fastify-type-provider-zod";
import { createPool, stringifyDsn } from "slonik";
import { createFieldNameTransformationInterceptor } from "slonik-interceptor-field-name-transformation";
import config from "./config";

const main = async () => {
	const pool = await createPool(stringifyDsn(config.get("postgres")), {
		interceptors: [
			createFieldNameTransformationInterceptor({
				format: "CAMEL_CASE",
			}),
		],
	});

	const app = fastify({
		logger: true,
	});

	app.setValidatorCompiler(validatorCompiler);
	app.setSerializerCompiler(serializerCompiler);

	await app.ready();

	app.listen({
		port: config.get("port"),
	});

	// Kill the pool when the app is shut down.
	app.addHook("onClose", async () => {
		await pool.end();
	});
};

main();

The first thing I do here is setup a connection pool to Postgres using Slonik. The super-useful stringifyDsn helper allows me to take a plain object from my configuration and turn it into a connection string. In addition to this I introduce an ‘interceptor’ that automatically converts fields in my query results to camel case. Again, very useful given that in Postgres, field names are case-insensitive.

Next I create a fastify instance with the additional steps of setting the validator and serializer to use Zod. I’ll be adding the actual type provider later on. Finally, I set the app to listen on a configured port and attach a handler to the ‘onClose’ event which fires when the application is shut down. The handler ensures that the pool I created earlier is closed. That’s it! let’s give it a quick test run:

npm run dev

{"level":30,"time":1705508694499,"pid":37201,"hostname":"iMac.local","msg":"Server listening at http://[::1]:3000"}
{"level":30,"time":1705508694501,"pid":37201,"hostname":"iMac.local","msg":"Server listening at http://127.0.0.1:3000"}

Domain Driven Design

Domain-Driven Design (DDD) is a vast topic, and while I won’t delve into its entirety here, I’ll focus on the aspects that will help me craft a modular and developer-friendly application. An important first concept is the ‘Bounded Context’. a “Bounded Context” in DDD is like a boundary around a specific part of your software system. This boundary defines where certain functionality lives based upon what it addresses. Instead of grouping functionality to form a context, think about encapsulating specific areas of responsibility.

In this case our ‘Bounded Contexts’ could be:

  • Student Management
  • Course Administration
  • School Management
  • Enrollment Management

So we can model our project structure around this:

.
└── src/
    ├── index.ts (entrypoint)
    ├── config.ts
    └── plugins/
        ├── student/
        │   ├── services/
        │   │   └── student.ts
        │   ├── repositories/
        │   │   └── student.ts
        │   ├── schemas/
        │   │   └── index.ts
        │   └── routes.ts
        └── course/
            ├── ...etc
            └── routes.ts

Each bounded context in this example is encapsulated by a plugin. At its simplest, a plugin in fastify is just a function that accepts a fastify instance and does something with it. In this example it just takes the instance and bolts routes on.

export const student =
  async function (fastify) {
    .get('/:uuid', async function (request, reply) {
      // ...
    })
  };

And then register the plugin at the entrypoint we defined previously:

  app.register(students);

Note: Make sure this goes before app.ready();

Conclusion

I’ve quickly demonstrated some of my ideas around structuring and designing a RESTful API and some of the tools I use on a day to day basis. There’s obviously a lot more to this and this wasn’t intended to be a comprehensive guide. I’d encourage you to read through the guides on the Fastify website and doing your own research.

For part 2 I’ll be creating some of the the CRUD functionality for students. I’ll be introducing schemas, repositories and services. Be sure to read my article on the Service Repository Pattern first! Thanks for reading.