A very opinionated, "bullet-proof", scalable and maintainable Node.js template for building small and medium-sized applications, that I have come up with after years of experience in different projects.
It follows the principles of SOLID, DDD / Clean Architecture, 12 Factor Apps in a balanced way, while does not overcomplicate things and uses the minimum set of required dependencies.
Out of the box, it provides:
- TypeScript setup
- Ready to use folder structure
- Fastify server with simple REST API setup
- Swagger-UI documentation on OpenAPI 3.0
- Drizzle-ORM for PostgreSQL with migrations setup
- Test environment with Ava runner
- Simple authentication with in-memory sessions
- Safe config loading from .env files
- Ready to use simple mail sending with HTML templates
Aside from just being a template with package.json
and some code, it also comes with a set of principles and philosophy.
Prerequisites:
- Node.js
- PostgreSQL
Creating own application from this template:
- Clone the repository
- Remove this README.md, and rename README-app.md to README.md, edit your application name
- Look for
// TEMPLATE:
comments in the code and modify such parts as needed, or remove them - Follow README instructions to start the application.
The listed principles are basically compilation from different paradigms like DDD, Clean Architecture, 12 Factor Apps, etc. Following them has shown great results in my projects, and I would recommend to follow them as well.
As by DDD/Clean Architecture, the application code is implicitly divided into multiple layers:
- Anemic domain model (entities are represented by interfaces without logic inside them)
- Business-logic services
- Infrastructure services (database, email, etc.)
No domain entity or service may depend directly on any infrastructure part - instead they should depend on interfaces, that are implemented by the infrastructure level. This includes database access, logging, external API calls (directly or through an SDK), network communication, HTTP, etc. Examples in code:
- Users repository at src/users/usersRepository.ts
- Email provider at src/mailer/mailerProvider.ts
The abstraction (interface) name is up to you, but for sake of consistency, the following naming convention is recommended:
- Persistence layer:
*Repository
(e.g.UsersRepository
) - Database custom queries:
*Gateway
(e.g.UsersGateway
) - External API provider:
*Provider
(e.g.EmailProvider
,TwilioProvider
, etc.)
The code is organized in a feature-based way, where each feature has it's own folder with all the code related to it, including:
- Routes
- Entities
- Table schemas
- Services
- Repositories
- Providers
Do not use Ruby on Rails-style folder structure, where you have separate folders for controllers, models, views, etc. This becomes harder and harder to maintain as the application grows. As some wise man said: "Your application should scream when you open it's codebase: I am an order management system!", not "I am a HTTP app with controllers, models and views".
Such approach also allows to easily split the code later if needed, and also allows easier feature-removal.
3. Do not use middlewares for everything not related to HTTP, even if the framework or ecosystem allows it
The template comes with some fastify plugins (middlewares) for HTTP-related things, like cookies, CORS, rate-limit, etc. You may add others like multipart/form-data
parsing, but do not add anything related to authentication, database access, things like Redis, JWT, config loading etc.! This must be part of your application logic and easily testable and replaceable!
Fastify is first of all, a web routing framework, it should not care about your auth, database, etc.!
JWT is usually "go-to-way" for authentication in Node.js applications partially due to it being a "buzzword", but in reality, it's very hard to "cook" JWTs in right and secure way. You won't need a pair of access and refresh tokens in most applications. You are absolutely fine with sessions, that can be stored in memory, Redis or database. You will be able to control all open sessions from server-side perspective, and your frontend won't have to deal with token expiration, refresh tokens, etc., as cookies are automatically handled by the browser.
If need to provide an API to non-web clients, like mobile apps, you can still return the sessionId
in the response and use it as a Bearer
token. Mobile app does not care if you provide an token as JWT, a session or something else, it's just something they need to send with every request.
It's a trap! You will vendor lock yourself and will soon or later have to migrate away! Authentication and access check are critical parts of your application business logic, and shall be managed by you, not some third-party. You write them once in a few hours, and have complete confidence and control over them since then.
Basically comes from the 12 Factor Config principle.
In this template, the configuration is loaded from .env
files using dotenv-safe
package, which checks against .env.example
file. This ensures no environment variable will be missed in production, and ensures that orchestrating software may inject config easily with tools like Docker, Kubernetes, etc.
Utils folder shall contain project-global utility functions that can be applied in any context, basically treat them as pure functions. If something applies to specific feature or business context, it shall be placed in the feature folder. Otherwise, it can quickly turn into a dumpster.
Template comes with a single AppError
class that contains systematic code and message for the error.
Error code is from a fixed enum, and is designed to be used by API consumers, while descriptive message is for humans.
It should be thrown by the application code as soon as possible, and it will be caught by custom Fastify error handler:
if (order.creatorId != user.id) {
throw new AppError(ErrorCode.NOT_ENOUGH_PERMISSIONS, 'Only order creator can cancel the order!');
}
Will result in graceful HTTP 403 response:
{
"code": "NOT_ENOUGH_PERMISSIONS",
"message": "Only order creator can cancel the order!",
"timestamp": 1746125890728,
}
Do not use Java or C# approach of inventing a new error class for every possible error.
Suggested approach allows easily checking, if error comes from the application code or not, with a single instanceof
expression, and also allows to easily add new error codes and messages in the future, along with 1 defined error handler.
An ID is property of an entity, which means it must be possible to generate that value on the domain layer, where no database is present. An auto-incrementing ID requires a connection to the database to determine next value in the sequence.
Use UUIDs instead. This allows:
- Generating IDs independently of the database (in test environments, for example)
- Providing initially a unique ID that can work later in distributed systems
- Avoiding possible security issues with auto-increment IDs, like brute-forcing or estimating the number of records in the database
An existing utility in src/utils/id.ts can be used for that:
class OrderService {
...
async createOrder(userId: string, itemId: string) {
const order: Order = {
id: makeEntityId(),
userId,
itemId,
createdAt: new Date(),
updatedAt: new Date(),
};
await this.ordersRepository.createOrder(order);
return order;
}
}
If you really need an auto incrementing number, that will be used by humans, associated with the entity, store it as a separate column and calculate it's value on the application layer.
Node.js ecosystem develops and grows very rapidly, one things replace another, and unfortunately, for many problems there are still no "perfect" or at least "very good" solutions, that is applicable for other languages and platforms like Java, C#, etc.
Nevertheless, through years, I have personally found some libraries to be excellent for their purpose, and consider them the current de-facto standard for Node.js applications. On the other hand, there are some choices that I strongly oppose against as they are outdated, enforce bad practices or just add unnecessary bloat to your codebase, and here I will try to explain my reasoning.
✅ Fastify
Fastify has recommended itself to be a very good base framework, providing 3 main things:
- Extendable routing system
- Performance
- Typescript support
It has great ecosystem, active development, and is pretty stable.
❌ Express
Express, although being one of the "founding fathers" of Node.js, should never be used for new projects, because:
- It's just too old, it does not support async/await out of box or good error handling
- It's made of spaghetti code
- It has no "real" typescript support
- It has bad performance (regexp routing)
- It just leads to bad practices
- It's not well maintained anymore
- It's usage propagates bad practices, like using middleware for everything or request object pollution
❌ NestJS
Nest.js is kinda of a big deal in the community, but to be honest, I don't see any real profit from it, as Nest.js by itself is just a fancy Inversion of Control / Dependency Injection container in it's core with additional features built on top of it in it's ecosystem. Not only implementing DI can be done easily without vendor-locking yourself into Nest.js ecosystem, but also it uses quite a lof of magic and unnecessary abstractions, like decorators.
Nest.js does not enforce any architecture (it may look like it, but it does not) and has other problems that are not worth dealing with.
❌ Hono
Hono is great, but it's too new and I see it's focused on bit other use cases, like edge computing and serverless. I am not ready to use it in production so far.
Nothing to add, PostgreSQL is the beast. Open source, fast, well maintained, good documentation.
Unless you have very specific requirements, PostgreSQL should be the way to go.
With it's JSONB support, you can easily store embedded data and use it as a document database, if needed.
Therefore, other options are not considered:
- ❌ MongoDB - idea of storing raw JSONs may look appealing, but in reality, well-structured relational database will be better.
- ❌ MySQL - not as good as PostgreSQL, and not as well maintained.
This is the only ORM that is worth using, period.
Aside from being created by my fellow 🇺🇦 countryman, it has:
- Great documentation
- Amazing TypeScript support
- Great toolkit for migrations
- Good performance, as generated queries are very simple
- Active community and development
You can read more on Drizzle philosophy here, and I can guarantee you that after trying it, you will never want to use anything else.
Other options, in that comparison, are not worth using:
- ❌ TypeORM - just look at number of issues on Github
- ❌ Sequelize - basically as ancient, as Express
- ❌ Prisma - too complicated and too much magic
✅ Ava
Modern, quick, without any bloat, with Typescript support and great documentation.
- 💡 Possible great replacement: Vitest
These tools are outdated and bring some or other bloat to your codebase. A test runner nowadays shall provide it's own assertion library, and not rely on external libraries like Chai.
@fastify/<x>
- official HTTP-level plugins for Fastify (cookie, cors, helmet, rate-limit, swagger)bcrypt
- secure password hashingdotenv-safe
- load .env files and check against .env.exampleeta
- lightweight HTML templating engine for compiling templatesnodemailer
- sending emailsinline-css
- inlining CSS styles in HTML templatesdayjs
- date formatting and manipulation
Good:
- ✅ zod - really great library for validation with excellent type deduction
- ✅ ioredis - high-performant Redis client
- ✅ playwright - modern E2E testing framework or browser automation solution
- ✅ gaxios, fetch - HTTP client libraries
Bad:
- ❌
lodash
- come on, most if it is possible with native JS, and you can just put small needed functions inutils
folder without loading lodash. - ❌
moment
- deprecated. - ❌
class-validator
and any other decorator-based validation library - decorators are a bad idea, and these libraries introduce unneeded complexity and magic. Usezod
instead - ❌
passport
- see below
Cry about it Well, it's kinda the point of this repo :D. All choices and principles are built on my personal experience, for my personal usage when applicable. You are free to attempt to convince me otherwise in repo issues.
All dependencies are manually injected into the constructors of the classes. Dependency shall be an interface, when possible. Services may skip the interface implementation if they are not injected anywhere else.
There is a deps.ts file that instantiates all dependencies and exports them in a single object. This object is basically your whole application. You can use that in any environment, be it an HTTP request handler, a command line interface, a cron job, etc.
The manual initialization can seem a bit tedious, but that is small price to pay for:
- Freedom from any IoC library
- Ability to visualize relationships between dependencies, discover circular dependencies, even just with TypeScript compiler
- Easy testing
The test/
folder mimics the overall structure of src
folder, but with test files.
A test file should be named *.test.ts
and placed in the same folder as the file it tests.
Do not apply should wording in the test names, just state what the test does. Express requirement, not wish or desire.
Mocking can easily be done without any external tool, as your dependencies are passed in the constructor and are interfaces.
You can just create empty dumb objects or some stub implementations, like this:
export const mockAuthHasher: AuthHasher = {
comparePassword: async (password: string, hashedPassword: string) => {
return password === hashedPassword;
},
hashPassword: async (password: string) => {
return password;
},
};
const authService = new AuthService(mockAuthHasher, ...);
Although this template provides a foundation for testing, you should not aim for 100% unit coverage, this is usually waste of time.
Use unit tests to cover really critical parts of your business logic.
Otherwise, prefer writing E2E tests, they provide more value for less effort. Recommended tool for E2E tests is Playwright, as it's modern and has great API.
Yes, but I would not recommend it. Manually describing entities with interfaces and creating a matching database separately schema allows:
- Your entities to be "crystal-clear" of any database-related stuff. They are just objects with data, nothing more.
- Easily separate your entities definition into a package for shared usage by other projects, including frontend
- Allows possibly extending later to rich domain model without making it dirty
NO! Passport is a huge bloat and another piece of decade-old spaghetti code. Handle your authentication yourself, it's not that hard. Writing OAuth integrations with Google, Facebook, etc is also easy. This will save you a lot of time and headache in the future.
- Create a provider interface, abstracting away any external system communication details:
export interface SomePaymentSystemProvider {
createPayment(amount: number, currency: string): Promise<SomeSystemPayment>;
processPayment(paymentId: string): Promise<SomeSystemPayment>;
getPaymentStatus(paymentId: string): Promise<SomeSystemPayment>;
}
- Create a "default" implementation of the provider, that actually takes care of the communication with the external system:
class DefaultSomePaymentSystemProvider implements SomePaymentSystemProvider {
async createPayment(amount: number, currency: string): Promise<SomeSystemPayment> {
// Call external system API with fetch or whatever, handle all authentication, etc.
}
async processPayment(paymentId: string): Promise<SomeSystemPayment> {
// Call external system API with fetch or whatever, handle all authentication, etc.
}
async getPaymentStatus(paymentId: string): Promise<SomeSystemPayment> {
// Call external system API with fetch or whatever, handle all authentication, etc.
}
}
- Provide the implementation to all services or other classes that depend on
SomePaymentSystemProvider
interface in src/deps.ts file.
I consider that there is no need for controllers and routes separation, as controllers shall just propagate request params from HTTP to application layer with a few lines of code. Therefore, they are joined into "routes" in this template.
If your route code does something more than preparing params, calling a service and preparing the response, then you should consider refactoring your code.
It's sometimes appealing to use PDF libraries like pdfkit
, and although they are great, the rapid change of business requirements or design usually leads to painful refactoring of the PDF generation code, so instead you should leverage the capability of browser engines to render HTML to PDF:
- Write your PDF as HTML template in
resources
folder - Write a service that will read the template and render it with eta
- Use Playwright to render HTML to PDF in a headless Chromium browser
- Cache when needed
- Inject the service into your application code where PDF has to be generated
MIT