developers

Fastify and TypeScript Tutorial: Build a CRUD API

Learn how to use TypeScript to build a feature-complete Fastify API. Tutorial on how to use TypeScript with Fastify to create, read, update, and delete data.

Apr 17, 20251 min read

This tutorial shows you how to build a feature-complete API using Node.js, Fastify, and TypeScript that lets clients perform data operations on resources. Fastify is a high-performance Node.js web framework focused on developer experience and speed. Using TypeScript with Fastify gives you access to optional static type-checking along with robust tooling for large apps and the latest ECMAScript features.

Learn also how to define data models using Prisma, validate data with Zod, handle errors gracefully using

neverthrow
and centralized handlers, implement dependency injection, use modern ID strategies (UUIDv7), and quickly build modular endpoints. As an option, you can also learn how to secure the API using Auth0 (covered in follow-up resources).

What You Will Build

You'll learn how to create and secure a feature-complete Fastify API through hands-on practice. This API will be part of a simple document management full-stack application where users can create and share documents with others. This tutorial will focus on building the API. In contrast, the next tutorial will focus on integrating Auth0 for user authentication to secure access to the client and server applications.

You'll use Prisma with an SQLite database for data persistence, providing a more realistic setup than an in-memory store.

Ensure you have a recent version of Node.js and npm installed; targeting a current LTS release like

v20.x
or newer is recommended. If you need to install them, follow the instructions provided by the Node.js Foundation for your operating system.

This tutorial uses recent versions of Fastify, Prisma, and other libraries. Always check the official documentation for the latest features and potential breaking changes.

Create a Fastify Project

The Fastify CLI is a tool that lets us create a Fastify project that supports TypeScript easily and quickly with sensible defaults and a well-organized project structure.

Run the following command to generate a Fastify project anywhere in your system:

npx fastify-cli generate content-api --lang=typescript

If you see the following prompt, type

y
and press
<Enter>
or
<Return>
to proceed:

Need to install the following packages:
fastify-cli@7.4.0
Ok to proceed? (y)

Once the Fastify CLI completes generating your project, you'll see a list of all the generated files in the terminal, along with commands that you can run to use your Fastify project:

generated README.md
generated .gitignore
generated tsconfig.json
generated src/app.ts
generated test/helper.ts
generated test/tsconfig.json
generated src/plugins/README.md
generated src/routes/README.md
generated test/plugins/support.test.ts
generated test/routes/root.test.ts
generated src/plugins/sensible.ts
generated src/plugins/support.ts
generated src/routes/root.ts
generated test/routes/example.test.ts
generated src/routes/example/index.ts
--> reading package.json in content-api
edited package.json, saving
saved package.json
--> project content-api generated successfully
run 'npm install' to install the dependencies
run 'npm start' to start the application
run 'npm build:ts' to compile the typescript application
run 'npm run dev' to start the application with pino-pretty pretty logging (not suitable for production)
run 'npm test' to execute the unit tests

Make the newly generated project directory

content-api
your current directory:

cd content-api

Install the project dependencies:

npm install

Open the project in your IDE or code editor of choice, and let's take a quick tour of the value and features that this Fastify project gives us out of the box.

Understanding the Fastify Project Structure

The Fastify CLI generates a project that provides a structured starting point that prioritizes clean code, modularity, and ease of maintenance, especially as your application grows in complexity. It provides a solid foundation for building web applications and APIs that favor convention over configuration, largely reducing boilerplate code. Let's break down the core components and their roles.

Core Directories and Files

Root directory:

  • package.json
    : The heart of your Node.js project.
    • Lists dependencies (the libraries your project needs to run, like
      fastify
      ).
    • Lists devDependencies (libraries needed for development, like
      typescript
      ,
      tap
      for testing).
    • Defines scripts (
      npm start
      ,
      npm run dev
      ,
      npm test
      ) for common tasks.
  • tsconfig.json
    : Configures the TypeScript compiler. Using TypeScript adds type safety, which helps catch errors early and improves code clarity.
  • .gitignore
    : Tells Git version control which files/folders to ignore (e.g.,
    node_modules/
    , compiled code,
    .env
    files). Essential for keeping your repository clean.
  • README.md
    : Basic documentation for your project (how to install, run, etc.).

src/
directory (source code):

  • This is where your application code will live.
  • app.ts
    : The module serves as the application entry point.
    • It sets up your Fastify server instance and configures its behavior, which you can extend as you build up your application.
    • It uses the
      @fastify/autoload
      plugin
      to automatically discover and register your routes and plugins based on an opinionated directory structure, eliminating lots of manual
      import
      and
      register
      calls.
  • plugins/
    : This directory contains reusable code modules known as Fastify plugins.
    • The purpose of Fastify plugins is to encapsulate specific functionalities shared across your application, such as connecting to a database, setting up authentication, adding utility functions to the Fastify server instance, or integrating third-party services.
    • The generated project includes two plugins:
      • @fastify/sensible
        : This plugin adds useful utilities to your Fastify instance for HTTP error handling and responses.
      • A plugin that serves as an example of how to create your own reusable plugin.
    • The
      autoload
      plugin used in
      app.ts
      finds and registers the plugins that the files in this directory export.
  • routes/
    : This directory defines your application API endpoints.
    • It organizes the different resources and actions your API exposes using an opinionated file structure:
      • You can create files like
        users.ts
        to define a route.
      • You can also create subdirectories like
        users
        to hold any files related to the business logic of your route. However, you must have a file named
        index.ts
        that exports a function where you define routes using
        fastify.get()
        ,
        fastify.post()
        , etc.
    • autoload
      finds the files in this directory and registers all the defined routes.
    • The generated project includes an example file,
      root.ts
      , that defines the route for the base path (
      /
      ) along with an
      example/
      directory with a sample route.

test/
directory:

  • This directory holds automated tests for your application that help verify that your code works correctly and detect regressions (when a change breaks existing functionality).
  • The Fastify CLI uses the
    node:test
    test running by default along with
    c8
    for code coverage that uses Node.js' built-in functionality.
  • It includes example tests for the default routes and plugins to show you how to write your own.

The Fastify workflow

Let's cover how all these modules come together to give you an efficient and delightful developer experience.

  1. Start: You run
    npm run dev
    or
    npm start
    .
  2. Initialize:
    src/app.ts
    runs, creating the Fastify server instance.
  3. Load Plugins:
    app.ts
    tells
    @fastify/autoload
    to scan the
    src/plugins
    directory. Autoload finds and registers all valid plugins, potentially adding new features or methods to Fastify.
  4. Load Routes:
    app.ts
    tells
    @fastify/autoload
    to scan the
    src/routes
    directory. Autoload finds all route definition files and registers every endpoint defined within them.
  5. Ready: Fastify finishes setting up the routes and starts the HTTP server, listening for incoming requests.

As such, run the following command to get your Fastify server up and running in development mode:

npm run dev

Load Environment Variables

Instead of using hard-coded configuration variables within files throughout your project, you can define them in a central location and load them into modules that need them. Developers working with Node.js commonly define this central location as a hidden file named

.env
, which you can create as follows:

touch .env

Populate the

.env
hidden file with the following variable that defines the port your server can use to listen for requests and the allowed origins for CORS as an example:

PORT=8080
# Example: Allow a client running on these URLs to access your API
CORS_ALLOWED_ORIGINS=http://localhost:3000

⚠️ Immediately, add the following entry at the end of the

.gitignore
file to prevent committing the
.env
file to version control:

.env*

Let's use the

@fastify/env
Fastify plugin to check environment variables and make them type-safe.

Install the package:

npm i @fastify/env

Update the

src/app.ts
file as follows to import and use the
@fastify/env
plugin:

import { join } from "node:path";
import AutoLoad, { AutoloadPluginOptions } from "@fastify/autoload";
import { FastifyPluginAsync, FastifyServerOptions } from "fastify";
import Env from "@fastify/env";

type Envs = {
  PORT: number;
  CORS_ALLOWED_ORIGINS: string;
};

const envOptions = {
  schema: {
    type: "object",
    required: ["PORT", "CORS_ALLOWED_ORIGINS"],
    properties: {
      PORT: {
        type: "number",
      },
      CORS_ALLOWED_ORIGINS: {
        type: "string",
      },
    },
  },
};

export interface AppOptions
  extends FastifyServerOptions,
    Partial<AutoloadPluginOptions> {}
// Pass --options via CLI arguments in command to enable these options.
const options: AppOptions = {};

const app: FastifyPluginAsync<AppOptions> = async (
  fastify,
  opts
): Promise<void> => {
  // Place here your custom code!

  await fastify.register(Env, envOptions);

  const { CORS_ALLOWED_ORIGINS } = fastify.getEnvs<Envs>();

  fastify.log.info(CORS_ALLOWED_ORIGINS);

  // Do not touch the following lines...
};

export default app;
export { app, options };

As we are using TypeScript, we must define an

Envs
type for the shape of our
.env
file. We also need to define a schema within an
envOptions
object that
@fastify/env
can use to validate the values of our env vars at startup time.

We then use the

register
API to add the
@fastify/env
plugin to our Fastify instance, passing the
envOptions
object as its second argument to customize its behavior.

The

register
API follows that pattern, which you'll use throughout this tutorial:

fastify.register(plugin, [options]);

The

@fastify/env
plugin exposes the environment variable in two ways:

  • It adds a
    config
    property to the
    fastify
    instance, which contains the name and values of the content of the
    .env
    file.
    • The default name of this property is
      config
      . However, you can add a
      confKey
      property to the
      envOptions
      object to customize its name. Whatever the value of
      confKey
      is will be the name of this property.
  • It adds a
    getEnvs()
    method to the
    fastify
    instances that returns an object with the content of the
    .env
    file.

The

getEnvs()
approach is the easiest method to use with TypeScript as you can pass it a generic type to add type to the object it returns. Using the
config
approach requires using declaration merging to type the
fastify.config
object properly, which involves more lines of code than the simple
Envs
type definition.

The

fastify.log.info(CORS_ALLOWED_ORIGINS);
line allows you to see the value from the
.env
file logged in the console, validating that the
@fastify/env
plugin is working correctly.

Adding Fastify Security Plugins

When building web applications, you often have a frontend running in the user's browser that needs to communicate with your backend API, potentially running on another domain. For security reasons, browsers enforce the Same-Origin Policy, which restricts webpages from making requests to a different origin (domain, protocol, or port) than their own. This default behavior presents a problem: it blocks legitimate requests from your frontend to your API. To enable this communication, your API server must utilize Cross-Origin Resource Sharing (CORS) headers to explicitly tell the browser which external origins can access its resources, effectively creating a controlled exception to the Same-Origin Policy.

Furthermore, web applications are targets for various common attacks. Malicious actors might try to inject harmful scripts into your pages (Cross-Site Scripting or XSS) or trick users by embedding your site within a malicious one (clickjacking). To help mitigate these threats, servers can send specific HTTP security headers in their responses. These headers act as instructions for the browser, enabling built-in security features like enforcing HTTPS connections (

Strict-Transport-Security
) or preventing browsers from guessing content types (
X-Content-Type-Options: nosniff
). The problem is that manually configuring the right set of these security headers can be complex and easy to get wrong. Therefore, you need a reliable method to apply these essential security headers consistently across your API responses, strengthening its defenses against common vulnerabilities.

Let's use two Fastify plugins to improve the security posture of this application:

  • @fastify/cors
    : Fastify plugin to enable and configure Cross-Origin Resource Sharing (CORS).
  • @fastify/helmet
    : Fastify plugin to secure your app by setting various HTTP headers, mitigating common attack vectors.

Runt the following command to install those plugins:

npm i @fastify/cors @fastify/helmet

Once installed, update the imports section in the

src/app.ts
file to import those two plugins as
Cors
and
Helmet
:

import { join } from "node:path";
import AutoLoad, { AutoloadPluginOptions } from "@fastify/autoload";
import { FastifyPluginAsync, FastifyServerOptions } from "fastify";
import Env from "@fastify/env";
import Cors from "@fastify/cors";
import Helmet from "@fastify/helmet";

Next, update the

app
function in the
src/app.ts
file to register those plugins with their corresponding options:

const app: FastifyPluginAsync<AppOptions> = async (
  fastify,
  opts
): Promise<void> => {
  // Place here your custom code!

  await fastify.register(Env, envOptions);

  const { CORS_ALLOWED_ORIGINS } = fastify.getEnvs<Envs>();

  await fastify.register(Cors, {
    origin: CORS_ALLOWED_ORIGINS.length > 0 ? CORS_ALLOWED_ORIGINS : false,
    methods: ["GET", "POST", "PUT", "DELETE"],
  });
  await fastify.register(Helmet);

  // Do not touch the following lines...
};

The

Cors
plugin uses the parsed
CORS_ALLOWED_ORIGINS
value loaded by the
Env
plugin to restrict access to only the origins defined there. If that value happens to be blank, the default value for
origin
is then
false
, disabling CORS.

The

@fastify/helmet
plugin is a port from the Express.js
helmet
middleware, a collection of 14 small functions that set HTTP response headers. Adding
helmet()
to your Fastify application doesn't include all of these functions but provides you with sensible defaults such as DNS Prefetch Control, Frameguard, Hide Powered-By, HSTS, IE No Open, Don't Sniff Mimetype, and XSS Filter.

Initialize Prisma

Install

@prisma/client
, which is the auto-generated and type-safe database client for Prisma ORM:

npm i @prisma/client

Also, install

prisma
, which is the Prisma command-line interface (CLI) tool used for managing database schema migrations, introspection, and generating the Prisma Client:

npm i -D prisma

Now, initialize Prisma in your project. This will create a

prisma
directory containing your schema definition file and set up the database connection:

npx prisma init --datasource-provider sqlite

This command does two things:

  1. Creates a
    prisma
    directory with a
    schema.prisma
    file. This is where you define your database models.
  2. Creates or updates a
    .env
    file to include a
    DATABASE_URL
    variable pointing to a default SQLite database file, which in this case is
    file:./dev.db
    .

Model Data with Prisma

Before creating any other plugins, let's define the structure of the data for this simple document management application.

Users can read, create, update, and delete documents. Users can also share their documents with others by adding other users as collaborators. As such, we need the following models:

  • User
    model: Represents the users of your application.
    • It stores essential user information, such as user ID, username, and timestamps on when the user record was created and last updated.
    • As you'll use Auth0 later on to secure access to the application, this model also stores the Auth0 ID of the user, which will help you link the internal database record with an external user identity record in the Auth0 layer.
  • Document
    model: Represents the documents created within the application.
    • It stores the document information such as title, content, timestamps on when the user record was created and last updated, who created it, and who can access it as a collaborator.
  • DocumentCollaborator
    model: Acts as a join table to manage the many-to-many relationship between
    Users
    and
    Documents
    for collaboration purposes.
    • It specifies which users have access to which documents, even if they don't own them, and their access level.
    • Collaborators can have different access levels defined via an
      AccessLevel
      enum:
      VIEWER
      and
      EDITOR
      .

You'll define the structure of these resources using Prisma and Zod.

Define the Prisma Schema

Open

prisma/schema.prisma
. This file defines your database schema using the Prisma Schema Language. Modify the file to include a
Document
model:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

enum AccessLevel {
  VIEWER
  EDITOR
}

model User {
  id        String   @id // App-generated UUID v7 primary key
  auth0Id   String   @unique // Auth0 'sub', unique and optional
  username  String   @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  documentsAsOwner        Document[]             @relation("Owner")
  documentsAsCollaborator DocumentCollaborator[]
}

model Document {
  id        String   @id // App-generated UUID v7 primary key
  title     String
  content   String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  ownerId String // Foreign key to User.id (app-generated UUID)
  owner   User   @relation("Owner", fields: [ownerId], references: [auth0Id], onDelete: Cascade)

  collaborators DocumentCollaborator[]

  @@index([ownerId])
}

model DocumentCollaborator {
  documentId String
  document   Document @relation(fields: [documentId], references: [id], onDelete: Cascade)

  userId String // Foreign key to User.id (app-generated UUID)
  user   User   @relation(fields: [userId], references: [auth0Id], onDelete: Cascade)

  accessLevel AccessLevel
  assignedAt  DateTime    @default(now())

  @@id([documentId, userId])
}

Your application code will generate the IDs for models using UUIDv7 values. Prisma supports various ID strategies, such as using autoincrement to create unique IDs. However, generating UUIDs v7 in your application code gives IDs that are time-sortable, have global uniqueness across distributed systems due to precise timestamping, and afford you optimized indexing. Learn more about the benefits of UUIDv7.

Time-sortability is useful for efficiently querying records based on creation order (like recent documents), often without needing a separate index on a

createdAt
column, unlike UUIDv4, which is random. The global uniqueness ensures that IDs generated across different servers or database shards won't collide, which is critical for scalable, distributed architectures, unlike traditional auto-incrementing IDs, which are only unique within a single database instance.

Let's cover how data will flow in the application and how these models relate.

User identification and creation

When a user signs up, your application creates a corresponding

User
record in the database. The application generates its own unique
id
(a UUID v7) for this user record.

For this tutorial part, you'll seed the database with user data that you can use from a client application to test your implementation without any access control or user authentication mechanism. However, once authentication is implemented, you'll obtain a unique identifier from Auth0 (the

sub
claim, such as
auth0|xxxxxxxx
) that you'll store in the
auth0Id
field to quickly look up your users and their data upon login.

Document ownership

Every document must have an owner. The

Document.ownerId
field stores the
auth0Id
of the
User
who owns the document. You'll use the
auth0Id
instead of the
id
for faster lookups from the client application after users log in. The
Document.owner
relation links the
Document.ownerId
field back to the
User.auth0Id
field. This explicitly tells Prisma: "The value in
ownerId
corresponds to the
auth0Id
of a user."

This setup creates a one-to-many relationship: One

User
(identified by
auth0Id
) can own many
Documents
.

Document collaboration

A

User
can collaborate on multiple
Documents
, and a
Document
can have multiple
Users
collaborating on it. This is a many-to-many relationship.

The

DocumentCollaborator
table manages this:

  • Each row represents a single collaboration grant.
  • DocumentCollaborator.documentId
    links to the
    Document.id
    .
  • DocumentCollaborator.userId
    links to the
    User.auth0Id
    . So that the value stored in
    DocumentCollaborator.userId
    must match the
    auth0Id
    of an existing user.
  • DocumentCollaborator.accessLevel
    specifies the permission level:
    VIEWER
    or
    EDITOR
    .
  • The
    @@id([documentId, userId])
    defines a composite primary key, ensuring that a specific user can only have one entry (one access level) per specific document in this table.

Cascading Deletes (
onDelete: Cascade
)

If a

User
is deleted, the
onDelete: Cascade
on the relations triggers the following behavior:

  • Any
    Document
    records they owned (
    Document.owner
    relation) will also be deleted.
  • Any
    DocumentCollaborator
    entries linking them to documents (
    DocumentCollaborator.user
    relation) will also be deleted.

On the other hand, if a Document is deleted, any

DocumentCollaborator
entries associated with that document (
DocumentCollaborator.document
relation) will also be deleted.

The

DocumentCollaborator
model is critical for adding and managing collaborators; however, that functionality is outside the scope of this specific tutorial part.

Apply Schema Changes to Database

Now, synchronize your database schema with your Prisma schema definition. It's best practice to use Prisma Migrate to track schema changes over time, especially as your application evolves.

First, generate the Prisma Client based on your updated schema:

npx prisma generate

This updates the types available in

@prisma/client
based on your models.

Next, create and apply a migration. This command will create an SQL migration file in

prisma/migrations
and apply it to your database. Choose a descriptive name (e.g.,
init
or
add_data_model
). Suppose your database already exists, and the change isn't straightforward (like adding a required column to a table with data). In that case, Prisma will guide you, potentially suggesting a reset, which is often fine in early development or requires manual steps.

npx prisma migrate dev --name init

If Prisma resets the database during

migrate dev
, or if it's the first time setting up, your database will be empty. We need to populate it with initial data.

⚠️ Immediately, add the following entry at the end of the

.gitignore
file to prevent committing your local SQLite database to version control:

prisma/dev.db

Seed the Database

Prisma offers seeding functionality that allows you to consistently re-create the same data in your database, which helps populate your database with data that is required for your application to start.

For the seeding functionality to work, you need to add a

prisma
property in your
package.json
file that, in turn, has a
seed
property with the command Prisma should run to initialize your database with data.

Let's create the script to populate the database with initial documents and generate correct IDs. Let's also add a convenient way to run this script via npm.

First, create a

seed.ts
file under the
prisma
directory:

touch prisma/seed.ts

Then, populate

prisma/seed.ts
with the following content:

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export const seedDatabase = async (): Promise<void> => {
  // Check if we already have users
  const userCount = await prisma.user.count();

  if (userCount === 0) {
    console.log("Database is empty. Seeding initial user and documents...");

    // 1. Create a sample user
    const userAuth0Id = `auth0|demo-user`;
    const user = await prisma.user.create({
      data: {
        id: "0195ed4b-8d27-722f-a570-2ebd00cf9ce8",
        auth0Id: userAuth0Id,
        username: "demo-user",
      },
    });
    console.log(`Created user: ${user.username} (Auth0 ID: ${user.auth0Id})`);

    // 2. Create sample documents owned by the user
    await prisma.document.createMany({
      data: [
        {
          id: "0195ed4b-8d29-7588-ae8d-7305e542c305",
          title: "Building RAG Systems with LLMs",
          content:
            "An introductory guide to Retrieval-Augmented Generation (RAG) patterns for enhancing Large Language Model responses with external knowledge...",
          ownerId: userAuth0Id,
        },
        {
          id: "0195ed4b-8d29-7588-ae8d-76aca6b915c7",
          title: "Fine-Tuning Models: Best Practices",
          content:
            "Key considerations and steps for effectively fine-tuning pre-trained models like Llama 3 or GPT-4 for specific tasks...",
          ownerId: userAuth0Id,
        },
        {
          id: "0195ed4b-8d29-7588-ae8d-78361aa3e97d",
          title: "Evaluating Agent Performance",
          content:
            "Discussion notes on metrics and frameworks for evaluating the performance and reliability of autonomous AI agents...",
          ownerId: userAuth0Id,
        },
      ],
    });
    const documentCount = await prisma.document.count();
    console.log(`Created ${documentCount} documents.`);

    console.log("Database seeded successfully.");
  } else {
    console.log("Database already contains users. Skipping seed.");
  }
};

seedDatabase()
  .then(async () => {
    await prisma.$disconnect();
  })
  .catch(async (e) => {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  });

Then, add the

prisma
section in your
package.json
file:

{
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
  }
}

Using

ts-node
allows the seed script to be written in TypeScript.

Finally, add a

db:seed
script to your
package.json
to make it easier for you to run that command:

{
  "scripts": {
    // Other properties...
    "db:seed": "prisma db seed"
  }
}

Now, run the seed script:

npm run db:seed

This command executes your

prisma/seed.ts
script, populating the
Document
table if it's empty.

Visualize Your Database with Prisma Studio

While developing, seeing the actual data in your database is often helpful. Prisma provides a web application called Prisma Studio to accomplish that task.

You can open Prisma Studio by running the following command in your terminal:

npx prisma studio

Your server doesn't have to be running for this; it connects directly to the DB based on your schema.

This will open a new tab in your web browser with an interface that lets you view and manipulate the data in your connected database: the

dev.db
SQLite file in the
prisma
folder in this case. You can see your models, like
Document
, browse rows, and create or edit data directly.

This is incredibly useful for verifying that your API operations are working correctly or setting up test data quickly.

Add it as a script in

package.json
for easier access:

{
  "scripts": {
    // Other properties...
    "db:seed": "prisma db seed",
    "db:studio": "prisma studio"
  }
}

Now, you can run

npm run db:studio
whenever you want to launch this data visualization tool.

Create a Prisma Fastify plugin

As stated before, Fastify allows you to extend its functionality with plugins. You can create a Fastify Prisma plugin to instantiate and expose the Prisma Client instance to the rest of your application via the

fastify
server instance in
src/app.ts
by defining a
prismaPlugin
.

Create a

prisma.ts
file under the
src/plugins
subdirectory:

touch src/plugins/prisma.ts

Add the following content to that file:

import fp from "fastify-plugin";
import { FastifyPluginAsync } from "fastify";
import { PrismaClient } from "@prisma/client";

declare module "fastify" {
  interface FastifyInstance {
    prisma: PrismaClient;
  }
}

const prismaPlugin: FastifyPluginAsync = fp(
  async (fastify, options) => {
    const prisma = new PrismaClient();

    await prisma.$connect();

    fastify.decorate("prisma", prisma);

    fastify.addHook("onClose", async (instance) => {
      await instance.prisma.$disconnect();
    });
  },
  {
    name: "prismaPlugin",
  }
);

export default prismaPlugin;

The Prisma plugin definition uses TypeScript module augmentation to define a

prisma
property in the Fastify instance interface and define its type as
PrismaClient
.

The plugin starts the database connection using

prisma.$connect()
and also terminates it using the event handler of the Fastify
onClose
hook.

Fastify offers a system of hooks that allow you to listen to specific events in the application or request-response lifecycle. You can register a hook using the

fastify.addHook
method.

Fastify triggers

onClose
event when it invokes the
fastify.close()
method to stop the server, after all in-flight HTTP requests have been completed. It's helpful and ideal to add the
onClose
hook to a plugin that needs a "shutdown" event, for example, to close an open connection to a database, as is our case with this Prisma plugin.

Fastify also offers a decorators API to customize core Fastify objects, such as the server instance and any request and reply objects used during the HTTP request-response lifecycle. You can use the

fastify.decorate()
method to attach any property to core objects, such as functions, plain objects, or native types. In this case, the plugin attaches the
prisma
property to the Fastify server instance to make the Prisma Client available through the instance via
fastify.prisma
.

To register the Prisma plugin, do you need to update the

app
function in the
src/app.ts
file? You don't. Thanks to the
AutoLoad
plugin registered in that file, which loads all plugins defined in the
plugins
subdirectory.

Define Data Schemas with Zod

While Prisma defines our database structure, we need a way to define and validate the shape of data for our API, such as request bodies, parameters, and responses. We also want to ensure our TypeScript code uses accurate types for this data. Instead of maintaining separate interfaces and manual validation logic, we'll use Zod, a TypeScript-first schema declaration and validation library.

Zod allows us to define a schema as a single source of truth, from which we can:

  • Infer TypeScript types.
  • Generate validation logic.
  • Generate JSON Schema for Fastify to optimize request validation and response serialization.

You need the Zod and its JSON Schema generator packages:

  • zod
    : A TypeScript-first schema declaration and validation library. We'll use it as our single source of truth for data shapes and validation rules.
  • zod-to-json-schema
    : Utility to convert Zod schemas into JSON Schema format, which Fastify uses for optimized validation and serialization.

Install them by running the following command:

npm install zod zod-to-json-schema

Now, create the file where our document-related Zod definitions will live:

# Ensure the documents directory exists
mkdir -p src/routes/documents
touch src/routes/documents/document.zod.ts

Open

src/routes/documents/document.zod.ts
and start by importing Zod and the JSON schema generator:

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

Let's follow a series of steps to gradually build up our schemas file.

Define the base input schema

Let's define the core properties and validation rules for data needed when creating or updating a document. For now, the client only needs to provide the

title
and
content
.

// ... imports ...

// Base schema for properties common to create/update inputs
const DocumentInputBaseSchema = z
  .object({
    title: z.string().min(1, { message: "Title must not be empty" }),
    content: z.string().min(1).max(2000),
  })
  .strict(); // Disallow extra properties

The

DocumentInputBaseSchema
definition defines the following validation rules:

  • .string()
    : To define basic types.
  • .min()
    ,
    .max()
    : For string length constraints.
  • .strict()
    : To ensure that any extra properties not defined in this schema will cause validation to fail, preventing unexpected data from entering our system. This is generally good practice as it prevents clients from sending unexpected or potentially malicious extra fields that might be inadvertently processed or stored by your application and helps catch typos in client-side code.

Define the Create Schema

When creating a document, all base properties (

title
,
content
) are required. The
DocumentInputBaseSchema
already defines this, so you can simply alias it:

// ... (imports and DocumentInputBaseSchema)

// Schema for Creating a Document (requires all base properties)
export const CreateDocumentSchema = DocumentInputBaseSchema;

Define the Update Schema

When updating a document, the

title
and
content
fields are optional. You can achieve this by applying Zod's
.partial()
method to the base schema:

// ... (imports and other schemas)

// Schema for Updating a Document (title and content optional)
export const UpdateDocumentSchema = DocumentInputBaseSchema.partial();

Define the Output Schema

Now, let's define the shape of the data you want our API to return. This includes the document details along with information about its owner. Let's define a simple

UserOutputSchema
schema for nesting document owner data in the API response:

// ... (imports and other schemas)

const UserOutputSchema = z
  .object({
    id: z.string().uuid(), // Assuming internal ID is UUID
    username: z.string(),
  })
  .strict();

const DocumentOutputSchema = z
  .object({
    id: z.string().uuid({ message: "Invalid UUID format" }),
    title: z.string(),
    content: z.string(),
    // Keep dates as strings for JSON serialization consistency
    createdAt: z
      .string()
      .datetime({ message: "Invalid createdAt ISO date format" }),
    updatedAt: z
      .string()
      .datetime({ message: "Invalid updatedAt ISO date format" }),
    owner: UserOutputSchema.optional(), // Include the nested owner object
    // TODO: Add collaborators schema later if needed
  })
  .strict();

You use

z.string().uuid()
to validate the format of our internal ID. Once again, you use
.strict()
to ensure only defined properties are sent out.

Infer TypeScript Types

One of Zod's major benefits is inferring static TypeScript types directly from the schemas, replacing the need for manually defining interfaces:

// ... (imports and schemas)

// Infer TypeScript types from Zod schemas
export type CreateDocumentInput = z.infer<typeof CreateDocumentSchema>;
export type UpdateDocumentInput = z.infer<typeof UpdateDocumentSchema>;
export type DocumentOutput = z.infer<typeof DocumentOutputSchema>;

You can infer types for creating and updating documents, as well as for the API output. Now, you can import these types into our service and router layers, and they will always be synchronized with our validation schemas.

Generate JSON Schema for Fastify

Fastify performs best when validating requests and serializing responses using standard JSON Schema. We can generate this automatically from our Zod schemas using the

zod-to-json-schema
library.

At the bottom of the

src/routes/documents/document.zod.ts
file, define the schemas needed for different route parts (such as
params
,
body
, and
response
) and export them in a convenient object:

// ... (imports, Zod schemas, inferred types)

// Schema for route parameters expecting an ID
const IdParamSchema = z.object({ id: z.string().min(1) });

// Exported object containing Fastify-compatible JSON Schemas
export const jsonSchemas = {
  // Schema for GET / (response is an array of Documents)
  getAllDocuments: {
    response: {
      200: zodToJsonSchema(z.array(DocumentOutputSchema), {
        name: "DocumentArrayResponse",
        errorMessages: true,
      }),
    },
  },
  // Schema for GET /:id (params has id, response is single Document)
  getDocument: {
    params: zodToJsonSchema(IdParamSchema, { name: "IdParams" }),
    response: {
      200: zodToJsonSchema(DocumentOutputSchema, { name: "DocumentResponse" }),
    },
  },
  // Schema for POST / (body is CreateDocumentInput, the response is single Document)
  createDocument: {
    body: zodToJsonSchema(CreateDocumentSchema, { name: "CreateDocumentBody" }),
    response: {
      201: zodToJsonSchema(DocumentOutputSchema, { name: "DocumentResponse" }),
    },
  },
  // Schema for PUT /:id (params has id, body is UpdateDocumentInput, response is single Document)
  updateDocument: {
    params: zodToJsonSchema(IdParamSchema, { name: "IdParams" }),
    body: zodToJsonSchema(UpdateDocumentSchema, { name: "UpdateDocumentBody" }),
    response: {
      200: zodToJsonSchema(DocumentOutputSchema, { name: "DocumentResponse" }),
    },
  },
  // Schema for DELETE /:id (params has id, No body validation needed, response is empty 204)
  deleteDocument: {
    params: zodToJsonSchema(IdParamSchema, { name: "IdParams" }),
    response: {
      204: { type: "null" },
    },
  },
};

We use

zodToJsonSchema()
to convert our Zod definitions into the format Fastify expects for its
schema
option in route definitions. This enables automatic request validation and highly optimized response serialization.

With

document.zod.ts
complete, we have a single, reliable source for defining, validating, and typing our
Document
data throughout the application.

Build the Service Layer

The service layer encapsulates the core business logic for interacting with the

Document
resources. It will handle database operations via Prisma and manage predictable error conditions using
neverthrow
.

Let's define a model to help us make error handling effective before defining the service's business logic.

Define strategy for error handling

In typical JavaScript or TypeScript development, handling operations that might fail often relies on

try...catch
blocks or functions returning potentially
null
or
undefined
values. This approach can introduce subtle bugs, as it's easy to forget to check for these error conditions, leading to unexpected runtime errors like
"Cannot read property 'x' of undefined"
. The problem is that the possibility of failure isn't explicitly encoded in the function's return type, making error handling implicit and reliant on developer discipline or runtime checks.

neverthrow
addresses this by introducing a
Result
type, similar to patterns found in languages like Rust. Instead of throwing exceptions or returning ambiguous values like
null
, functions return either an
Ok
value containing the successful result or an
Err
value containing specific error information. This structure forces you, the developer, to explicitly handle success and failure paths wherever you use the result.

neverthrow
makes error handling explicit, type-safe, and predictable, moving potential runtime errors to compile-time checks and leading to more robust and maintainable code by ensuring that all possible outcomes of an operation are properly considered.

We'll use

neverthrow
to handle the errors of the service layer of this Fastify application. Run the following to install the dependency:

npm install neverthrow

In the next sections, you'll see the value proposition of

neverthrow
in action.

To decouple our service layer from HTTP details and provide clear, predictable error types, we'll define specific service error codes and a structure for service errors.

Create a

src/models
directory if it doesn't exist:

mkdir -p src/models

Create the file

src/models/service-error.model.ts
:

touch src/models/service-error.model.ts

Add the following enum and interface to define the structure for errors originating from the service layer:

export enum ServiceErrorCode {
  NotFound = "NOT_FOUND",
  Conflict = "CONFLICT",
  InternalError = "INTERNAL_ERROR",
  // Add more specific codes as needed (ValidationError, Unauthorized, etc.)
}

export interface ServiceErrorModel {
  message: string;
  code: ServiceErrorCode;
}

The

ServiceErrorCode.Conflict
can represent the scenario where a unique constraint violation happens, which the router can communicate to the client using an
HTTP 429
status code. The
ServiceErrorCode.InternalError
is a catch-all value for unexpected issues, which can prompt the router to reply with an
HTTP 500
status code. You can extend these service error codes to include values that communicate issues related to validation, authentication, and any other service layer tasks.

The

ServiceErrorModel
interface defines the shape of errors returned by the service layer we'll build in the next section to represent predictable failures.

Generating unique IDs

Let's use

uuid
to generate a unique identifier following the UUIDv7 format. Run the following command to install the package:

npm i uuid

Build service to perform CRUD operations

Create the Service File and Factory Structure

First, create the service file within the

src/documents
directory:

touch src/routes/documents/document.service.ts

You'll use a factory pattern. This is a function that takes dependencies (like a logger and Prisma client) and returns an object containing the actual service methods. This makes dependency injection and testing easier.

Add the basic factory structure to

src/routes/documents/document.service.ts
:

import { FastifyBaseLogger } from "fastify/types/logger";
import { PrismaClient } from "@prisma/client";

type DocumentWithOwner = Prisma.DocumentGetPayload<{
  include: { owner: true };
}>;

interface CreateDocumentServiceParams {
  logger: FastifyBaseLogger;
  prisma: PrismaClient;
}

export const createDocumentService = (params: CreateDocumentServiceParams) => {
  const { logger, prisma } = params;

  const errorPrefix = "[Document Service Error]";
  const infoPrefix = "[Document Service]";

  // Service methods (findAll, find, create, update, remove) will be defined here

  // Expose public methods
  return {
    // Methods will be added here
  };
};

export type DocumentService = ReturnType<typeof createDocumentService>;

After bringing the required packages, you define a type

DocumentWithOwner
that includes the owner relation to ensure that when you return a document, it contains user metadata about its owner.

Then, you define the structure of the service using a factory function,

createDocumentService
. This function takes as parameters an instance of the Fastify
logger
object and the Prisma client to apply a dependency injection pattern that will make the service easier to test.

You define an error and info prefix to namespace service logs and create some comments that can help you later understand where you need to add methods and values to the service.

Finally, you proactively create and export the type of the returned service object as

DocumentService
for type safety elsewhere.

Add Necessary Imports

The service needs access to several other tools: Zod types for input/output, Prisma types and the client,

neverthrow
for error handling, the custom error model (
ServiceErrorModel
,
ServiceErrorCode
), UUID generator, and Fastify's logger type. Add these imports at the top of the
src/routes/documents/document.service.ts
file:

import { CreateDocumentInput, UpdateDocumentInput } from "./document.zod";
import { Prisma, PrismaClient } from "@prisma/client";
import { err, ok, Result } from "neverthrow";
import { FastifyBaseLogger } from "fastify/types/logger";
import { v7 as uuidv7 } from "uuid";
import {
  ServiceErrorCode,
  ServiceErrorModel,
} from "../../models/service-error.model";

// ... (rest of the factory function structure)

Implement

findAll

Let's add the method to retrieve all documents for a given user context. Inside the

createDocumentService
function, before the
return
statement, add the
findAll
function:

export const createDocumentService = (params: CreateDocumentServiceParams) => {
  const { logger, prisma } = params;
  // ... errorPrefix, infoPrefix ...

  /**
   * Retrieves all documents from the database owned by a specific user.
   * @param ownerId - The ID of the user context (provided by the caller, e.g., router).
   * @returns Result containing an array of documents or a ServiceErrorModel.
   */
  const findAll = async (
    ownerId: string // Service expects the owner context ID
  ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => {
    try {
      const documents: DocumentWithOwner[] = await prisma.document.findMany({
        where: { ownerId }, // Use the provided ownerId for filtering
        include: {
          owner: true,
        },
      });

      return ok(documents);
    } catch (error) {
      logger.error(
        { err: error, ownerId },
        `${errorPrefix} Failed to fetch documents for owner`
      );

      return err({
        code: ServiceErrorCode.InternalError,
        message: "Failed to find documents",
      });
    }
  };

  // Expose public methods
  return { findAll };
};

The

findAll
method retrieves all documents along with their related owner information by calling the
findMany()
method on the
prisma.document
model to fetch all records from the
Document
table. The
include: { owner: true }
parameter tells Prisma to also fetch the related owner record for each document through their relationship. The result is stored in the
documents
variable, an array of type
DocumentWithOwner[]
containing all document objects with their properties plus the complete owner object nested inside each document.

In the current setup, the

ownerId
passed into it originates from the simulated context in the router. Later on, that
ownerId
should come from the token or session of an authenticated user.

The method wraps the result (or caught error) in

neverthrow
's
ok
or
err
helpers. The return type
Promise<Result<DocumentWithOwner[], ServiceErrorModel>>
indicates it returns either an array of the
DocumentWithOwner
type on success or a
ServiceErrorModel
on failure.

You update the return object from the

createDocumentService
function to include
findAll
.

Implement

find
by ID

Next, add the method to find a single document by its unique ID, ensuring it belongs to the user context provided. Add the

find
function inside
createDocumentService
before the
return
. This handles the expected "not found" case as a predictable error.

export const createDocumentService = (params: CreateDocumentServiceParams) => {
  // ... logger, prisma, prefixes ...

  const findAll = async (
    ownerId: string
  ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => {
    /* ... */
  };

  /**
   * Retrieves a document by ID, ensuring it belongs to the specified user context.
   * @param id - The ID of the document to fetch.
   * @param ownerId - The ID of the user context (provided by the caller).
   * @returns Result containing the document or a ServiceErrorModel.
   */
  const find = async (
    id: string,
    ownerId: string // Service expects the owner context ID
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
    try {
      const document: DocumentWithOwner | null =
        await prisma.document.findUnique({
          where: { id, ownerId }, // Use both id and the provided ownerId
          include: {
            owner: true,
          },
        });

      if (!document) {
        logger.warn({ id, ownerId }, `${errorPrefix} Document not found`);

        return err({
          code: ServiceErrorCode.NotFound,
          message: `Document with ID ${id} not found for this owner`,
        });
      }

      return ok(document);
    } catch (error) {
      logger.error(
        { err: error, id, ownerId },
        `${errorPrefix} Failed to fetch document for owner`
      );

      return err({
        code: ServiceErrorCode.InternalError,
        message: `Failed to find document with ID ${id}`,
      });
    }
  };

  // Expose public methods
  return { findAll, find }; // Update the return object
};

The

find
method uses Prisma's
findUnique()
method, searching for a document that matches both the provided
id
and the
ownerId
from the user context.

If

findUnique
returns
null
, you treat that as a predictable
NotFound
error and return an
err
result with the appropriate
ServiceErrorCode
.

You also update the return object from the

createDocumentService
function to include
find
.

Implement

create
with ID Generation

You need a method that takes the validated input data, generates the internal UUID, and saves the new document using the

ownerId
provided by the caller (the router).

Add the

create
method inside
createDocumentService
after the
find
method definition:

export const createDocumentService = (params: CreateDocumentServiceParams) => {
  // ... logger, prisma, prefixes ...

  const findAll = async (
    ownerId: string
  ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => {
    /* ... */
  };

  const find = async (
    id: string,
    ownerId: string
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
    /* ... */
  };

  /**
   * Creates a document with a generated UUIDv7 for a specific user.
   * @param data - The document data (title, content). Expected to include ownerId provided by the caller.
   * @returns Result containing the created document or a ServiceErrorModel.
   */
  const create = async (
    // Service expects data object containing title, content, and ownerId
    data: CreateDocumentInput & { ownerId: string }
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
    const id = uuidv7();

    try {
      const newDocument: DocumentWithOwner = await prisma.document.create({
        // Use title, content, and ownerId from the data object
        data: {
          id,
          title: data.title,
          content: data.content,
          ownerId: data.ownerId,
        },
        include: {
          owner: true,
        },
      });

      logger.info(
        {
          documentId: newDocument.id,
          title: newDocument.title,
          ownerId: newDocument.ownerId, // Log the ownerId used
        },
        `${infoPrefix} Document created successfully`
      );

      return ok(newDocument);
    } catch (error) {
      // Catch block for Prisma P2002 (Unique constraint violation)
      if (
        error instanceof Prisma.PrismaClientKnownRequestError &&
        error.code === "P2002"
      ) {
        logger.warn(
          { title: data.title, ownerId: data.ownerId, error: error.meta },
          `${errorPrefix} Failed to create document due to unique constraint violation.`
        );
        return err({
          code: ServiceErrorCode.Conflict,
          message: `Document creation failed: A document with similar properties might already exist for the user.`,
        });
      }
      logger.error(
        { err: error, title: data.title, ownerId: data.ownerId },
        `${errorPrefix} Failed to create document`
      );
      return err({
        code: ServiceErrorCode.InternalError,
        message: "Failed to create document",
      });
    }
  };

  // Expose public methods
  return { findAll, find, create };
};

This

create
method generates the unique
id
using
uuidv7()
. It expects the caller (the router) to provide an input object containing
title
,
content
, and the
ownerId
representing the user context. It uses these values in
prisma.document.create()
. It specifically catches Prisma's
P2002
error code
for unique constraint violations, returning a
Conflict
error.

As before, you add the return object from the

createDocumentService
function to include
create
.

Implement

update
by ID

You need a method that updates a document found by its ID, accepting partial data and the

ownerId
passed by the caller (router) to ensure the update is only performed if the document belongs to that user context. It also catches the "record not found" error (
P2025
) from Prisma.

Add the

update
function inside
createDocumentService
after the
create
method.

export const createDocumentService = (params: CreateDocumentServiceParams) => {
  // ... logger, prisma, prefixes ...

  const findAll = async (
    ownerId: string
  ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => {
    /* ... */
  };
  const find = async (
    id: string,
    ownerId: string
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
    /* ... */
  };
  const create = async (
    data: CreateDocumentInput
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
    /* ... */
  };

  /**
   * Updates an existing document by ID, ensuring the user context owns the document.
   * @param id - The ID of the document to update.
   * @param data - Update data (optional title/content) including the ownerId from the user context.
   * @returns Result containing the updated document or a ServiceErrorModel.
   */
  const update = async (
    id: string,
    data: UpdateDocumentInput & { ownerId: string }
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
    const { ownerId, ...updateData } = data;

    try {
      const updatedDocument: DocumentWithOwner = await prisma.document.update({
        where: { id, ownerId },
        data: updateData,
        include: {
          owner: true,
        },
      });

      logger.info(
        {
          documentId: updatedDocument.id,
          title: updatedDocument.title,
          ownerId: updatedDocument.ownerId,
        },
        `${infoPrefix} Document updated successfully`
      );

      return ok(updatedDocument);
    } catch (error) {
      if (
        error instanceof Prisma.PrismaClientKnownRequestError &&
        error.code === "P2025" // Record to update not found (or ownerId didn't match)
      ) {
        logger.warn(
          { id, ownerId },
          `${errorPrefix} Document not found for update`
        );
        return err({
          code: ServiceErrorCode.NotFound,
          message: `Document with ID ${id} not found for this owner`,
        });
      }
      // Catch block for Prisma P2002 (Unique constraint violation) during update
      if (
        error instanceof Prisma.PrismaClientKnownRequestError &&
        error.code === "P2002"
      ) {
        logger.warn(
          { id, data: updateData, ownerId: ownerId, error: error.meta },
          `${errorPrefix} Failed to update document due to unique constraint violation.`
        );
        return err({
          code: ServiceErrorCode.Conflict,
          message: `Document update failed: The changes conflict with another existing document.`,
        });
      }

      logger.error(
        { err: error, id, ownerId },
        `${errorPrefix} Failed to update document`
      );

      return err({
        code: ServiceErrorCode.InternalError,
        message: `Failed to update document with ID ${id}`,
      });
    }
  };

  // Expose public methods
  return { findAll, find, create, update };
};

The

update
method uses
prisma.document.update()
. The
where
clause uses both the document
id
and the
ownerId
provided by the caller (the router). This ensures that the update only proceeds if a document with that
id
exists and it belongs to the specified
ownerId
. It catches the
P2025
error code, mapping it to the
NotFound
service error.

The return object from the

createDocumentService
function includes
update
as well.

Implement

remove
by ID

Finally, let's implement the deletion logic. It requires both the document

id
and the
ownerId
from the caller (router) to ensure the correct document is deleted. It handles the
P2025
"not found" error.

Add the

remove
function inside
createDocumentService
after the
update
method definition:

export const createDocumentService = (params: CreateDocumentServiceParams) => {
  // ... logger, prisma, prefixes ...

  const findAll = async (
    ownerId: string
  ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => {
    /* ... */
  };
  const find = async (
    id: string,
    ownerId: string
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
    /* ... */
  };
  const create = async (
    data: CreateDocumentInput & { ownerId: string }
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
    /* ... */
  };
  const update = async (
    id: string,
    data: UpdateDocumentInput & { ownerId: string }
  ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => {
    /* ... */
  };

  /**
   * Deletes a document by ID, ensuring the user context owns the document.
   * @param id - The ID of the document to delete.
   * @param ownerId - The ID of the user context (provided by the caller).
   * @returns Result containing void or a ServiceErrorModel.
   */
  const remove = async (
    id: string,
    ownerId: string
  ): Promise<Result<void, ServiceErrorModel>> => {
    try {
      await prisma.document.delete({
        where: { id, ownerId },
      });

      logger.info(
        { documentId: id, ownerId },
        `${infoPrefix} Document deleted successfully by owner`
      );

      return ok(void 0);
    } catch (error) {
      if (
        error instanceof Prisma.PrismaClientKnownRequestError &&
        error.code === "P2025" // Record to delete not found (or ownerId didn't match)
      ) {
        logger.warn(
          { id, ownerId },
          `${errorPrefix} Document not found for deletion`
        );
        return err({
          code: ServiceErrorCode.NotFound,
          message: `Document with ID ${id} not found for this owner`,
        });
      }

      logger.error(
        { err: error, id, ownerId },
        `${errorPrefix} Failed to delete document`
      );
      return err({
        code: ServiceErrorCode.InternalError,
        message: `Failed to delete document with ID ${id}`,
      });
    }
  };

  // Expose public methods
  return { findAll, find, create, update, remove };
};

The

remove
method uses
prisma.document.delete()
. Similar to
update
, the
where
clause uses both the
id
and the
ownerId
provided by the caller (router) to ensure the correct document is targeted. It returns
ok(void 0)
on success and handles the
P2025
error if the document doesn't exist or isn't owned by the specified user context.

The returned object from the

createDocumentService
function now includes
remove
to have all the methods related to CRUD operations on the document resource.

With these steps, the service layer (

document.service.ts
) is complete. Its methods accept the necessary
ownerId
to perform context-aware operations, ready to be called by the router, which will supply this context (initially simulated, later from authentication).

Create a Custom Plugin For Services

Earlier, we created a

prismaPlugin
that handles the database connection nicely. But where should we create our
DocumentService
? We could technically do it right in
src/app.ts
, but that file's already coordinating quite a bit. Plus, as our app grows, we could have more services, such as
UserService
or
OrderService
, and dumping them all in
src/app.ts
would get messy fast.

A much cleaner, Fastify-idiomatic way is to create another plugin specifically for your services. This keeps things organized and lets you manage dependencies. This services plugin will:

  1. Depend on our
    prismaPlugin
    so we know
    fastify.prisma
    is ready when we create it.
  2. Create the
    DocumentService
    instance using
    fastify.prisma
    and
    fastify.log
    .
  3. Decorate the
    fastify
    instance with our service (
    fastify.documentService
    ) so our routes can easily access it.

As we noted with the Prisma plugin, the beauty of using the

@fastify/autoload
plugin for our
src/plugins
directory is that we don't even need to touch
src/app.ts
again to get this new plugin registered. Autoload will automatically detect our services plugin, check its dependencies (
prismaPlugin
), and load it in the correct order.

Let's get that plugin out. Create a

services.ts
file under the
src/plugins
subdirectory:

touch src/plugins/services.ts

Add the following content to that file:

import fp from "fastify-plugin";
import { FastifyInstance, FastifyPluginAsync } from "fastify";
import {
  createDocumentService,
  DocumentService,
} from "../routes/documents/document.service";

declare module "fastify" {
  interface FastifyInstance {
    documentService: DocumentService;
    // Add other services here if needed
  }
}

const services: FastifyPluginAsync = fp(
  async (fastify: FastifyInstance) => {
    const documentService = createDocumentService({
      logger: fastify.log,
      prisma: fastify.prisma,
    });

    fastify.decorate("documentService", documentService);

    fastify.log.info("DocumentService registered");
  },
  {
    name: "servicesPlugin",
    dependencies: ["prismaPlugin"],
  }
);

export default services;

This new plugin follows the same patterns and uses the same APIs we used to create the Prisma plugin. What's new here is that we define a dependency that needs to be loaded before this plugin can be loaded.

Create Fastify Routes

For this application, we want to create endpoints to perform read and write operations on documents:

# get all documents (for the demo user)
GET /api/documents

# get a document using an id parameter (for the demo user)
GET /api/documents/:id

# create a document (for the demo user)
POST /api/documents

# update a document using an id parameter (for the demo user)
PUT /api/documents/:id

# delete a document using an id parameter (for the demo user)
DELETE /api/documents/:id

In this phase of the tutorial, before implementing authentication, you will simulate the user context within the router by using a hardcoded user ID. This ensures our service layer receives the necessary context without relying on insecure client input, such as passing the

ownerId
as a query parameter in the request.

As you can see, we must prefix our API routes with

/api
. You can do so easily by making a small update in the
app
function present in the
src/app.ts
file.

Locate the code that uses

AutoLoad
to load all the plugins defined in the
routes
directory and update it like so:

// This loads all plugins defined in routes
// define your routes in one of these
// eslint-disable-next-line no-void
void fastify.register(AutoLoad, {
  dir: join(__dirname, "routes"),
  options: { ...opts, prefix: "/api" },
});

You are adding the

prefix
option to its
options
object with the value that we need,
/api
, satisfying the requirements of the API design.

In Fastify, everything is a plugin, including routers that define your API endpoints. Implementing routes as a Fastify plugin provides the following benefits:

  • Encapsulation: A plugin groups related routes and logic within a single module.
  • Modularity: A plugin allows you to manage an application feature independently.
  • Shared Context: A plugin provides access to a decorated (enhanced)
    fastify
    instance, such as using
    fastify.documentService
    .
  • Now, let's create the basic structure of the documents router plugin:
touch src/routes/documents/index.ts

Then, populate that file with the following content:

import { FastifyPluginAsync } from "fastify";
import {
  CreateDocumentInput,
  jsonSchemas,
  UpdateDocumentInput,
} from "./document.zod";
import { ServiceErrorCode } from "../../models/service-error.model";

interface DocumentParams {
  id: string;
}

const DEFAULT_OWNER_ID = "auth0|demo-user";

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  // Add route definitions here
};

export default documents;

You define the

DEFAULT_OWNER_ID
constant within the plugin scope. This ID will be used to simulate the authenticated user context when calling the service layer. For a production application, you use an authenticated user's ID derived from a validated session or token.

Define the route to retrieve all documents for the simulated user:

// imports, interface, constant ...

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  fastify.get(
    "/",
    { schema: jsonSchemas.getAllDocuments },
    async (request, reply) => {
      // Simulate getting the ownerId from auth context
      const ownerId = DEFAULT_OWNER_ID;

      const result = await fastify.documentService.findAll(ownerId);

      if (result.isErr()) {
        fastify.log.error(
          { error: result.error, ownerId },
          "Error fetching all documents for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(200).send(result.value);
    }
  );

  // Add more route definitions here...
};

export default documents;

The

fastify.get()
method is a quick way to define a Fastify route that handles the
GET
method. It takes the route path, configuration options, and the route handler as arguments. All other REST methods have the same signature, such as
fastify.post()
or
fastify.put()
.

You provide the Zod-generated JSON schema via

jsonSchemas.getAllDocuments
for response serialization. Inside the handler, you use our
DEFAULT_OWNER_ID
constant and pass it to
fastify.documentService.findAll()
. You access the injected service via the decorated fastify instance:
fastify.documentService
to
findAll
documents in the database. If the service returns an error (
result.isErr()
), you log it and send a 500 response. Otherwise, we send a 200 OK with the documents.

Learn more about Fastify validation and serialization.

If this task fails, you throw the error that Fastify's centralized error handler will catch. On success, you reply with a

200 OK
status code along with the array of documents,
result.value
.

The rest of the route will follow a similar pattern. So, let's define the route to retrieve a specific document by its ID next:

// imports, interface, constant ...

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  fastify.get(
    "/",
    { schema: jsonSchemas.getAllDocuments },
    async (request, reply) => {
      /* ... */
    }
  );

  fastify.get<{ Params: DocumentParams }>(
    "/:id",
    { schema: jsonSchemas.getDocument },
      const id = request.params.id;
      // Simulate getting the ownerId from auth context
      const ownerId = DEFAULT_OWNER_ID;

      const result = await fastify.documentService.find(id, ownerId);

      if (result.isErr()) {
        if (result.error.code === ServiceErrorCode.NotFound) {
          fastify.log.warn(
            { id, ownerId, error: result.error },
            "Failed to find document for user"
          );

          return reply.status(404).send(result.error.message);
        }

        fastify.log.warn(
          { id, ownerId, error: result.error },
          "Error finding document for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(200).send(result.value);
    },
  );

  // Add more route definitions here...
};

export default documents;

This route extracts the

id
from
request.params
and uses the
DEFAULT_OWNER_ID
when calling
fastify.documentService.find()
. It handles the
NotFound
service error specifically by returning a 404 status.

Next, define the route to create a new document for the simulated user:

// imports, interface, constant ...

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  fastify.get(
    "/",
    { schema: jsonSchemas.getAllDocuments },
    async (request, reply) => {
      /* ... */
    }
  );
  fastify.get<{ Params: DocumentParams }>(
    "/:id",
    { schema: jsonSchemas.getDocument },
    async (request, reply) => {
      /* ... */
    }
  );

  fastify.post<{ Body: CreateDocumentInput }>(
    "/",
    { schema: jsonSchemas.createDocument },
    async (request, reply) => {
      const documentData = request.body;
      const ownerId = DEFAULT_OWNER_ID;

      const serviceInput = {
        ...documentData,
        ownerId: ownerId,
      };

      const result = await fastify.documentService.create(serviceInput);

      if (result.isErr()) {
        // Handle potential conflict error from service
        if (result.error.code === ServiceErrorCode.Conflict) {
          fastify.log.warn(
            { error: result.error, body: serviceInput },
            "Conflict error creating document for user"
          );
          return reply.status(409).send(result.error.message);
        }

        fastify.log.error(
          { error: result.error, body: serviceInput },
          "Error creating document for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(201).send(result.value);
    }
  );

  // Add more route definitions here...
};

export default documents;

Here,

request.body
is validated against
CreateDocumentSchema
, which only includes
title
and
content
. You then create a
serviceInput
object, combining the client data with our simulated
DEFAULT_OWNER_ID
, before passing it to
fastify.documentService.create()
. You also handle the
Conflict
error with a
409
status.

With that in place, let's define the route to update an existing document for the simulated user:

// imports, interface, constant ...

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  fastify.get(
    "/",
    { schema: jsonSchemas.getAllDocuments },
    async (request, reply) => {
      /* ... */
    }
  );
  fastify.get<{ Params: DocumentParams }>(
    "/:id",
    { schema: jsonSchemas.getDocument },
    async (request, reply) => {
      /* ... */
    }
  );
  fastify.post<{ Body: CreateDocumentInput }>(
    "/",
    { schema: jsonSchemas.createDocument },
    async (request, reply) => {
      /* ... */
    }
  );

  fastify.put<{ Params: DocumentParams; Body: UpdateDocumentInput }>(
    "/:id",
    { schema: jsonSchemas.updateDocument },
    async (request, reply) => {
      const id = request.params.id;
      const documentData = request.body;
      const ownerId = DEFAULT_OWNER_ID;

      const serviceInput = {
        ...documentData,
        ownerId: ownerId,
      };

      const result = await fastify.documentService.update(id, serviceInput);

      if (result.isErr()) {
        if (result.error.code === ServiceErrorCode.NotFound) {
          fastify.log.warn(
            { id, ownerId, error: result.error },
            "Failed to update document (not found or wrong user)"
          );

          return reply.status(404).send(result.error.message);
        }
        // Handle potential conflict error from service
        if (result.error.code === ServiceErrorCode.Conflict) {
          fastify.log.warn(
            { id, error: result.error, body: serviceInput },
            "Conflict error updating document for user"
          );

          return reply.status(409).send(result.error.message);
        }

        fastify.log.error(
          { id, ownerId, error: result.error, body: serviceInput },
          "Error updating document for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(200).send(result.value);
    }
  );

  // Add more route definitions here...
};

export default documents;

Similar to

POST
, the
PUT
handler combines the validated client data (
request.body
containing optional
title
/
content
) with the simulated
DEFAULT_OWNER_ID
into
serviceInput
. This object is then passed to
fastify.documentService.update()
, which uses the
ownerId
within
serviceInput
for its
where
clause check.

Finally, define the route to delete a document for the simulated user:

// imports, interface, constant ...

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  fastify.get(
    "/",
    { schema: jsonSchemas.getAllDocuments },
    async (request, reply) => {
      /* ... */
    }
  );
  fastify.get<{ Params: DocumentParams }>(
    "/:id",
    { schema: jsonSchemas.getDocument },
    async (request, reply) => {
      /* ... */
    }
  );
  fastify.post<{ Body: CreateDocumentInput }>(
    "/",
    { schema: jsonSchemas.createDocument },
    async (request, reply) => {
      /* ... */
    }
  );
  fastify.put<{ Params: DocumentParams; Body: UpdateDocumentInput }>(
    "/:id",
    { schema: jsonSchemas.updateDocument },
    async (request, reply) => {
      /* ... */
    }
  );

  fastify.delete<{ Params: DocumentParams }>(
    "/:id",
    { schema: jsonSchemas.deleteDocument },
    async (request, reply) => {
      const id = request.params.id;
      const ownerId = DEFAULT_OWNER_ID;

      const result = await fastify.documentService.remove(id, ownerId);

      if (result.isErr()) {
        if (result.error.code === ServiceErrorCode.NotFound) {
          fastify.log.warn(
            { id, ownerId, error: result.error },
            "Failed to delete document (not found or wrong user)"
          );

          return reply.status(404).send(result.error.message);
        }

        fastify.log.error(
          { id, ownerId, error: result.error },
          "Error removing document for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(204).send();
    }
  );
};

export default documents;

The

DELETE
handler simply extracts the
id
from params and uses the
DEFAULT_OWNER_ID
when calling
fastify.documentService.remove()
. The schema correctly specifies no request body is expected.

By building this routes plugin, you've seen the following key Fastify concepts in action:

  • Request Validation: Zod schemas defined in
    jsonSchemas
    are used within the
    schema
    option of each route definition to automatically validate request parameters (
    Params
    ) and bodies (
    Body
    ). Invalid requests result in a 400 Bad Request response.
  • Response Serialization: The same schemas define the structure of the response (
    Reply
    ), ensuring consistent output and providing performance benefits through Fastify's optimized serialization.
  • Service Layer Integration: Route handlers delegate business logic and data operations to the injected
    fastify.documentService
    .
  • Simulated User Context: For this unauthenticated phase, the router uses a hardcoded
    DEFAULT_OWNER_ID
    to provide the necessary user context to the service layer, ensuring security while preparing for real authentication.
  • Error Handling: Service layer methods return a
    Result
    . Route handlers check for errors (
    isErr()
    ), log them, and map specific service errors (
    NotFound
    ,
    Conflict
    ) to appropriate HTTP status codes (404, 409), returning a generic 500 for other errors.

Error Handling

It's easy to fall into traps related to Cross-Origin Resource Sharing (CORS) errors when building a frontend application that needs to communicate with your Fastify backend, particularly when credentialed requests are involved, such as including

Authorization
headers.

While you already set up a CORS plugin on your entry point plugin, the

@fastify/cors
plugin adds CORS headers only to successful responses from defined routes and preflight (
OPTIONS
) responses. It does not automatically add CORS headers to responses generated by Fastify's internal default
404 (Not Found)
handler, especially when using the Fastify CLI to run your application.

When the browser receives the 404 response lacking the

Access-Control-Allow-Origin
header, it blocks the response due to the CORS policy, even though the
404
status itself might be expected.

You can prevent this issue by implementing a custom

404
handler using
fastify.setNotFoundHandler
to ensure the necessary CORS headers are added to
404
responses when the request originates from an allowed origin. You also need to do the same for internal error responses generated by Fastify.

Add the following code after the registration of the

Cors
and
Helmet
plugins in your
src/app.ts
file and before Fastify registers the
AutoLoad
plugin:

fastify.setNotFoundHandler((request, reply) => {
  // Set CORS headers explicitly
  reply.header("Access-Control-Allow-Origin", CORS_ALLOWED_ORIGINS); // Or your origin
  // Add other necessary CORS headers (methods, headers, etc.) if needed

  reply.code(404).send({ message: "Resource not found" });
});

fastify.setErrorHandler((error, request, reply) => {
  // Log the error
  request.log.error(error);

  // Set CORS headers explicitly
  reply.header("Access-Control-Allow-Origin", CORS_ALLOWED_ORIGINS); // Or your origin
  // Add other necessary CORS headers if needed

  // Send generic error response
  reply.status(500).send({ message: "Internal Server Error" });
  // Or customize based on error type if needed
});

Test the Fastify API Endpoints

Ensure your server is running (

npm run dev
). You might need to re-seed your database (
npm run db:seed
) if you reset it earlier or made incompatible changes.

Use

curl
or a tool like Postman/Insomnia to test the endpoints.

Get All Documents (for Demo User)

curl -X GET "http://localhost:3000/api/documents"

Get a Specific Document (for Demo User)

Replace

genai_doc_001
with an actual ID from your seeded data (e.g.,
0195ed4b-8d29-7588-ae8d-7305e542c305
).

curl -X GET "http://localhost:3000/api/documents/0195ed4b-8d29-7588-ae8d-7305e542c305"

Create a New Document (for Demo User)

curl -X POST "http://localhost:3000/api/documents" \
     -H "Content-Type: application/json" \
     -d '{
           "title": "GenAI Prompt Engineering Guide",
           "content": "Best practices for writing effective prompts for large language models."
         }'

Note the server will automatically assign ownership to the demo user.

Update a Document (for Demo User)

Replace

genai_doc_001
with an actual ID.

curl -X PUT "http://localhost:3000/api/documents/0195ed4b-8d29-7588-ae8d-7305e542c305" \
     -H "Content-Type: application/json" \
     -d '{
           "title": "Advanced GenAI Prompt Engineering",
           "content": "Advanced techniques for prompt chaining and few-shot learning in LLMs."
         }'

This only works if the document is owned by the demo user.

Delete a Document (for Demo User)

Replace

genai_doc_001
with an actual ID.

curl -X DELETE "http://localhost:3000/api/documents/0195ed4b-8d29-7588-ae8d-7305e542c305"

This only works if the document is owned by the demo user. No request body is needed.

Test 404 Not Found

Try to get a document that does not exist:

curl -X GET "http://localhost:3000/api/documents/non_existent_doc_id"

Try to get a route that does not exist:

curl -X GET "http://localhost:8080/api/orders

Security Considerations

Currently, all API endpoints are public. You would typically implement authentication and authorization to properly secure the write operations (

POST
,
PUT
,
DELETE
). This involves:

  1. Integrating an identity provider like Auth0.
  2. Validating access tokens (e.g., JWTs) on incoming requests.
  3. Checking user permissions or roles before allowing sensitive operations.

Auth0 recently released a Fastify API SDK for handling JWTs and implementing authorization in your Fastify APIs:

@auth0/auth0-fastify-api
. Be sure to check it out.

Conclusion

You've built a robust CRUD API using Node.js, Fastify, and TypeScript. You've leveraged powerful tools and patterns:

  • Fastify: For high performance and a great developer experience.
  • TypeScript: For static typing and improved code maintainability.
  • Prisma: As a type-safe ORM for database interactions (with Prisma Studio for visualization).
  • Zod: As a single source of truth for validation and API data shapes.
  • neverthrow
    :
    For the service layer's functional and explicit error handling.
  • UUIDv7: For efficient internal IDs and user-friendly public identifiers.
  • Dependency Injection: Using Fastify decorators for cleaner service management.
  • Centralized Error Handling: Leveraging Fastify's
    setErrorHandler
    and
    setNotFoundHandler
    for consistent error responses.

This foundation provides a scalable and maintainable structure for building more complex features onto your API. Remember to explore testing strategies (unit, integration) and implement proper security measures for production deployment.

Happy coding!