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
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
importandexport, 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 usesrequireandmodule.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
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
At the grammatical level:
CommonJS:
// import
const fs = require('fs')
// export
module.exports = {
test() {}
}
ES Module:
// import
import fs from 'fs'
// export
export function test() {}
export default {}
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)
})
})
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' })
})
})
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!')
})
})
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)