A complete, hands-on walkthrough of building a production-ready REST API with AdonisJS 7.x (Node.js 24 LTS, TypeScript 5), organized into Git branches that progressively cover the key concepts of the AdonisJS ecosystem.
The data model follows: categories -> products -> customers -> orders, secured by a full JWT authentication system based on the EER_AUTH diagram.
This document is the complete specification of the project. It is meant to be followed step by step, branch by branch.
- AdonisJS vs NestJS - Quick Reference
- AdonisJS Glossary
- Tech Stack
- Data Model
- Auth Model (EER_AUTH)
- Branching Strategy
- Project Structure
- Standard Response Format
- Environment Variables Reference
- Git Commit Convention
- feature/core-architecture
- feature/data-modeling
- feature/auth
- feature/categories
- feature/products
- feature/customers
- feature/orders
- Order of Work
- Code Conventions
- Concepts Covered
- How to Follow This Tutorial
- Troubleshooting
If you are coming from a NestJS background, this table maps the concepts you already know to their AdonisJS equivalents.
| NestJS concept | AdonisJS equivalent | Notes |
|---|---|---|
@Module() |
No module system | AdonisJS uses a flat IoC container; all services are registered globally |
@Injectable() / DI |
@inject() decorator |
Constructor injection via the IoC container |
TypeORM @Entity() |
Lucid BaseModel |
ActiveRecord pattern instead of DataMapper |
TypeORM Repository<T> |
Model + QueryBuilder | No repository class needed; query directly on the model |
@Column() |
@column() |
Lucid maps snake_case DB columns to camelCase properties automatically |
synchronize: true |
Never available | Lucid only supports explicit migrations |
class-validator DTO |
VineJS validator | Schemas defined in app/validators/; no decorator-based classes |
ValidationPipe |
Built-in via VineJS | request.validateUsing(schema) in the controller |
@nestjs/passport guard |
@adonisjs/auth guard |
JWT guard configured in config/auth.ts |
@UseGuards(RolesGuard) |
Named middleware role() |
Applied per-route in start/routes.ts |
@Roles() decorator |
middleware.role('ADMIN') |
Passed as a route-level middleware argument |
AuthGuard('jwt') |
middleware.auth() |
Named middleware registered in start/kernel.ts |
GlobalExceptionFilter |
app/exceptions/handler.ts |
Same concept, different file location |
TransformInterceptor |
respond() helper |
A plain function wrapping the response object |
EventEmitter2 |
@adonisjs/emitter |
Listeners registered in adonisrc.ts via preloads |
@nestjs/cache-manager |
@adonisjs/cache |
Same cache-aside pattern |
jest.config.js |
tests/bootstrap.ts |
Japa is the built-in test runner |
| Supertest | @japa/api-client |
Integrated with the app lifecycle |
nest-cli.json |
adonisrc.ts |
Central app config: providers, preloads, aliases |
Key terms you will encounter throughout this tutorial.
adonisrc.ts - The central application manifest. Declares providers, preloads (files loaded at boot), command aliases, and test suite configuration. Equivalent to app.module.ts in terms of "what gets loaded at startup", but declarative rather than class-based.
Ace CLI - The AdonisJS command-line tool (node ace). Used to run migrations (node ace migration:run), generate files (node ace make:controller), run tests (node ace test), and start the server (node ace serve).
Provider - A class that runs code during the application boot lifecycle (register, boot, start, ready, shutdown). Used to bind services into the IoC container or set up third-party libraries. Declared in adonisrc.ts.
Preload - A file that is imported and executed automatically at application startup, before the HTTP server starts. start/routes.ts, start/kernel.ts, and start/env.ts are all preloads. Listener registrations also live in a preload.
IoC Container - AdonisJS's built-in dependency injection container. Resolves constructor dependencies automatically when @inject() is applied to a class. No need to declare providers in a module - any class decorated with @inject() is resolvable.
start/env.ts - The single source of truth for environment variables. Variables are declared and validated here using VineJS at startup. Always import env from this file - never use process.env directly.
start/kernel.ts - Where global and named middleware are registered. Named middleware (e.g. auth, role) are referenced by name in route definitions. Think of it as the equivalent of app.use() and the NestJS APP_GUARD pattern combined.
start/routes.ts - Where all route definitions live. Routes reference controllers, middleware, and route groups. There is one routes file for the whole application (split by convention with router.group() or by importing sub-route files).
Lucid - The AdonisJS ORM. Uses the ActiveRecord pattern: models have both data properties and query methods (Category.all(), Category.find(id), category.save()). Relations are declared as decorated methods.
VineJS - AdonisJS's validation library. Schemas are plain objects (not decorated classes). request.validateUsing(schema) both validates and returns a fully typed object.
Bouncer - AdonisJS's authorization library (@adonisjs/bouncer). Defines policies as classes with methods named after actions (view, create, update, delete). More expressive than role middleware alone.
Japa - AdonisJS's built-in test runner. Tests are structured with test.group(), lifecycle hooks (group.setup, group.teardown, group.each.setup), and assertions via @japa/assert. @japa/api-client provides HTTP test helpers against the running app.
| Component | Choice |
|---|---|
| Framework | AdonisJS 7.x |
| Language | TypeScript 5.x |
| Runtime | Node.js 24 LTS |
| Build | @adonisjs/core + ace CLI |
| Database | PostgreSQL 16 (via Docker Compose) |
| Migrations | Lucid migrations (never sync) |
| ORM | Lucid ORM (built-in AdonisJS) |
| Validation | VineJS (built-in AdonisJS 7) |
| API documentation | adonis-autoswagger |
| Config management | AdonisJS env + config/ factories with VineJS validation |
| Security | @adonisjs/auth + JWT guard |
| Password hashing | @adonisjs/core hash service (Argon2 / bcrypt) |
| Rate limiting | @adonisjs/limiter (on auth routes) |
| Logging | AdonisJS built-in logger (Pino) |
| Caching | @adonisjs/cache |
| Tests | Japa + @japa/api-client + @japa/assert |
| CI/CD | GitHub Actions |
| Containerization | Docker, docker-compose |
| Linting | ESLint + Prettier |
| Hooks | Husky + lint-staged |
categories (id, category_name)
| 1
|
| N
products (id, category_id, product_name, unit_price)
| 1
|
| N
orders (id, customer_id, product_id, quantity, total)
| N
|
| 1
customers (id, first_name, last_name, telephone, email, address)
categories
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK, auto-increment |
| category_name | VARCHAR(255) | NOT NULL |
products
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK, auto-increment |
| category_id | BIGINT | FK -> categories.id, NOT NULL |
| product_name | VARCHAR(255) | NOT NULL |
| unit_price | FLOAT | NOT NULL, > 0 |
customers
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK, auto-increment |
| first_name | VARCHAR(255) | NOT NULL |
| last_name | VARCHAR(255) | NOT NULL |
| telephone | VARCHAR(50) | NOT NULL |
| VARCHAR(255) | NOT NULL, UNIQUE | |
| address | VARCHAR(255) | NOT NULL |
orders
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK, auto-increment |
| customer_id | BIGINT | FK -> customers.id, NOT NULL |
| product_id | BIGINT | FK -> products.id, NOT NULL |
| quantity | INT | NOT NULL, > 0 |
| total | FLOAT | NOT NULL, computed = quantity x unit_price |
Based on the EER_AUTH diagram, the authentication and authorization system uses the following tables:
users
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK |
| first_name | VARCHAR(50) | NOT NULL |
| last_name | VARCHAR(100) | NOT NULL |
| VARCHAR(100) | NOT NULL, UNIQUE | |
| password | VARCHAR(255) | |
| enabled | BOOLEAN | NOT NULL |
| account_locked | BOOLEAN | NOT NULL |
roles
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK |
| role_name | VARCHAR(50) | NOT NULL, UNIQUE |
permissions
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK |
| resource | VARCHAR(50) | NOT NULL |
| action | VARCHAR(50) | NOT NULL |
role_user (pivot table)
| Column | Type | Constraints |
|---|---|---|
| user_id | BIGINT | FK -> users.id, NOT NULL |
| role_id | BIGINT | FK -> roles.id, NOT NULL |
role_permission (pivot table)
| Column | Type | Constraints |
|---|---|---|
| role_id | BIGINT | FK -> roles.id, NOT NULL |
| permission_id | BIGINT | FK -> permissions.id, NOT NULL |
activation_tokens
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK |
| user_id | BIGINT | FK -> users.id |
| token | VARCHAR(255) | |
| created_at | DATETIME | NOT NULL |
| expires_at | DATETIME | |
| validated_at | DATETIME |
blacklisted_tokens
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK |
| user_id | BIGINT | FK |
| token | VARCHAR(768) | NOT NULL |
| jti | VARCHAR(255) | UNIQUE |
| blacklisted_at | DATETIME | |
| created_at | DATETIME | NOT NULL |
| expires_at | DATETIME | |
| validated_at | DATETIME |
password_reset_tokens
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK |
| user_id | BIGINT | FK -> users.id |
| token | VARCHAR(255) | NOT NULL |
| type | VARCHAR(255) | NOT NULL |
| expiry_date | DATETIME | NOT NULL |
| Resource | GET | POST / PUT / DELETE |
|---|---|---|
categories, products |
Public | ADMIN role only |
customers, orders |
Authenticated | ADMIN role only |
auth/* |
Public | - |
| Branch | Role |
|---|---|
master |
Stable, production-ready. No direct commits - only merges from develop. |
develop |
Integration branch. All feature/* branches merge here via PR before going to master. |
feature/core-architecture |
Project scaffold, global config, Docker, CI/CD pipeline, Husky hooks. |
feature/data-modeling |
All Lucid models, migrations, seeds, and database connection. |
feature/auth |
Full auth: JWT, roles, permissions, token blacklist, activation, password reset. |
feature/categories |
Category CRUD - secured from day one with AuthMiddleware + RoleMiddleware. |
feature/products |
Product CRUD, linked to categories - secured from day one. |
feature/customers |
Customer CRUD - secured from day one. |
feature/orders |
Order CRUD, business logic (total computation, events) - secured from day one. |
Each feature branch ends with a Pull Request to develop. Each PR must include atomic commits (one per file), a completed task list, and passing unit + functional tests.
Convention: test files mirror the
app/structure undertests/unit/. Functional (E2E) tests live intests/functional/.
adonisjs_tutorial/
├── .github/
│ ├── workflows/
│ │ ├── ci.yml # GitHub Actions CI pipeline
│ │ └── pr-checks.yml # Lint, test, build on PRs
│ └── PULL_REQUEST_TEMPLATE.md
├── .husky/
│ ├── pre-commit # lint-staged
│ ├── commit-msg # commitlint
│ └── pre-push # node ace test
├── docker/
│ └── postgres/
│ └── init.sql
├── docker-compose.yml
├── docker-compose.test.yml
├── tests/
│ ├── bootstrap.ts # Japa bootstrap (app lifecycle)
│ ├── helpers/
│ │ └── auth_helper.ts # Shared login/token helpers for functional tests
│ ├── functional/ # API-level tests (real DB)
│ │ ├── health.spec.ts
│ │ ├── categories.spec.ts
│ │ ├── products.spec.ts
│ │ ├── customers.spec.ts
│ │ ├── orders.spec.ts
│ │ └── auth.spec.ts
│ └── unit/ # Unit tests (mocked dependencies)
│ ├── services/
│ │ ├── categories_service.spec.ts
│ │ ├── products_service.spec.ts
│ │ ├── customers_service.spec.ts
│ │ ├── orders_service.spec.ts
│ │ └── auth_service.spec.ts
│ └── validators/
│ ├── create_category_validator.spec.ts
│ └── create_product_validator.spec.ts
├── .env.example # Documented variable reference (see below)
├── .env.development
├── .env.test
├── commitlint.config.js
├── .eslintrc.js
├── .prettierrc
├── adonisrc.ts # AdonisJS app config: providers, preloads, aliases
├── ace.js # Ace CLI entry point
├── tsconfig.json
├── package.json
└── app/
├── controllers/
│ ├── health_controller.ts
│ ├── categories_controller.ts
│ ├── products_controller.ts
│ ├── customers_controller.ts
│ ├── orders_controller.ts
│ └── auth_controller.ts
├── models/ # Lucid ORM models
│ ├── category.ts
│ ├── product.ts
│ ├── customer.ts
│ ├── order.ts
│ ├── user.ts
│ ├── role.ts
│ ├── permission.ts
│ ├── activation_token.ts
│ ├── blacklisted_token.ts
│ └── password_reset_token.ts
├── services/
│ ├── categories_service.ts
│ ├── products_service.ts
│ ├── customers_service.ts
│ ├── orders_service.ts
│ └── auth_service.ts
├── validators/ # VineJS validators (replaces DTOs)
│ ├── pagination_validator.ts
│ ├── category_validator.ts
│ ├── product_validator.ts
│ ├── customer_validator.ts
│ ├── order_validator.ts
│ └── auth_validator.ts
├── middleware/
│ ├── auth_middleware.ts # JWT auth check
│ ├── role_middleware.ts # RBAC role enforcement
│ ├── silent_auth_middleware.ts
│ └── response_time_middleware.ts
├── policies/ # Bouncer authorization policies
│ ├── category_policy.ts
│ ├── product_policy.ts
│ ├── customer_policy.ts
│ └── order_policy.ts
├── helpers/
│ ├── api_response.ts # ApiResponse<T> interface + respond() helper
│ └── page_response.ts # PageResponse<T> interface
├── exceptions/
│ └── handler.ts # Global exception handler
└── events/
├── order_created.ts # Event definition
└── listeners/
└── send_order_notification.ts # Event listener
├── config/
│ ├── app.ts
│ ├── auth.ts # JWT guard config
│ ├── database.ts
│ ├── hash.ts
│ ├── limiter.ts # Rate limiting config
│ ├── logger.ts
│ └── cache.ts
├── database/
│ ├── migrations/
│ │ ├── 1700000001_create_core_schema.ts
│ │ └── 1700000002_create_auth_schema.ts
│ └── seeders/
│ ├── main_seeder.ts
│ ├── category_seeder.ts
│ ├── product_seeder.ts
│ ├── customer_seeder.ts
│ └── user_seeder.ts
├── start/
│ ├── kernel.ts # Global + named middleware registration
│ ├── routes.ts # Route definitions
│ ├── events.ts # Event -> listener bindings (preload)
│ └── env.ts # Env validation with VineJS
└── providers/
└── app_provider.ts
| Convention | Source |
|---|---|
app/controllers/ for all controllers |
AdonisJS 7 default scaffold |
app/models/ for Lucid models |
AdonisJS / Lucid documentation |
app/validators/ for VineJS schemas |
AdonisJS 7 official convention |
app/policies/ for Bouncer authorization |
@adonisjs/bouncer documentation |
app/middleware/ for HTTP middleware |
AdonisJS 7 official convention |
app/events/ + listeners/ |
AdonisJS emitter package convention |
start/events.ts as a dedicated preload |
AdonisJS emitter documentation (not kernel.ts) |
config/ for typed config factories |
AdonisJS 7 official convention |
database/migrations/ and seeders/ |
Lucid ORM documentation |
start/routes.ts for route definitions |
AdonisJS 7 official convention |
start/env.ts for env validation |
AdonisJS 7 official convention |
tests/functional/ for API-level tests |
Japa + @japa/api-client convention |
tests/unit/ for unit tests |
Japa convention |
tests/helpers/auth_helper.ts |
Shared test utilities to avoid duplication across specs |
AdonisJS 7 uses Japa as its built-in test runner, configured in tests/bootstrap.ts:
// tests/bootstrap.ts
import { configure, processCLIArgs, run } from '@japa/runner'
import { assert } from '@japa/assert'
import { apiClient } from '@japa/api-client'
processCLIArgs(process.argv.slice(2))
configure({
files: ['tests/**/*.spec.ts'],
plugins: [assert(), apiClient('http://localhost:3333')],
})
run()Typical functional test structure using group.setup / group.teardown:
// tests/functional/categories.spec.ts
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
test.group('Categories - CRUD', (group) => {
// Runs once before all tests in this group
group.setup(async () => {
await db.beginGlobalTransaction()
})
// Runs once after all tests - rolls back to keep DB clean
group.teardown(async () => {
await db.rollbackGlobalTransaction()
})
test('GET /api/v1/categories returns paginated list', async ({ client }) => {
const response = await client.get('/api/v1/categories')
response.assertStatus(200)
response.assertBodyContains({ success: true })
})
})Run tests with:
node ace test # all tests
node ace test --files="tests/functional/categories.spec.ts"
node ace test unit # unit tests only
node ace test functional # functional tests onlyEvery endpoint - success or error - returns a consistent ApiResponse<T> envelope:
// app/helpers/api_response.ts
export interface ApiResponse<T> {
success: boolean
message: string
data?: T
errors?: string[]
timestamp: string
path?: string
}
export function respond<T>(data: T, message = 'OK', statusCode = 200) {
return {
success: true,
message,
data,
timestamp: new Date().toISOString(),
}
}Paginated list endpoints wrap their data in PageResponse<T>, which maps directly from Lucid's ModelPaginatorContract:
// app/helpers/page_response.ts
export interface PageResponse<T> {
items: T[]
total: number
page: number
limit: number
totalPages: number
hasNext: boolean
hasPrevious: boolean
}The global exception handler in app/exceptions/handler.ts wraps every thrown exception in the same ApiResponse shape with success: false.
The .env.example file is the documented contract for all environment variables. Every variable must be present and validated in start/env.ts before the server starts.
# ── Application ──────────────────────────────────────────
HOST=0.0.0.0 # Server bind address
PORT=3333 # Server port
APP_KEY= # 32-char random key used for encryption (generate with: node ace generate:key)
NODE_ENV=development # development | production | test
# ── Database ─────────────────────────────────────────────
DB_HOST=127.0.0.1 # PostgreSQL host
DB_PORT=5432 # PostgreSQL port
DB_USER=postgres # PostgreSQL user
DB_PASSWORD= # PostgreSQL password
DB_DATABASE=adonisjs_tutorial # Database name (use adonisjs_tutorial_test for .env.test)
# ── Authentication ────────────────────────────────────────
JWT_SECRET= # Secret used to sign JWT tokens (min 32 chars)
JWT_EXPIRY=7d # Token lifespan (e.g. 7d, 24h, 3600)
# ── Hashing ───────────────────────────────────────────────
HASH_DRIVER=argon # argon | bcrypt
# ── Rate limiting ─────────────────────────────────────────
LIMITER_STORE=database # database | redis
THROTTLE_AUTH_MAX=10 # Max auth attempts per window
THROTTLE_AUTH_WINDOW=60 # Window in secondsAll commits follow Conventional Commits enforced by commitlint + Husky.
<type>(<scope>): <short summary>
<body - what was done and why, one sentence per file touched>
<footer - refs, breaking changes>
| Type | When to use |
|---|---|
feat |
New feature or file |
fix |
Bug fix |
refactor |
Code change that is neither a bug fix nor a feature |
test |
Adding or updating tests |
docs |
Documentation only |
chore |
Tooling, config, CI, deps |
style |
Formatting, linting (no logic change) |
perf |
Performance improvement |
One commit per file added or modified. Never group unrelated files in a single commit.
Good:
feat(categories): add Category Lucid model
- Defines the Category Lucid model with id and category_name columns.
- Declares hasMany relation to Product.
- Uses @column() for snake_case to camelCase mapping.
Bad:
feat: add categories module with model, validator, service and controller
Set up the technical foundation - AdonisJS scaffold, config, Docker, CI, hooks. No business logic.
- How AdonisJS 7 is initialized (
npm init adonisjs@latest) and what the--kit=apiscaffold generates - The role of
adonisrc.tsas the application manifest (providers, preloads, aliases) - How
start/env.tsvalidates environment variables at startup using VineJS - the app will refuse to boot if a required variable is missing or malformed - How
start/kernel.tsregisters global middleware (applied to every request) vs named middleware (applied per route) - How AdonisJS's global exception handler works and how to format all error responses uniformly
- How to structure a
respond()helper to keep controllers thin
Named middleware registration in start/kernel.ts:
// start/kernel.ts
import router from '@adonisjs/core/services/router'
export const middleware = router.named({
auth: () => import('#middleware/auth_middleware'),
role: () => import('#middleware/role_middleware'),
guest: () => import('#middleware/guest_middleware'),
})Env validation in start/env.ts:
// start/env.ts
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
HOST: Env.schema.string({ format: 'host' }),
PORT: Env.schema.number(),
APP_KEY: Env.schema.string(),
JWT_SECRET: Env.schema.string(),
JWT_EXPIRY: Env.schema.string(),
DB_HOST: Env.schema.string({ format: 'host' }),
DB_PORT: Env.schema.number(),
DB_USER: Env.schema.string(),
DB_PASSWORD: Env.schema.string.optional(),
DB_DATABASE: Env.schema.string(),
})- Initialize AdonisJS project with
npm init adonisjs@latest adonisjs_tutorial -- --kit=api - Configure ESLint + Prettier (strict TypeScript rules)
- Set up
commitlint.config.jswith Conventional Commits preset - Install and configure Husky (
pre-commit,commit-msg,pre-push) - Configure
lint-stagedinpackage.json - Create
docker-compose.ymlwith PostgreSQL 16 service and a named volume - Create
docker-compose.test.ymlfor an isolated test database on a different port - Create
.env.example(fully documented, see Environment Variables Reference),.env.development,.env.test - Define and validate all env variables in
start/env.tswith VineJS - Create typed config factories:
config/app.ts,config/database.ts,config/auth.ts - Implement
ApiResponse<T>interface andrespond()helper inapp/helpers/api_response.ts - Implement
PageResponse<T>interface inapp/helpers/page_response.ts - Implement global exception handler in
app/exceptions/handler.ts(wraps all errors inApiResponseshape withsuccess: false) - Implement
ResponseTimeMiddlewareand register it globally instart/kernel.ts - Register
authandroleas named middleware instart/kernel.ts(stubs - implementation comes infeature/auth) - Configure
adonis-autoswaggerfor API documentation, expose/swaggerand/docs - Install every dependency the whole project's Tech Stack needs, not just this branch's own:
pg,@adonisjs/bouncer,@adonisjs/limiter,@adonisjs/mail,@adonisjs/cache,argon2,jose,adonis-autoswagger, so no later branch needs a mid-branch install - Configure
@adonisjs/bouncer,@adonisjs/limiter(database store) and@adonisjs/mail(SMTP against Mailhog) with their base scaffolding; the app-specific wiring (the JWT guard, Argon2 as the active hasher, real policies) still belongs to the branch that owns that behavior - Configure
@adonisjs/cachewith an in-memory L1 layer and a database L2 layer, forfeature/categoriesto use on its list endpoint - Add Mailhog to both
docker-compose.ymlanddocker-compose.test.yml, and to both CI workflows - Add
GET /api/v1/healthhealth-check route - Set up GitHub Actions
ci.ymlandpr-checks.yml - Functional test:
GET /api/v1/healthreturns 200 withApiResponseshape - Functional test: unknown route returns 404 with
ApiResponseshape
Define all Lucid models, run migrations, and seed test data - including a ready-to-use ADMIN and USER account so every subsequent CRUD branch can authenticate in functional tests without depending on feature/auth being merged first.
- The Lucid ActiveRecord pattern: models hold both data and query methods (
Category.find(),category.save()) - How Lucid maps
snake_casedatabase columns tocamelCaseTypeScript properties via@column() - How to declare relations (
@hasMany,@belongsTo,@manyToMany) and when to usepreload()vs lazy loading - How to use
serializeExtras: trueon paginated queries to exposemeta(total, perPage, currentPage) alongside rows - How to use a
@beforeSavehook on theUsermodel to hash passwords transparently - How to write migration files and run them with
node ace migration:run/node ace migration:rollback - The difference between
MainSeeder(orchestrator) and domain seeders (data factories)
Column mapping and @beforeSave hook on the User model:
// app/models/user.ts
import { BaseModel, beforeSave, column, manyToMany } from '@adonisjs/lucid/orm'
import type { ManyToMany } from '@adonisjs/lucid/types/relations'
import hash from '@adonisjs/core/services/hash'
import Role from '#models/role'
export default class User extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare firstName: string // maps to first_name in DB
@column()
declare lastName: string
@column()
declare email: string
@column({ serializeUsing: () => undefined }) // never expose password in responses
declare password: string
@column()
declare enabled: boolean
@column()
declare accountLocked: boolean
@manyToMany(() => Role, { pivotTable: 'role_user' })
declare roles: ManyToMany<typeof Role>
@beforeSave()
static async hashPassword(user: User) {
if (user.$dirty.password) {
user.password = await hash.make(user.password)
}
}
}Paginated query with serializeExtras:
// app/services/categories_service.ts (preview)
const categories = await Category.query().paginate(page, limit)
categories.baseUrl('/api/v1/categories')
return categories.serialize({ extra: true })
// Produces: { data: [...], meta: { total, perPage, currentPage, lastPage, ... } }- Install and configure
@adonisjs/lucidwith thepgPostgreSQL driver - Create
Categorymodel (id,categoryName,@hasMany(() => Product)) - Create
Productmodel (id,categoryId,productName,unitPrice,@belongsTo(() => Category)) - Create
Customermodel (id,firstName,lastName,telephone,email,address) - Create
Ordermodel (id,customerId,productId,quantity,total,@belongsTo(() => Customer),@belongsTo(() => Product)) - Create
Usermodel with@beforeSavepassword hashing hook and@column({ serializeUsing: () => undefined })onpassword - Create
Rolemodel with@manyToMany(() => User, { pivotTable: 'role_user' })and@manyToMany(() => Permission, { pivotTable: 'role_permission' }) - Create
Permissionmodel (resource,action) - Create
ActivationTokenmodel - Create
BlacklistedTokenmodel - Create
PasswordResetTokenmodel - Generate migration
1700000001_create_core_schema(categories, products, customers, orders) - Generate migration
1700000002_create_auth_schema(users, roles, permissions, pivot tables, token tables) - Create
CategorySeeder,ProductSeeder,CustomerSeederwith realistic sample data - Create
UserSeederthat creates two test accounts seeded with known credentials:admin@example.com/Admin1234!with roleADMINuser@example.com/User1234!with roleUSER
- Create
MainSeederthat runs all seeders in order - Store seeded credentials in
.env.testasTEST_ADMIN_EMAIL,TEST_ADMIN_PASSWORD,TEST_USER_EMAIL,TEST_USER_PASSWORDso functional tests can read them without hardcoding - Verify
synchronizeis never set - only migrations touch the schema - Test:
CategorymodelhasManyrelation returns associated products - Test:
Ordermodeltotalis correctly stored and retrieved - Test:
User.beforeSavehook hashes the password before insert and on password change only - Test: migration runs and rolls back without error
Full authentication and authorization based on the EER_AUTH diagram - JWT, roles, permissions, token blacklisting, account activation, and password reset. This branch is built before any resource module so that AuthMiddleware and RoleMiddleware are available from the first CRUD slice.
- How
@adonisjs/authworks: the JWT guard, how tokens are issued, and howauth.authenticate()resolves the current user - How to write a named middleware that reads
auth.userand checks loaded relations - and why you mustpreload('roles')before the check - How to blacklist JWTs by storing the
jticlaim and rejecting it on every authenticated request - How
@adonisjs/bouncerpolicies work and the difference between a policy action and a role check - How
@adonisjs/limiterapplies rate limits per IP on sensitive routes - The full account lifecycle: register -> activate (email token) -> login -> logout -> forgot password -> reset password
Named middleware receiving arguments (role_middleware.ts):
// app/middleware/role_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class RoleMiddleware {
async handle(ctx: HttpContext, next: NextFn, options: { roles: string[] }) {
await ctx.auth.authenticate()
const user = ctx.auth.user!
await user.load('roles') // preload relation - auth.user does NOT auto-load relations
const hasRole = user.roles.some((r) => options.roles.includes(r.roleName))
if (!hasRole) {
return ctx.response.forbidden({ success: false, message: 'Insufficient role' })
}
return next()
}
}JWT guard hook to reject blacklisted tokens:
// app/middleware/auth_middleware.ts (relevant excerpt)
import BlacklistedToken from '#models/blacklisted_token'
// After jwt.authenticate(), check if jti is blacklisted
const payload = ctx.auth.use('jwt').payload!
const jti = payload.jti as string
const isBlacklisted = await BlacklistedToken.findBy('jti', jti)
if (isBlacklisted) {
return ctx.response.unauthorized({ success: false, message: 'Token has been revoked' })
}Rate limiting on auth routes:
// start/routes.ts (auth group excerpt)
import { throttle } from '@adonisjs/limiter'
router.group(() => {
router.post('/register', [AuthController, 'register'])
router.post('/login', [AuthController, 'login'])
router.post('/forgot-password', [AuthController, 'forgotPassword'])
}).prefix('/api/v1/auth').use(throttle({ maxRequests: 10, windowMs: 60_000 }))| Method | URL | Description | Access |
|---|---|---|---|
| POST | /api/v1/auth/register |
Register a new user, sends activation token | Public |
| POST | /api/v1/auth/activate |
Activate account with token | Public |
| POST | /api/v1/auth/login |
Sign in, returns JWT | Public |
| POST | /api/v1/auth/logout |
Blacklist the current JWT | Authenticated |
| POST | /api/v1/auth/refresh |
Issue new JWT (if not blacklisted) | Authenticated |
| POST | /api/v1/auth/forgot-password |
Send password reset token | Public |
| POST | /api/v1/auth/reset-password |
Consume token, set new password | Public |
| GET | /api/v1/auth/me |
Current user profile | Authenticated |
User, role and permission administration. Registration always assigns the default USER role and nothing else in the app manages users, roles or permissions afterward, promoting an account to ADMIN, creating a new role, or changing what a role can do would otherwise only be possible by hand in the database. These endpoints close that gap, all ADMIN only since they operate on other accounts and on the authorization model itself.
| Method | URL | Description | Access |
|---|---|---|---|
| GET | /api/v1/users |
Paginated list of users | ADMIN |
| GET | /api/v1/users/:id |
User detail with roles preloaded | ADMIN |
| PUT | /api/v1/users/:id |
Update firstName, lastName, email, enabled, accountLocked |
ADMIN |
| DELETE | /api/v1/users/:id |
Delete a user | ADMIN |
| POST | /api/v1/users/:id/roles |
Assign a role to a user | ADMIN |
| DELETE | /api/v1/users/:id/roles/:roleId |
Revoke a role from a user | ADMIN |
| GET | /api/v1/roles |
Paginated list of roles | ADMIN |
| GET | /api/v1/roles/:id |
Role detail with permissions preloaded | ADMIN |
| POST | /api/v1/roles |
Create a role | ADMIN |
| PUT | /api/v1/roles/:id |
Update a role | ADMIN |
| DELETE | /api/v1/roles/:id |
Delete a role, blocked with 409 while any user still has it | ADMIN |
| POST | /api/v1/roles/:id/permissions |
Assign a permission to a role | ADMIN |
| DELETE | /api/v1/roles/:id/permissions/:permissionId |
Revoke a permission from a role | ADMIN |
| GET | /api/v1/permissions |
Paginated list of permissions | ADMIN |
| POST | /api/v1/permissions |
Create a permission (resource, action) |
ADMIN |
| PUT | /api/v1/permissions/:id |
Update a permission | ADMIN |
| DELETE | /api/v1/permissions/:id |
Delete a permission, blocked with 409 while any role still has it | ADMIN |
- Install and configure
@adonisjs/authwith the JWT guard; configureconfig/auth.ts - Install and configure
@adonisjs/bouncer; configure policies - Install and configure
@adonisjs/limiter; configureconfig/limiter.ts - Configure
config/hash.tswith Argon2 via thehashservice - Create
authValidator(VineJS):registerSchema(firstName, lastName, email, passwordminLength(8)+ complexity regex),loginSchema,resetPasswordSchema - Implement
AuthMiddleware: callauth.authenticate(), then checkjtiagainstblacklisted_tokens - Implement
RoleMiddleware: callauth.authenticate(), preloadrolesrelation, check role name against theoptions.rolesargument - Register both as named middleware in
start/kernel.ts - Implement
AuthService:register: create user (password hashed by@beforeSavehook), assign defaultUSERrole, generate and store activation tokenactivate: find and validate token (not expired, not already used), setenabled = true, mark token asvalidatedAtlogin: verify password withhash.verify(), checkenabledandaccountLocked, issue JWT with uniquejtilogout: writejtitoblacklisted_tokensforgotPassword: generate aPasswordResetToken, return the raw token (email delivery is a placeholder)resetPassword: validate token (type, expiry), update password (hashed by hook), delete tokenme: returnauth.userwith preloadedrolesandpermissions
- Apply rate limiting on
register,login,forgot-passwordroutes - Declare all auth routes in
start/routes.ts - Unit test:
registercreates user withUSERrole and activation token;activatesetsenabled = trueand marks token used;loginthrows for wrong password, locked account, inactive account;loginreturns token containingjti;logoutinsertsjtiintoblacklisted_tokens;AuthMiddlewarerejects a blacklistedjti - Functional test (full auth flow): register -> activate -> login ->
GET /auth/me-> logout ->GET /auth/mewith revoked token returns 401 - Functional test (middleware smoke): protected stub route returns 401 without token, 200 with valid
USERtoken; same route withrole('ADMIN')returns 403 forUSER, 200 forADMIN - Functional test (rate limiting): 11th login attempt within 60 s returns 429
- Create
userValidator(updateUserSchema),roleValidator(createRoleSchema,updateRoleSchema),permissionValidator(createPermissionSchema,updatePermissionSchema), all VineJS, allADMINonly - Implement
UsersService:findAll(page, limit),findOne(id),update(id, data),remove(id),assignRole(id, roleId),revokeRole(id, roleId);assignRole/revokeRoleare idempotent, no error re-assigning a role the user already has - Implement
RolesService:findAll(page, limit),findOne(id),create(data),update(id, data),remove(id),assignPermission(id, permissionId),revokePermission(id, permissionId) - Business rule: a role with at least one user still assigned cannot be deleted, throw HTTP 409
- Implement
PermissionsService:findAll(page, limit),findOne(id),create(data),update(id, data),remove(id) - Business rule: a permission still attached to at least one role cannot be deleted, throw HTTP 409
- Create
UsersController,RolesController,PermissionsController, thin,@inject(), calling their services and usingrespond() - Declare all administration routes under
middleware.auth()+middleware.role({ roles: ['ADMIN'] }) - Unit test:
UsersService.assignRoleattaches without duplicating an existing pivot row;RolesService.removethrows 409 when users are still assigned;PermissionsService.removethrows 409 when a role still has it - Functional test: full CRUD lifecycle for users, roles and permissions; assigning and revoking a role on a user changes what that user's JWT is authorized to do on a subsequent request; all responses match
ApiResponseshape - Functional test (auth): every administration route returns 401 without a token and 403 with a non
ADMINtoken
First complete vertical slice - Category CRUD with pagination, Swagger docs, and full tests. Authentication and authorization are applied directly in this branch using AuthMiddleware and RoleMiddleware from feature/auth. Functional tests authenticate using the seeded ADMIN and USER accounts from feature/data-modeling.
- How to write a VineJS validator and call
request.validateUsing(schema)in a controller - How Lucid's
.paginate(page, limit)works and how to map its output toPageResponse<T> - How to declare route-level middleware using named middleware arguments in
start/routes.ts - How to inject and use a service via the AdonisJS IoC container with
@inject() - How to structure unit tests with Japa: mocking service dependencies and asserting HTTP status codes
VineJS validator:
// app/validators/category_validator.ts
import vine from '@vinejs/vine'
export const createCategorySchema = vine.compile(
vine.object({
categoryName: vine.string().trim().maxLength(255),
})
)
export const updateCategorySchema = vine.compile(
vine.object({
categoryName: vine.string().trim().maxLength(255).optional(),
})
)Controller using @inject() and request.validateUsing():
// app/controllers/categories_controller.ts
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { createCategorySchema } from '#validators/category_validator'
import CategoriesService from '#services/categories_service'
import { respond } from '#helpers/api_response'
@inject()
export default class CategoriesController {
constructor(private categoriesService: CategoriesService) {}
async index({ request, response }: HttpContext) {
const { page, limit } = request.qs()
const result = await this.categoriesService.findAll(page ?? 1, limit ?? 10)
return response.ok(respond(result))
}
async store({ request, response }: HttpContext) {
const data = await request.validateUsing(createCategorySchema)
const category = await this.categoriesService.create(data)
return response.created(respond(category, 'Category created', 201))
}
}Route declaration with middleware:
// start/routes.ts (excerpt)
import { middleware } from '#start/kernel'
router.get('/api/v1/categories', [CategoriesController, 'index'])
router.get('/api/v1/categories/:id', [CategoriesController, 'show'])
router.post('/api/v1/categories', [CategoriesController, 'store'])
.use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })])
router.put('/api/v1/categories/:id', [CategoriesController, 'update'])
.use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })])
router.delete('/api/v1/categories/:id', [CategoriesController, 'destroy'])
.use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })])| Method | URL | Description | Access |
|---|---|---|---|
| GET | /api/v1/categories |
Paginated list of categories | Public |
| GET | /api/v1/categories/:id |
Category detail | Public |
| POST | /api/v1/categories |
Create a category | ADMIN |
| PUT | /api/v1/categories/:id |
Update a category | ADMIN |
| DELETE | /api/v1/categories/:id |
Delete a category | ADMIN |
- Create
createCategorySchemaandupdateCategorySchemainapp/validators/category_validator.ts - Create
CategoriesServicewithfindAll(page, limit),findOne(id),create(data),update(id, data),remove(id) - Use
Category.query().paginate(page, limit)and map toPageResponse<T> - Business rule: a category with associated products cannot be deleted - throw HTTP 409
- Create
CategoriesControllerwith@inject(), calling the service and usingrespond() - Declare routes with access rules applied immediately (see snippet above)
- Add Swagger JSDoc annotations on the controller
- Unit test:
findAllreturns paginated results;findOnereturns category for valid id;findOnethrows 404 for unknown id;createsaves and returns new category;updatemodifies existing category;removedeletes category with no products;removethrows 409 when category has products - Functional test: full CRUD lifecycle on real database; 409 on delete with products; all responses match
ApiResponseshape - Functional test (auth):
POST /categorieswithout token returns 401; withUSERtoken returns 403; withADMINtoken returns 201
Product CRUD linked to categories, with filtering by categoryId and sorting. Auth rules are applied directly.
- How to build a dynamic QueryBuilder query in Lucid: conditional
where,orderBy, andpreloadchaining - How to validate optional query parameters with VineJS (
vine.string().optional()) - How to eager-load a relation (
preload('category')) and include it in the serialized response - How to enforce a foreign-key existence check in the service layer before writing to the DB
Dynamic QueryBuilder with optional filters:
// app/services/products_service.ts (excerpt)
async findAll(filter: ProductFilter, page: number, limit: number) {
const query = Product.query().preload('category')
if (filter.categoryId) {
query.where('category_id', filter.categoryId)
}
if (filter.sortBy) {
query.orderBy(filter.sortBy, filter.order ?? 'asc')
}
return query.paginate(page, limit)
}| Method | URL | Description | Access |
|---|---|---|---|
| GET | /api/v1/products |
Paginated list, filter by categoryId, sort by productName/unitPrice |
Public |
| GET | /api/v1/products/:id |
Product detail (includes category) | Public |
| POST | /api/v1/products |
Create a product | ADMIN |
| PUT | /api/v1/products/:id |
Update a product | ADMIN |
| DELETE | /api/v1/products/:id |
Delete a product | ADMIN |
- Create
createProductSchema,updateProductSchema,productFilterSchema(categoryId?,sortBy?,order?) inapp/validators/product_validator.ts - Create
ProductsServicewith dynamic QueryBuilder filtering and sorting; check category exists before create/update; eager-load category on response - Business rule: a product linked to at least one order cannot be deleted
- Create
ProductsControllerwith@inject() - Declare routes with ADMIN protection on mutations
- Unit tests: filtering by
categoryIdreturns only matching products;createthrows 404 if category does not exist;removethrows 409 if product has orders - Functional tests: full CRUD lifecycle; filtering and sorting; nested category in response
- Functional test (auth):
POST /productswithout token 401;USERtoken 403;ADMINtoken 201
Customer CRUD with email uniqueness enforcement and full-text name/email search. All routes require authentication.
- How to implement a case-insensitive search with
ILIKEusing Lucid's QueryBuilder - How to enforce a unique constraint at the service layer (distinct from the DB-level unique index) to produce a clean 409 error with a meaningful message
- How to allow a field to keep its current value on update without triggering a false uniqueness conflict
ILIKE search in the service:
// app/services/customers_service.ts (excerpt)
async findAll(search: string | undefined, page: number, limit: number) {
const query = Customer.query()
if (search) {
query.where((q) => {
q.whereILike('first_name', `%${search}%`)
.orWhereILike('last_name', `%${search}%`)
.orWhereILike('email', `%${search}%`)
})
}
return query.paginate(page, limit)
}| Method | URL | Description | Access |
|---|---|---|---|
| GET | /api/v1/customers |
Paginated list, search by ?search= (first/last name or email) |
Authenticated |
| GET | /api/v1/customers/:id |
Customer detail | Authenticated |
| POST | /api/v1/customers |
Create a customer | ADMIN |
| PUT | /api/v1/customers/:id |
Update a customer | ADMIN |
| DELETE | /api/v1/customers/:id |
Delete a customer | ADMIN |
- Create
createCustomerSchemaandupdateCustomerSchemainapp/validators/customer_validator.ts(email:vine.string().email(), telephone:vine.string().regex(...)) - Create
CustomersService: implementILIKEsearch; enforce unique email on create; on update, allow same email only for the same record - Business rule: a customer with existing orders cannot be deleted
- Create
CustomersControllerwith@inject() - Declare routes: all routes use
middleware.auth(); mutations additionally usemiddleware.role({ roles: ['ADMIN'] }) - Unit test: search filters by first name, last name, and email;
createthrows 409 for duplicate email;updateallows same email for same customer;updatethrows 409 if email belongs to another customer;removethrows 409 when customer has orders - Functional test: full CRUD; search; email uniqueness
- Functional test (auth):
GET /customerswithout token 401; withUSERtoken 200;POST /customerswithUSERtoken 403; withADMINtoken 201
Order CRUD with computed total, business-rule validations, and application events. All routes require authentication.
- How to compute a derived field (
total) in the service layer and persist it to the DB - How to emit and listen to domain events with
@adonisjs/emitterand why listeners are registered in a dedicatedstart/events.tspreload (not inkernel.ts) - How to declare a nested resource route (
/customers/:id/orders) alongside a top-level route - How to write a Japa unit test that asserts an event was emitted using
emitter.fake()
Event definition and listener registration:
// app/events/order_created.ts
export default class OrderCreated {
constructor(public orderId: number, public customerId: number) {}
}
// start/events.ts <-- registered as a preload in adonisrc.ts
import emitter from '@adonisjs/core/services/emitter'
import OrderCreated from '#events/order_created'
emitter.on(OrderCreated, () => import('#listeners/send_order_notification'))
// adonisrc.ts (relevant excerpt)
preloads: [
() => import('#start/routes'),
() => import('#start/kernel'),
() => import('#start/events'), // event bindings loaded at boot
]Faking the emitter in tests:
// tests/unit/services/orders_service.spec.ts (excerpt)
import emitter from '@adonisjs/core/services/emitter'
import OrderCreated from '#events/order_created'
test('create emits OrderCreated event', async ({ assert }) => {
const fakeEmitter = emitter.fake()
// ... call service.create(...)
fakeEmitter.assertEmitted(OrderCreated)
emitter.restore()
})| Method | URL | Description | Access |
|---|---|---|---|
| GET | /api/v1/orders |
Paginated list, filter by customerId/productId |
Authenticated |
| GET | /api/v1/orders/:id |
Order detail | Authenticated |
| POST | /api/v1/orders |
Create an order (total computed automatically) |
ADMIN |
| PUT | /api/v1/orders/:id |
Update an order | ADMIN |
| DELETE | /api/v1/orders/:id |
Delete an order | ADMIN |
| GET | /api/v1/customers/:id/orders |
All orders for a given customer | Authenticated |
- Create
createOrderSchema(customerId,productId,quantity),updateOrderSchema,orderFilterSchema(customerId?,productId?) inapp/validators/order_validator.ts - Create
OrdersService: verify customer and product exist, computetotal = quantity x product.unitPrice; eager-load customer and product on response - Emit
OrderCreatedevent viaemitter.emit()after successful creation - Create
SendOrderNotificationlistener (log the event + placeholder for email delivery) - Register the event -> listener binding in
start/events.tsand declarestart/events.tsas a preload inadonisrc.ts - Create
OrdersControllerwith@inject(); include the nested/customers/:id/ordersroute - Declare all routes with auth and role middleware applied immediately
- Unit test:
createcomputes total correctly;createthrows 404 for unknown customer or product;createemitsOrderCreated(useemitter.fake());updaterecomputes total when quantity changes;updatedoes not recompute total when quantity is unchanged - Functional test:
POST /ordersstores correct total;GET /orders?customerId=1filters correctly;GET /customers/1/ordersreturns only that customer's orders - Functional test (auth):
GET /orderswithout token 401; withUSERtoken 200;POST /orderswithUSERtoken 403; withADMINtoken 201
1. feature/core-architecture -> PR to develop
2. feature/data-modeling -> PR to develop (includes seeded ADMIN + USER test accounts)
3. feature/auth -> PR to develop (security foundation)
4. feature/categories -> PR to develop (first full vertical slice, secured from day one)
5. feature/products -> PR to develop (depends on categories)
6. feature/customers -> PR to develop
7. feature/orders -> PR to develop (depends on products + customers)
8. develop -> PR to master (final stable release)
Each branch is created from the tip of develop:
git checkout develop
git pull origin develop
git checkout -b feature/<name>- App code lives in
app/; configuration inconfig/; routes instart/routes.ts - Each domain has its controller in
app/controllers/, its Lucid model inapp/models/, its service inapp/services/, its VineJS validator inapp/validators/, and its Bouncer policy inapp/policies/ - No business logic in controllers - controllers only call
request.validateUsing(), call the service, and returnrespond() - Services throw AdonisJS HTTP exceptions via
import { errors } from '@adonisjs/core'- never rawError - All list endpoints are paginated via Lucid's
.paginate(page, limit)- never return an unbounded array - Response shape is always
ApiResponse<T>- therespond()helper handles wrapping - Endpoint paths are plural and
snake_case:/api/v1/categories,/api/v1/orders - VineJS validators live in
app/validators/- no inline schema definitions in controllers - Environment variables are always accessed via the typed
envobject fromstart/env.ts- neverprocess.env - Event -> listener bindings live in
start/events.ts, notstart/kernel.ts - Migrations only - Lucid
synchronizeis never used
Architecture
- Layered architecture: Controller -> Service -> Model (Lucid ActiveRecord)
- Dependency injection via AdonisJS IoC container (
@inject()decorator) - Service classes as the single source of business logic
Data Layer
- Lucid ORM models with relations (
@hasMany,@belongsTo,@manyToMany) @column()snake_case to camelCase mapping@beforeSavehook for transparent password hashing- QueryBuilder for dynamic filtering, sorting, and ILIKE search
serializeExtras: truefor pagination metadata- Lucid migrations and seeders
Validation
- VineJS compiled schemas in
app/validators/ request.validateUsing(schema)returning fully typed data- Optional query parameter validation
API Design
- Consistent
ApiResponse<T>envelope withrespond()helper - Pagination with
PageResponse<T>via LucidModelPaginatorContract - Swagger / OpenAPI via
adonis-autoswagger - Versioned endpoints (
/api/v1/)
Error Handling
- Global exception handler in
app/exceptions/handler.ts - Domain-specific HTTP exceptions from the service layer
Security
@adonisjs/authJWT guard (stateless authentication)jti-based token blacklisting (explicit logout)- Role-based access control (
ADMIN,USER) viaRoleMiddleware - Fine-grained permissions via Bouncer policies
- Account activation and password reset flows
@beforeSavehook +hashservice for Argon2 password hashing- Rate limiting on auth routes via
@adonisjs/limiter
Events
@adonisjs/emitterfor decoupled domain eventsOrderCreatedevent +SendOrderNotificationlistener- Listener registration via
start/events.tspreload inadonisrc.ts emitter.fake()for asserting events in unit tests
Caching
@adonisjs/cacheon frequently-read endpoints (categories list)
Configuration
- Typed config factories in
config/ - VineJS env validation in
start/env.ts- app refuses to boot on invalid config
Testing
- Unit tests with mocked dependencies (Japa +
@japa/assert) - Functional / API-level tests (
@japa/api-client+ real PostgreSQL via Docker) test.group(),group.setup(),group.teardown(),group.each.setup()emitter.fake()for event assertions- Shared
tests/helpers/auth_helper.tsfor reusable login logic
Developer Experience
- Husky hooks (pre-commit lint, commit-msg validation, pre-push test)
- Commitlint (Conventional Commits)
- ESLint + Prettier
- GitHub Actions CI pipeline
- Docker Compose for local development and testing
# 1. Clone and set up
git clone https://github.com/your-org/adonisjs_tutorial.git
cd adonisjs_tutorial
cp .env.example .env.development
npm install
# 2. Start the database
docker-compose up -d
# 3. Run migrations and seed
node ace migration:run
node ace db:seed
# 4. Start the dev server
node ace serve --hmr
# API: http://localhost:3333/api/v1
# Swagger: http://localhost:3333/docs
# 5. Follow branches in order
git checkout develop
git checkout -b feature/core-architecture
# Complete every task in the branch's Tasks list
# Open a PR to develop when done
# 6. Run tests
node ace test # all tests
node ace test unit # unit tests only
node ace test functional # functional tests onlyWork through branches in the Order of Work. At the end of each branch:
- Complete every item in its Tasks list
- Ensure all atomic commits are in place (one per file)
- Confirm tests pass:
node ace test - Open a Pull Request to
develop
Common errors and how to fix them.
E_MISSING_APP_KEY: Cannot encrypt value without an application key
You have not set APP_KEY in your .env file. Generate one with:
node ace generate:keyCopy the output into your .env.development file.
E_INVALID_ENV_VARIABLES at startup
A variable declared in start/env.ts is missing or has the wrong type in your .env file. AdonisJS intentionally crashes at boot rather than running with bad config. Check the error message - it names the offending variable. Compare your .env file against .env.example.
Migration fails with column already exists or relation does not exist
Your migration history is out of sync. Check the adonis_schema table in your database. If you are in development, the fastest fix is:
node ace migration:rollback --batch=0 # rolls back all migrations
node ace migration:run # re-applies from scratchNever do this on a production database.
auth.user is undefined even after auth.authenticate()
auth.authenticate() resolves the user from the JWT but does NOT preload relations. If your middleware or service needs user.roles, call await user.load('roles') explicitly after authentication. This is a common source of empty role arrays in RoleMiddleware.
RoleMiddleware always returns 403 even with the correct role
Almost always caused by the missing preload mentioned above. Add await user.load('roles') before the role check. Also verify that your UserSeeder actually created the pivot row in role_user - query the table directly in psql to confirm.
Functional tests fail with connect ECONNREFUSED 127.0.0.1:5432
The test database is not running. Start it with:
docker-compose -f docker-compose.test.yml up -d
node ace migration:run --env=testMake sure your .env.test points to the test database port (default: 5433 to avoid conflicts with the dev DB).
emitter.fake() does not capture events in unit tests
Call emitter.fake() before the code that emits the event, and call emitter.restore() in the teardown. If you forget restore(), subsequent tests in the suite will also use the fake emitter and may produce unexpected results.
VineJS throws E_VALIDATION_ERROR but the error message is not in ApiResponse shape
The global exception handler does not automatically intercept VineJS errors unless you handle the E_VALIDATION_ERROR code in app/exceptions/handler.ts. Add a check for error.code === 'E_VALIDATION_ERROR' and format it using your ApiResponse structure with success: false and the messages array in errors.