skip to content

The Dismal *Amazing* Thoughts and Adventures of

James Gardner

Modular Fastify - Part 2

Modular Fastify - Part 2

/ 6 min read

In my last instalment I scaffolded a base project and explained why I had chosen some of its building blocks. Today I’d like to cover some of the fundamentals in my approach to developing RESTful APIs and then move on to implementing some actual logic.

Database Schematics and Identifiers

I’ve structured ‘schools’ and ‘students’ tables in our database schema, ensuring a robust relationship via a foreign key constraint on the ‘students’ table. This enforces referential integrity and guarantees each student is linked to a valid school.

Here are the table definitions:

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE school (
  id SERIAL PRIMARY KEY,
  uuid UUID NOT NULL DEFAULT uuid_generate_v4(),
  name VARCHAR(100),
  address VARCHAR(255),
  created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
  deleted_at TIMESTAMP WITH TIME ZONE,
  UNIQUE(uuid)
);

CREATE TABLE student (
  id SERIAL PRIMARY KEY,
  uuid UUID NOT NULL DEFAULT uuid_generate_v4(),
  first_name VARCHAR(50),
  last_name VARCHAR(50),
  date_of_birth DATE, -- Dates do not have a timezone
  created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
  deleted_at TIMESTAMP WITH TIME ZONE,
  school_uuid UUID,
  UNIQUE(uuid),
  CONSTRAINT fk_school_uuid FOREIGN KEY (school_uuid) REFERENCES school(uuid)
);

I opted for UUIDs as primary identifiers for both entities, driven by the need for unique, secure identifiers across our REST API. This choice mitigates security risks associated with predictable, sequential IDs and facilitates seamless data integration in distributed systems without worrying about ID conflicts.

Consider this RESTful endpoint for a student:

/student/5bc95f5a-fc02-4566-9fae-edfa877762ad

Here, the full URI represents the global identifier for the resource in the web context, while the UUID 5bc95f5a-fc02-4566-9fae-edfa877762ad specifically serves as the unique identifier for the student within our database. This distinction highlights my approach to API design, emphasising the importance of decoupling the API from the underlying database architecture. It prevents “leaky abstractions,” where internal system details inadvertently emerge in the API layer, complicating its use and evolution.

Integrating these elements into our database and API design lays a solid foundation for a secure, scalable application ready to adapt to future needs without compromising data integrity or privacy.

Routes and Data Access

Let’s run with a route structure as follows for students:

/school/:schoolId/student/:studentId

This explicitly represents the relationship between resources (schools and students). It’s more intuitive and straightforward for API consumers than the previous example: A consumer can directly access a specific student within a school without first retrieving or knowing the school context, assuming they have the schoolId and studentId.

With that said, a consumer should only be able to access students for their school. While the above makes it more difficult, anybody could use a valid combination of identifiers, which poses a security risk. Looking ahead to future posts, we’ll explore access control mechanisms to mitigate this. For now, though, this structure is more convenient than anything else. I rely on the integrity of the relationship between the school and the student to trust a WHERE clause on the student table’s school field, which means I don’t need to validate a school against the database whenever a student is requested. As such, the following will yield a 404:

  • An invalid school
  • A valid school with an invalid student (e.g. a non-existent student or one that does not belong to the school)

Schematics

If you refer back to the project structure I outlined in part 1:

.
└── src/
    └── plugins/
        ├── student/
        │   ├── services/
        │   │   └── student.ts
        │   ├── repositories/
        │   │   └── student.ts
        │   ├── schemas/
        │   │   └── index.ts <-- Here

We’ll put our schemas in one module under the ‘schemas’ folder for ‘students’. There’s not enough of them to consider breaking them out into separate files.

First, a schema that represents a partial (or if needed complete) database record.

export const StudentSchema = z.object({
  uuid: z.string(),
  schoolId: z.string(),
  firstName: z.string(),
  lastName: z.string(),
  dateOfBirth: z.string(),
});

There aren’t any computed fields or other bits we bolt on to make this any different to the response so let’s use this for both.

Path parameters, used to provide intellisense more than anything:

export const StudentPathParamsSchema = z.object({
  uuid: z.string(),
  schoolId: z.string()
});

Student Service and Repository

Let’s move swiftly on to our data access layer. This is nothing more than a pass-through wrapper for the repository until we define our data access rules etc:

// services/student.ts
import type { StudentRepository } from "../repositories";
import type { Student } from "../types";

export interface StudentService {
  getById(uuid: string, schoolId: string): Promise<Student>;
}

export const createStudentService = (repository: StudentRepository): StudentService => {  
  return {
    getById: (uuid, schoolId) => {
      return repository.getById(uuid, schoolId);
    },
  }
};

The repository

// repositories/student.ts
import { DatabasePool, sql} from "slonik";
import { StudentSchema } from '../schemas/student';
import { Student } from '../types';

export interface StudentRepository {
  getById(uuid: string, schoolId: string): Promise<Student>;
}

export const createStudentRepository = (pool: DatabasePool): StudentRepository => ({
  getById: async (uuid, schoolId) => {
    return pool.connect(connection => 
      connection.one(
        sql.type(StudentSchema)`SELECT * FROM student WHERE uuid = ${uuid} and school_uuid = ${schoolId}`
      )
    );
  },
})

Router Plugin

export interface StudentManagementOptions {
  service: StudentService
}

export const students: FastifyPluginAsync<StudentManagementOptions> = 
  async function (fastify, { service }) {
    fastify
      .withTypeProvider<ZodTypeProvider>()
      .get('/:uuid', {
        schema: {
          params: StudentPathParamsSchema,
          response: {
            200: StudentSchema
          }
        },
      }, async function (request, reply) {
        const { uuid, schoolId } = request.params;

        try {
          const student = await service.getById(uuid, schoolId);

          return reply.send(student);
        } catch (error) {
          if (!(error instanceof NotFoundError)) {
            // Possible invalid InvalidInputError for non-uuid string in postgres. Capture elsewhere and log.
            throw error;
          } else {
            reply.status(404);
          }
        }
      })
  };

Register our new plugin with our app:

app.register(students, {
  service: createStudentService(
    createStudentRepository(pool)
  ),
  prefix: '/school/:schoolId/student'
});

Utilising a route prefix with a school identifier may seem unconventional given our emphasis on separation. However, REST resources, which facilitate API interactions, and bounded contexts, which outline domain models in Domain-Driven Design, have different functions. Therefore, matching them exactly isn’t required for our architecture.

Conclusion

So that’s it for now. I’ve skimped on implementing the remaining CRUD operations for this post to keep things short and sweet. In the next post I’ll cover updating the data layer with create, update and delete operations alongside some decisions around database migrations.