@alis-kit/template-be
v0.3.0
Published
Base Template Rest API — Scaffold REST API with Fastify + Mongoose + Zod
Maintainers
Readme
alis-kit-template-be
Base template REST API — Fastify + Mongoose + Zod + JWT, dibangun di atas @alis-kit/mongoose dan @alis-kit/routers.
Table of Contents
- Quick Start
- Manual Setup
- Scripts
- Tech Stack
- Project Structure
- Environment Variables
- Defining Entities (
@alis-kit/mongoose) - Database Layer — What's Included
- Building Controllers (
@alis-kit/routers) - Putting It Together — Full Example
- Authentication (JWT)
- License
Quick Start
npx @alis-kit/template-be
cd my-project
npm run devWith Bun:
bunx @alis-kit/template-be
cd my-project
bun run dev:bunManual Setup
git clone <repo> my-project
cd my-project
npm install
cp .env.example .env
# edit .env with your config
npm run devScripts
| npm | bun | Description |
|-----|-----|-------------|
| npm run dev | bun run dev:bun | Development with watch |
| npm run build | bun run build:bun | Build for production |
| npm start | bun run start:bun | Start production server |
| npm run typecheck | bun run typecheck | TypeScript check |
| npm run lint | bun run lint | Lint source |
| npm run format | bun run format | Format source with Prettier |
| npm test | bun run test | Run the test suite (Vitest) |
Tech Stack
- Runtime: Node.js >=20 / Bun >=1.0
- Framework: Fastify v5
- ODM:
@alis-kit/mongoose(Mongoose v9 wrapper — decorator-based schema & repository) - Validation: Zod v4
- Decorators / Routing:
@alis-kit/routersv3
Project Structure
Layers are grouped by type, then by domain. Files are named <domain>.<layer>.ts.
my-project/
├── src/
│ ├── schemas/ # @Schema-decorated entities (one folder per domain)
│ │ ├── index.ts # Barrel — imported by app.ts so entities get registered
│ │ └── user/ # user, user.profile, user.token
│ ├── repositories/ # @Repository data access + *.query.ts (pure query builders)
│ │ └── user/
│ ├── models/
│ │ ├── types/<domain>/ # Zod: request payloads (IUser) & documents (IUserSchema)
│ │ ├── enums/<domain>/ # Domain enums (role, gender, ...)
│ │ └── filter/ # Zod search filters per collection
│ ├── controllers/ # @RestController-decorated route handlers
│ ├── config/ # env.ts, database.ts, server.ts
│ ├── utils/ # Small cross-domain helpers
│ ├── __tests__/ # *.spec.ts
│ ├── app.ts # Bootstrap: register entities → connect DB → serve
│ └── polyfill.ts # Symbol.metadata polyfill for decorators
├── .env.example
├── package.json
└── tsconfig.jsonTwo conventions worth knowing before you add code:
- Imports use the
@/alias and keep the.jsextension (NodeNext), e.g.import User from "@/schemas/user/user.schema.js".tsc-aliasresolves it on build;vitest.config.tsmaps it for tests. - A
@Schemaclass only registers when its file is imported. Add every new entity tosrc/schemas/index.ts, whichapp.tsimports on startup.
Environment Variables
# .env.example
# ─── Runtime ─────────────────────────────────────────────────────────
NODE_ENV=development
PORT=5000
HOST=0.0.0.0
LOG_LEVEL=info
# ─── CORS ────────────────────────────────────────────────────────────
# Comma-separated allowlist untuk production. Dev boleh "*".
CORS_ORIGIN=*
....look in file .env.example
Defining Entities (@alis-kit/mongoose)
Entities are defined with @Schema, validated at runtime with Zod, and exposed through a @Repository class. See the @alis-kit/mongoose docs for the full API (relations, TTL, soft delete, indexes).
Field names are stored verbatim. The generated Mongoose schema is
strict: falseand only registers theBaseEntityfields, so a property nameduserIdis stored asuserId— there is no camelCase → snake_case mapping.@Index,@Relation({ foreignField }), andSearchCustom.of()must all use the exact same spelling, otherwise you get an index on a field that does not exist and relations that always resolve tonull.
// src/schemas/user/user.schema.ts — the entity (collection definition)
import { BaseEntity, Index, Relation, Schema } from "@alis-kit/mongoose";
import { type IUserProfileSchema } from "@/models/types/user/user.profile.type.js";
@Index({ email: 1 }, { unique: true })
@Schema({ collection: "users", timestamps: true, idStrategy: "uuid" })
export default class User extends BaseEntity {
email!: string;
password!: string;
isActive!: boolean;
// Inverse one-to-one: the profile holds `userId`, so the join runs
// users._id → user_profiles.userId.
@Relation({ collection: "user_profiles", localField: "_id", foreignField: "userId" })
profile!: IUserProfileSchema | null;
}// src/models/types/user/user.type.ts — Zod shape of the stored document
import { z } from "zod";
import { BaseEntitySchema } from "@alis-kit/mongoose";
export const IUserSchema = BaseEntitySchema.extend({
email: z.string(),
password: z.string(),
isActive: z.boolean(),
});
export type IUserSchema = z.infer<typeof IUserSchema>;// src/repositories/user/user.repository.ts — data access
import { BaseRepository, Repository } from "@alis-kit/mongoose";
import User from "@/schemas/user/user.schema.js";
import { type IUserSchema } from "@/models/types/user/user.type.js";
@Repository(User)
export default class UserRepository extends BaseRepository<IUserSchema> {}Database Layer — What's Included
The template ships with a working user domain you can copy for your own
domains. It covers the data layer only — services, routes, and password hashing
are intentionally left to you.
| Collection | Entity | Holds |
|---|---|---|
| users | schemas/user/user.schema.ts | Login credentials (email, password hash, isActive) |
| user_profiles | schemas/user/user.profile.schema.ts | Personal data, one document per user |
| user_tokens | schemas/user/user.token.schema.ts | One document per active refresh-token session |
const users = new UserRepository();
// Paginated list. `profile` is joined automatically (eager relation);
// the password hash is never included.
const page = await users.search({ email: "budi", isActive: true }, Pageable.of(1, 10));
// The only read that carries the password hash — for the login flow.
const account = await users.findByEmailForAuth("[email protected]");
// Searching by name goes through the profile repository (see note below).
const profiles = new UserProfileRepository();
await profiles.search({ keyword: "budi", role: UserRole.ADMIN }, Pageable.of(1, 10));
// Sessions: issue on login, revoke on logout, revoke all on password change.
const sessions = new UserTokenRepository();
await sessions.issue(user._id, rawRefreshToken, expiresAt);
await sessions.revokeAllByUser(user._id);Conventions this layer follows — worth keeping when you add a domain:
- Query building lives in
*.query.tsas pure functions, separate from the repository. They are testable without a database, and filters that areundefinedare skipped — passing them through would produce{ field: undefined }and silently match nothing. - Search by a child collection's fields runs on that collection. The kit's
OPERATION_JOIN_*operations only join forward (a local field holding the target's_id), so an inverse relation like user → profile cannot be filtered from theusersside. Name search therefore lives inUserProfileRepository, where the fields are local and indexed. - Secrets stay out of reads. Every
UserRepositoryread projectsUSER_PUBLIC_FIELDS; refresh tokens are stored as a SHA-256 hash, access tokens are not stored at all, and the session relation islazyso it never rides along with a user read. - Expiry and deletion are built in.
expireAt(fromBaseEntity) has a TTL index, so expired sessions clean themselves up; logout is a soft delete.
Entity files can't be imported from tests — Vitest's bundled esbuild does not transform TC39 decorators yet. Test the
*.query.tsmodules, filters, and utils; verify decorator wiring againstdist/afternpm run build.
Building Controllers (@alis-kit/routers)
Controllers use TC39 native decorators to define routes, request validation, and Swagger metadata. See the @alis-kit/routers docs for the full API (auth, cookies, exceptions, response envelopes).
// src/controllers/user.controller.ts
import { z } from "zod";
import {
RestController, Authentication, Tag, Description,
GetMapping, PostMapping,
ReqBody, ReqQuery, ReqParam,
Response, HttpStatus,
} from "@alis-kit/routers";
import { Pageable } from "@alis-kit/mongoose";
import UserRepository from "@/repositories/user/user.repository.js";
const CreateUserSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.email(), // Zod v4 — not z.string().email()
password: z.string().min(8),
});
const PaginationSchema = z.object({
page: z.coerce.number().default(1),
size: z.coerce.number().default(10),
});
@RestController("/users")
@Authentication("auth")
@Tag("User Management")
@Description("User management CRUD")
export class UserController {
private userRepo = new UserRepository();
@GetMapping("/")
@Description("List users with pagination")
@ReqQuery(PaginationSchema)
@Response(200, "List of users")
async getAll(query: z.infer<typeof PaginationSchema>) {
return this.userRepo.findAll(undefined, Pageable.of(query.page, query.size));
}
@GetMapping("/:id")
@Description("Get user detail by ID")
@ReqParam("id")
@Response(200, "User detail")
@Response(404, "User not found")
async getById(id: string) {
return this.userRepo.findById(id);
}
@PostMapping("/")
@HttpStatus(201)
@ReqBody(CreateUserSchema)
@Response(201, "User created successfully")
async create(body: z.infer<typeof CreateUserSchema>) {
return this.userRepo.save(body, { actorId: "system" });
}
}Putting It Together — Full Example
The template already wires this up across three files, so adding a domain means touching two of them:
// src/app.ts — registers entities, connects to MongoDB, then serves
import { env } from "@/config/env.js";
import Server from "@/config/server.js";
import { ConnectDatabase, DisconnectDatabase } from "@/config/database.js";
import "@/schemas/index.js"; // ← every entity must be imported to be registered
async function bootstrap(): Promise<void> {
await ConnectDatabase();
const app = await Server();
await app.listen({ host: env.HOST, port: env.PORT });
// ... graceful shutdown on SIGTERM/SIGINT
}
void bootstrap();// src/config/server.ts — Fastify, plugins, error handler, route registration
RouterKit.setup({
framework: "fastify",
app,
responseEnvelope: "raw",
globalPrefix: "/api",
});
RouterKit.register(HealthController /*, UserController, ... */);
RouterKit.handleNotFound();So a new domain is: add the entity to src/schemas/index.ts, and add the
controller to RouterKit.register(...).
Authentication (JWT)
Register an auth middleware with RouterKit.setup() so routes marked with @Authentication("auth") are protected automatically:
import jwt from "jsonwebtoken";
RouterKit.setup({
framework: "fastify",
app,
authMiddleware: async (req) => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) throw new Error("Missing token");
return jwt.verify(token, env.JWT_ACCESS_SECRET);
},
});Issue tokens on login (e.g. inside an AuthController), and record the refresh
token as a session so it can be revoked later:
import jwt from "jsonwebtoken";
import { env } from "@/config/env.js";
import UserTokenRepository from "@/repositories/user/user.token.repository.js";
const accessToken = jwt.sign({ sub: user._id, email: user.email }, env.JWT_ACCESS_SECRET, {
expiresIn: env.JWT_ACCESS_TTL,
});
const refreshToken = jwt.sign({ sub: user._id }, env.JWT_REFRESH_SECRET, {
expiresIn: env.JWT_REFRESH_TTL,
});
// Stored as a SHA-256 hash; the raw token only ever lives in the httpOnly cookie.
await new UserTokenRepository().issue(user._id, refreshToken, expiresAt);Password hashing is not included — hash with argon2 or bcrypt in your service layer before calling
save().utils/hash.util.tsis for refresh tokens only, never for passwords.
License
MIT
