DEV Community

蕨类植物
蕨类植物

Posted on

A practical guide to implementing a Fastify modular project — Part 1 Technology Selection

Why Choose Fastify Over NestJS, ExpressJS, and Koa?

Based on my experience using ExpressJS to set up projects, the project structure was usually like this:

src/
└───── infrastructures/
    │   ├── db.js
    │   └── server.js
    │
    ├── middlewares/
    │   └── authMiddleware.js
    │
    ├── routes/
    │   ├── authRoutes.js
    │   └── messageRoutes.js
    │
    └── services/
        ├── messagesService.js
        ├── userService.js
        └── authService.js
Enter fullscreen mode Exit fullscreen mode

While planning this modular project, I wanted to try something new that would satisfy my desire for high encapsulation.

First, I compared NestJS and ExpressJS:

Comparison Item NestJS ExpressJS
Goal Advanced full-stack backend framework Lightweight web framework
Architectural Style Mandatory MVC + modularity + DI (Inspired by Angular) No architectural constraints, high flexibility
Learning Curve 🟠 Moderately high (requires understanding of decorators/dependency injection) 🟢 Low (API is concise and easy to use)
TypeScript Support Native support, strongly typed by default Requires manual TS configuration
Dependency Injection (DI) Built-in support None (typically uses singleton/global or custom DI)
Routing Annotation-based control Manual routing configuration
Extensibility ⭐⭐⭐⭐ ⭐⭐
Ecosystem and Plugins Modular (Auth, Cache, Swagger, GraphQL, Jobs) Requires custom middleware composition
Suitable Scenarios Enterprise-level complex business logic, large team collaboration Small projects, rapid prototyping, flexible business processes
Performance Comparable to Express Based on Express / Fastify, excellent performance
Community & Documentation Mature and active Classic and stable

Then, I explored the Fastify and Koa frameworks.

Fastify

  • Better performance than Express
  • Plugin mechanism
  • Native support for ultra-fast JSON serialization
  • Suitable for high-performance API services.
  • Slightly higher learning curve than Express.

Koa

  • Built by the Express core team
  • Native support for Promises and async/await
  • More modern and lightweight
  • Uses the new onion model for middleware.

However, I was impressed by Fastify’s high performance and plugin mechanism, so I decided to start building my project with it.

Three Things to Consider Before Setting Up a Fastify Project

Should You Choose JS or TS?

Dimension JS TS
Learning Curve Low Medium
Development Speed (Short-term) Fast Slightly Slower
Maintenance Cost (Long-term) High Low
Refactoring Safety Low High
Module Extensibility Medium High
Engineering Capabilities Medium Very High
Architectural Evolution Capabilities Weak Strong
Template Reusability Value Low High
Project Scale Suitability Small Projects Medium to Large Projects
Technical Asset Value Low High

To ensure reusability, TypeScript is the obvious choice.

CommonJS or ES Modules?

We all know that JavaScript has two module systems:

ES Modules (ESM) is the modern JavaScript module system standardized by ECMAScript. It uses import and export, supports static analysis, asynchronous loading, and the singleton pattern, and is suitable for browsers and newer versions of Node.js. CommonJS (CJS) is the traditional default system for Node.js. It uses require and module.exports, loads modules synchronously, and is primarily used on the server side.

The fundamental difference between them lies in their underlying mechanisms.
CJS loads modules at runtime

`require()` is a function
→ Loads only when execution reaches this line
→ Dynamic loading
→ Cannot statically analyze dependencies
Enter fullscreen mode Exit fullscreen mode

Whereas ESJ (compile-time static analysis):

import is a syntax
→ Dependencies are analyzed at compile time
→ Builds a dependency graph
→ Supports tree-shaking
→ Supports module optimization
Enter fullscreen mode Exit fullscreen mode

At the grammatical level:

CommonJS:

// import
const fs = require('fs')

// export
module.exports = {
  test() {}
}
Enter fullscreen mode Exit fullscreen mode

ES Module:

// import
import fs from 'fs'

// export
export function test() {}
export default {}
Enter fullscreen mode Exit fullscreen mode

I chose ESM to structure the project modules because it is the current trend in software development.

Should I choose a native Node testing framework or something else?

When it comes to JavaScript testing frameworks, Jest, Vite, Mocha, and Chai are the first ones that come to mind. Frontend development in the JavaScript ecosystem typically involves testing component rendering, while backend development often involves simple assertions, such as testing REST API requests or unit testing utility libraries. After working with Fastify, I learned about native Node testing methods.

Capability Jest Vitest node:test
Runner
Mocking ✅ Advanced ✅ Advanced ⚠️ Basic
Coverage ⚠️ External
Watch
Snapshot Testing
Concurrent Execution ⚠️
ESM Support ⚠️ Complex Configuration ✅ Native ✅ Native
TS Support ⚠️ Depends on ts-jest ✅ Native ⚠️ Manual
Ecosystem Plugins ✅ Very Mature 🟡 Growing ❌ Almost None

Let me give a few simple examples from Vitest:

front-end:

// using vitest
import { describe, expect, vi } from "vitest"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import App from "../../src/components/App"

// via vi.hoisted to mock functions
const { mockGetNotes, mockDeleteNote, mockPutNote } = vi.hoisted(() => {
  return { mockGetNotes: vi.fn(), mockDeleteNote: vi.fn(), mockPutNote: vi.fn() }
})

vi.mock('../../src/services/notesService', () => {
  return {
    default: { getNotes: mockGetNotes, deleteNote: mockDeleteNote, putNote: mockPutNote }
  }
})

describe("render app", () => {
  it("should show notes successfully", async () => {
    mockGetNotes.mockResolvedValue(["fake note1"])
    render(<App />)
    await waitFor(() => screen.getByText("fake note1"))

    expect(screen.getByText("fake note1")).toBeInTheDocument()
  })

  it("should delete note successfully when click delete note button", async () => {
    mockGetNotes.mockResolvedValue(["fake note1"])
    render(<App />)
    await waitFor(() => screen.getByText("fake note1"))

    const deleteNoteBtn = screen.getByTestId("delete-note")
    fireEvent.click(deleteNoteBtn)

    expect(mockDeleteNote).toHaveBeenCalled(1)
  })
})
Enter fullscreen mode Exit fullscreen mode

unit tests examples in back-end:

// vitest
import { beforeEach, describe, it, vi, expect } from "vitest"
import notesService from "../../src/services/notesService"

const mockSelect = vi.fn()
const mockInsert = vi.fn()
const mockDelete = vi.fn()
const mockEq = vi.fn()

// mock DB client functions
const { supabaseMockClient } = vi.hoisted(() => {
  return {
    supabaseMockClient: {
      from: vi.fn(() => ({
        select: mockSelect,
        insert: mockInsert,
        delete: vi.fn(() => ({
          eq: mockEq
        })),
      }))
    }
  }
})

vi.mock('@supabase/supabase-js', () => ({
  createClient: vi.fn(() => supabaseMockClient),
}))

describe("notes service layer tests", () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it("should return notes array", async () => {
    mockSelect.mockResolvedValue({ data: [{ note: 'note1' }, { note: 'note2' }], error: null })
    const result = await notesService.getNotes()

    expect(supabaseMockClient.from).toHaveBeenCalledWith('notes-online')
    expect(supabaseMockClient.from().select).toHaveBeenCalledWith('note')
    expect(result).toEqual(['note1', 'note2'])
  })

  it("should return empty array when return error", async () => {
    mockSelect.mockResolvedValue({ data: null, error: "some errors" })

    const result = await notesService.getNotes()

    expect(result).toEqual([])
  })

  it("should save note successfully", async () => {
    mockInsert.mockResolvedValue()

    await notesService.putNote("new fake note")

    expect(supabaseMockClient.from).toHaveBeenCalledWith('notes-online')
    expect(supabaseMockClient.from().insert).toHaveBeenCalledWith({ note: "new fake note", creator: "anonymous" }, { returning: 'minimal' })
  })
})
Enter fullscreen mode Exit fullscreen mode

using node:test:

import { describe, it } from 'node:test'
import assert from 'node:assert'
import { build, expectValidationError } from '../../../../helper.js'

describe('Security api', () => {
  describe('POST /api/v1/auth/login', () => {
    it('Transaction should rollback on error', async (t) => {
      const app = await build(t)

      const { mock: mockCompare } = t.mock.method(app.passwordManager, 'compare')
      mockCompare.mockImplementationOnce((value: string, hash: string) => {
        throw new Error('Kaboom!')
      })

      const { mock: mockLogError } = t.mock.method(app.log, 'error')

      const res = await app.inject({
        method: 'POST',
        url: '/api/v1/auth/login',
        payload: {
          email: 'basic@example.com',
          password: 'Password123$'
        }
      })

      assert.strictEqual(mockCompare.callCount(), 1)

      const arg = mockLogError.calls[0].arguments[0] as unknown as {
        err: Error;
      }

      assert.strictEqual(res.statusCode, 500)
      assert.deepStrictEqual(arg.err.message, 'Kaboom!')
    })
})
Enter fullscreen mode Exit fullscreen mode

node:test also very user-friendly. For one, it has no external dependencies and meets my needs. So far, I haven’t used any other testing frameworks.

Top comments (0)