A boilerplate/starter project for quickly building RESTful APIs using Node.js, Fastify, and Prisma.
This repository contains a Node.js Fastify application that follows a modular architecture. Each business entity is wrapped inside a single module, including routes, controllers, services, and other related components.
β‘οΈ The project is setup to work with node v20, which is gonna be next LTS from 24th October 2023. Node v20 includes native test runner. The project is setup to use the same and doesn't depend on external libraries like jest, mocha, ava etc. for tests. Node v20 also allows to use
await
outsideasync
and the project also uses this feature
The project is organized with a modular architecture to promote separation of concerns and code reusability. The architecture is designed to keep services separate from controllers, enabling better data processing and more maintainable code.
root
βββ prisma/
β βββ [Prisma data handling files]
βββ src/
βββ server.js
βββ app.js
βββ plugins/
β βββ [Plugin files]
βββ utils/
β βββ [Utility files]
βββ modules/
β βββ auth/
β β βββ auth.module.js
β β βββ auth.routes.js
β β βββ auth.service.js
β β βββ auth.controller.js
β β βββ auth.schema.js
β βββ health/
β β βββ health.module.js
β β βββ health.routes.js
β β βββ health.service.js
β β βββ health.controller.js
β β βββ health.schema.js
βββ [Other module directories and files]
-
The project root directory contains two main directories: prisma for data handling (e.g., database schema) and src for the application source code.
-
src
directory contains several files and directories:server.js:
This file is responsible for building the Fastify app and passing the configuration to app.js.app.js:
This file registers all the plugins, decorators, and modules for the Fastify app.plugins/:
This directory contains Fastify plugins used in the application.middlewares/:
This directory contains middleware files used for request processing.utils/:
This directory contains utility files with common functions or helpers.modules/:
This directory contains separate modules for different business entities.-
Each module (e.g., auth, health) in the modules/ directory has the following files:
auth.module.js:
This file represents the module and may contain module-specific configurations or setups.auth.routes.js:
This file contains the route definitions and handlers for the module's routes.auth.service.js:
This file holds the business logic and data processing for the module.auth.controller.js:
This file serves as the controller, handling incoming requests and calling services.auth.schema.js:
This file defines the data schema or models for the module.
-
This project structure organizes the application into separate modules, allowing each module to have its own routes, services, and controllers. The modules promote code modularity, reusability, and separation of concerns, making the codebase more maintainable and scalable.
The decision to separate services from controllers is motivated by several factors:
Modularity: By organizing each business entity as a separate module, the code becomes more modular and easier to manage.
Reusability: Separating services allows them to be reused across multiple controllers, promoting code reusability.
Data Processing: Services can handle complex data processing tasks and interact with multiple services, combining and processing data before sending back responses to clients.
Testability: The separation of concerns makes it easier to write unit tests and mock dependencies for testing services and controllers independently.
- install node v20
nvm install v20.5.0
- use node v20
nvm use 20
- install pnpm
npm i -g pnpm
- install dependencies
pnpm install
- setup Prism
pnpm dlx prisma generate
- run the app
pnpm start
; it will run the app in development - run the unit tests
pnpm test
it uses node.js v20 native test runner
The application contains below routes:
βββ /
βββ fastify-overview-ui/ (don't forget to add the slash)
β βββ * (HEAD, GET)
βββ json-overview-ui (GET, HEAD)
βββ api/v1/auth/login (POST)
βββ health
your-host:port/fastify-overview-ui/
: gives you a nice UI which visualizes everything your has application has like plugins, hooks, decorators etc.
your-host:port/json-overview-ui
: gives json version of everything your has application has like plugins, hooks, decorators etc.
your-host:port/api/v1/auth/login
: an api to play around with the features: decorators, context, plugins, routes, schema-validation etc.
Fastify! or should I call it Pluginify? π
Philosophy:
- Plugin design system
- DRY (Don't repeat yourself)
- Immutability
- Divide & Conquer
- root application instance: controls the standard Node.js `http.Server`` class and sets all the endpoints and the default behavior for every request and response.
- a plugin instance: a child object of the application instance, which shares the same interface. It isolates itself from other sibling plugins to let you build independent components that canβt modify other contexts.
- request: a wrapper of the standard Node.js http.IncomingMessage that is created for every clientβs call.
- reply: a wrapper of the standard Node.js http.ServerResponse and facilitates sending a response back to the user
- hook: a functions that act, when needed, during the lifecycle of the application or a single request and response
- decorator: let you augment the features installed by default on the main components, avoiding code duplication
- parser: responsible for the requestβs payload conversion to a primitive type
You create a new fastify instance first, this will represent the root application, the root in context tree structure.
const serverOptions = {
logger: {
level: 'info',
},
genReqId: () => crypto.randomUUID(),
},
const app = Fastify(serverOptions);
The created fastify instance app
has many useful properties and methods,
// get the application initial configs
app.log.debug(app.initialConfig, 'Fastify listening with the config');
// get details about the server address
const { port } = app.server.address();
app.log.info('HTTP Server port is %i', port);
// adds a new endpoint to the server
app.route(options[, handler])
// adds plugins to the server instance, creating a new server context if needed. This method provides Fastify with encapsulation
app.register(plugin)
// loads all the applications without listening for the HTTP request
app.ready([callback])
// starts the server and loads the application
app.listen(port|options [,host, callback])
// turns off the server and starts the closing flow. This generates the possibility to close all the pending connections to a database or to complete running tasks.
app.close([callback])
app.route({
url: '/hello',
method: 'GET',
handler: function myHandler(request, reply) {
reply.send('world');
},
});
const cats = [];
app.post('/cat', function saveCat(request, reply) {
cats.push(request.body);
reply.code(201).send({ allCats: cats });
});
OR use a shorthand,
app.get('/hello', function myHandler(request, reply) {
reply.send('world');
});
We can make it even shorter using an arrow function,
app.get('/hello', (request, reply) => reply.send('world'));
Important
Using an arrow function will prevent you from getting the function context. Without the context, you donβt have the possibility to use the this keyword to access the application instance. The arrow function syntax may not be a good choice because it can cause you to lose a great non-functional feature:the source code organization!
Tip
Context Tip: Use named function and avoid using arrow functions! It would be best to choose named functions._ In fact, avoiding arrow function handlers will help you debug your application and split the code into smaller files without carrying boring stuff, such as the application instance and logging objects. This will let you write shorter code and make it faster to implement new endpoints. The context binding doesnβt work exclusively on handlers but also works on every Fastify input function and hook, for example!
This is the reason the repository is structured to use named function and avoids the arrow function usage.
When you use named functions, you can the logger using this keyword and other app context:
// register the config at app level
app.decorate('config', { APP_CONFIG: 'some_app_config' })
app.get('/hi', function multi(request, reply) {
this.log.info('this line is executed') π±
this.log.info(this.config.APP_CONFIG) π±
reply.send('bye')
})
- use named function
- don't use multiple
reply.send()
if you need to use in case of conditions i.e if/else, usereturn reply.send()
- you can also just only return from the handler and fastify take care of everything
return data;
-
avoid using
reply.send()
, just return the valuereturn data;
. If you need to have different status code,app.get('/hi', async function multi(request, reply) { reply.code(203); return { msg: 'bye' }; });
One more advantage of not using
reply.send()
is that, you can use the same handler in other handler as well.sync function foo (request, reply) { return { one: 1 } } async function bar (request, reply) { const oneResponse = await foo(request, reply) return { one: oneResponse, two: 2 } } // use case app.get('/foo', foo) app.get('/bar', bar)
-
If you only want to return a promise and don't want to use
await
inside the handler, use thesync handler
; fastify will take care of resolving the promise.
const fs = require('fs/promises');
app.get('/file', function promiseHandler(request, reply) {
const fileName = './package.json';
const readPromise = fs.readFile(fileName, { encoding: 'utf8' });
return readPromise;
});
request.query
returns a key-value JSON object with all the query-string input parameters.request.params
maps the URL path parameters to a JSON object.request.headers
maps the requestβs headers to a JSON object as well.request.body
returns the requestβs body payload. It will be a JSON object if the requestβs Content-Type header is application/json. If its value is text/plain, the body value will be a string. In other cases, you will need to create a parser to read the request payload accordingly.
Reply Component
reply.send(payload)
will send the response payload to the client. The payload can be a String, a JSON object, a Buffer, a Stream, or an Error object. It can be replaced by returning the responseβs body in the handlerβs function.reply.code(number)
will set the response status code.reply.header(key, value)
will add a response header.reply.type(string)
is a shorthand to define the Content-Type header.
Plugin instance is a child component of an application instance.
plugins are the basic building blocks of our application. Some of the most prominent properties are the following:
β’ A plugin can register other plugins inside it.
β’ A plugin creates, by default, a new scope that inherits from the parent. This behavior also applies to its children and so on, although using the parentβs context is still possible.
β’ A plugin can receive an options parameter that can be used to control its behavior, construction, and reusability.
β’ A plugin can define scoped and prefixed routes, making it the perfect router.
At this point, it should be clear that where other frameworks have different entities, such as middleware, routers, and plugins, Fastify has only plugins!.
app.register(
function myPlugin(pluginInstance, opts, next) {
pluginInstance.log.info('I am a plugin instance, children of app');
next();
},
{ hello: 'the opts object' }
);
Important
First of all go through this article to understand plugin and their contexts
These simple lines have just created an encapsulated context: this means that every event, hook, plugin, and decorator registered in the myPlugin scope function will remain inside that context and all its children. Optionally, you can provide an input object as a second parameter to the register function. This will propagate the input to the pluginβs opts parameter. If you move the plugin to another file, this will become extremely useful when sharing a configuration through files.
Fastify creates a new context every time it enters a new plugin. We call these new contexts child contexts. A child context inherits only from the parent contexts, and everything added inside a child context will not be visible to its parent or its siblingsβ contexts. The parent-child annidation level is infinite, and we can have contexts that are children to their parents and parents to their children. The entities that are affected by scoping are:
β’ Decorators
β’ Hooks
β’ Plugins
β’ Routes
By default we can only access/read the data from Parent context when we need it in child context. But we can't share context between siblings or alter a parent context. This is default Fastify behaviour
But we need it, we still can do it using fastify-plugin
package. This comes in handy for more complex plugins, such as ones that handle database connections.
// myplugin.js
const fp = require('fastify-plugin');
async function myPlugin(fastify, _options) {
console.log('myPlugin decorates the parent instance.');
fastify.decorate('myPlugin', 'hello from myPlugin.');
}
module.exports = fp(myPlugin, {
name: 'myPlugin',
fastify: '4.x',
decorators: { fastify: ['root'] },
});
// index.js
const Fastify = require('fastify');
const myPlugin = require('./fp-myplugin.cjs');
const app = Fastify({ logger: true });
app.decorate('root', 'hello from the root instance.');
app.register(myPlugin);
app.ready().then(() => {
console.log('root -- ', app.root);
console.log('root -- ', app.myPlugin);
});
The context of child when wrapped with fp
gets merged into the parent.
Feel free to add yours!
- When to prefer
fastify.decorateRequest()
overfastify.decorate()
? fastify/help#918 - Should we install the dependencies which fastify already has like pino, ajv If we want to customize default behaviour? fastify/help#917
- Clarification on the Impact of Awaiting a Plugin/Decorator fastify/help#919
https://www.youtube.com/watch?v=gltzZjKYK1I
https://www.youtube.com/watch?v=ucatti2b7E4&list=PLAv2aQ9JgGbVAGY0ht6JMoGJ8tF_MJ4fx
https://www.youtube.com/watch?v=g-6Ig8k6Nzc&pp=ygUOZmFzdGlmeSBzaGFwZXM%3D
https://www.youtube.com/watch?v=e0rdy7APH-o
https://www.youtube.com/watch?v=-X84Cq-nsLw
https://www.youtube.com/watch?v=e1jkA-ee_aY&t=1s&pp=ygUOZmFzdGlmeSBzaGFwZXM%3D