| name | adonisjs-architecture |
| description | Opinionated architecture decisions, patterns, and project structure for AdonisJS v6 API kit applications. Use when the user asks about architecture decisions, project structure, pattern selection, separation of concerns, service layers, repository patterns, domain-driven design, or mentions how to organize, which pattern to use, best practices, or architecture. Also trigger when the user is creating controllers, services, repositories, actions, DTOs, validators, events, listeners, state machines, or any domain logic classes in an AdonisJS v6 project. Also trigger for observability, telemetry, tracing, metrics, OpenTelemetry, OTEL, Prometheus, prom-client, distributed tracing, spans, instrumentation, monitoring, or setting up a /metrics endpoint. Even if the user doesn't explicitly say "architecture," use this skill whenever structural or organizational decisions are being made in an AdonisJS v6 codebase.
|
AdonisJS v6 API Architecture
Opinionated, service-oriented architecture for AdonisJS v6 API kit applications. TypeScript-first, separation of concerns everywhere, testable by default.
Core Philosophy
- Controllers are HTTP adapters — they translate HTTP into service calls and back. Zero business logic.
- Services orchestrate — they coordinate repositories, emit events, enforce business rules.
- Repositories own data access — all Lucid queries live here. Services never call
.query() directly.
- Domain objects are pure — calculations, value objects, and state machines have no DB or HTTP awareness.
- Events decouple side effects — downstream consequences (notifications, audit logs, accounting entries) happen via listeners, not inline.
- DTOs cross boundaries — data moves between layers via typed objects, never raw
request.all().
When to Read Reference Files
Instruction: When this skill activates, immediately read the relevant reference file(s) for the task at hand using the Read tool before generating any output.
- Architecture decisions & rationale → Read
references/decisions.md (Decisions 1–12 on structure, 13–18 on performance & memory safety, 19 on multi-tenancy, 20 on observability)
- Code examples for each layer → Read
references/code-examples.md
- Testing patterns → Read
references/testing.md
- State machine patterns → Read
references/state-machines.md
- Performance & memory safety → Read
references/performance.md
- OpenTelemetry, tracing & Prometheus metrics → Read
references/observability.md
Project Structure
app/
├── controllers/ # Thin HTTP handlers (1 resource = 1 controller)
│ └── payments_controller.ts
├── services/ # Business orchestration
│ └── payment_service.ts
├── repositories/ # Data access via Lucid
│ └── invoice_repository.ts
├── domain/ # Pure business logic
│ ├── billing/ # Calculations, strategies
│ ├── accounting/ # Journal entry builders
│ └── value_objects/ # Money, DateRange, etc.
├── actions/ # Single-purpose commands (optional, for complex ops)
│ └── process_payment_action.ts
├── dtos/ # Data Transfer Objects — organised by domain
│ ├── payments/
│ │ ├── record_payment_dto.ts # Input DTO (command)
│ │ └── payment_response_dto.ts # Response/output DTO
│ ├── invoices/
│ │ └── invoice_dto.ts # Store, Update, LineItem DTOs
│ └── tasks/
│ └── task_dto.ts # TaskWorkCompleteDto, TaskCreateDto
├── state_machines/ # Lifecycle definitions
│ └── invoice_state_machine.ts
├── events/ # Event classes
│ └── payment_received.ts
├── listeners/ # Event handlers
│ └── generate_journal_entry.ts
├── validators/ # VineJS request validation
│ └── store_payment_validator.ts
├── models/ # Lucid models — schema + relationships + scopes ONLY
│ └── invoice.ts
├── contracts/ # Abstract classes for swappable dependencies
│ └── payment_gateway.ts
├── exceptions/ # Domain-specific errors
│ └── insufficient_balance_exception.ts
├── policies/ # Bouncer authorization
│ └── invoice_policy.ts
├── middleware/ # HTTP middleware
│ └── tenant_middleware.ts
└── providers/ # Service providers for container bindings
└── repository_provider.ts
otel.ts # OTEL init — must be first import in bin/server.ts
config/
└── otel.ts # @adonisjs/otel trace exporter + sampling configuration
start/
├── events.ts # Event → Listener registrations (emitter.on calls)
├── routes.ts # Route definitions
├── kernel.ts # Global middleware registration
└── otel_metrics.ts # DB query telemetry listeners + periodic gauge updates
database/
├── migrations/ # Lucid migration files
├── factories/ # Model factories for testing/seeding
└── seeders/ # Database seeders
File Naming
AdonisJS v6 uses snake_case for all files and directories. Follow this convention strictly:
payment_service.ts not PaymentService.ts
store_payment_validator.ts not StorePaymentValidator.ts
- Directories:
value_objects/ not ValueObjects/
Barrel Exports
Do NOT use barrel files (index.ts) for re-exporting. AdonisJS v6 uses direct imports with the # subpath import alias. Every import should point to the exact file:
import PaymentService from '#services/payment_service'
import { InvoiceRepository } from '#repositories/invoice_repository'
import { PaymentService } from '#services'
Layer Rules
Controllers
import type { HttpContext } from '@adonisjs/core/http'
import { inject } from '@adonisjs/core'
import PaymentService from '#services/payment_service'
import { storePaymentValidator } from '#validators/store_payment_validator'
export default class PaymentsController {
@inject()
async store({ request, response }: HttpContext, paymentService: PaymentService) {
const data = await request.validateUsing(storePaymentValidator)
const payment = await paymentService.processPayment(data)
return response.created(payment)
}
}
Rules:
- Validate input via VineJS validators
- Call exactly ONE service method
- Return response — no business logic, no direct DB queries
- Use
@inject() for method injection of services
- One controller per resource, standard REST actions only
Services
import { inject } from '@adonisjs/core'
import emitter from '@adonisjs/core/services/emitter'
import { InvoiceRepository } from '#repositories/invoice_repository'
import { WalletRepository } from '#repositories/wallet_repository'
import PaymentReceived from '#events/payment_received'
@inject()
export default class PaymentService {
constructor(
private invoiceRepo: InvoiceRepository,
private walletRepo: WalletRepository
) {}
async processPayment(data: StorePaymentDto) {
const invoice = await this.invoiceRepo.findOrFail(data.invoiceId)
const payment = await this.invoiceRepo.recordPayment(invoice, data.amount)
await emitter.emit(new PaymentReceived(payment))
return payment
}
}
Rules:
- Use constructor injection via
@inject() decorator
- Coordinate multiple repositories — this is the orchestration layer
- Emit events for side effects — don't handle them inline
- Throw domain exceptions for business rule violations
- Services may call other services when orchestrating cross-domain workflows
- Keep methods focused: one business operation per method
Repositories
import Invoice from '#models/invoice'
export class InvoiceRepository {
async findOrFail(id: string) {
return Invoice.findOrFail(id)
}
async findOverdue(tenantId: string) {
return Invoice.query()
.where('tenant_id', tenantId)
.where('status', 'overdue')
.orderBy('due_date', 'asc')
}
async recordPayment(invoice: Invoice, amount: number) {
}
}
Rules:
- All Lucid query builder calls live here
- Return models or serialized data — never raw query results
- Name methods after what they retrieve, not how:
findOverdue() not queryByStatusAndDate()
- No business logic — just data access and persistence
- Pagination: use Lucid's
.paginate(page, limit) in the repository. Return the ModelPaginatorContract directly to the service/controller. Never pass raw page/limit into a service — let the repository own pagination.
async list(page: number, limit: number) {
return Invoice.query().orderBy('created_at', 'desc').paginate(page, limit)
}
Domain Objects
Pure TypeScript classes/functions with no framework dependencies:
export class InstallmentCalculator {
static calculate(principal: number, rate: number, months: number): InstallmentSchedule {
}
}
DTOs
The app/dtos/ directory is organised by domain subdirectory, not by DTO type. Each subdirectory owns all DTOs for that domain (input, output, nested shapes).
app/dtos/
├── payments/
│ ├── record_payment_dto.ts
│ └── payment_response_dto.ts
├── invoices/
│ └── invoice_dto.ts
└── tasks/
└── task_dto.ts
Three DTO types
1. Input DTOs (commands) — data flowing into a service method. Produced either by VineJS inference or explicitly when enrichment is needed.
import type { DateTime } from 'luxon'
import type { PaymentMethod } from '#enums/payment_method'
export interface RecordPaymentDto {
invoiceId: string
amount: number
method: PaymentMethod
reference?: string
paidAt?: DateTime
recordedByUserId: string
tenantId: string
}
2. Response DTOs (output shapes) — data flowing out of a service when the Lucid model shape is insufficient (computed fields, aggregates, projections).
export interface PaymentResponseDto {
id: string
invoiceId: string
amount: number
method: string
reference: string | null
paidAt: string
invoiceBalance: number
receiptUrl: string
}
3. Nested/embedded DTOs — sub-shapes used inside input or response DTOs.
export interface LineItemDto {
description: string
quantity: number
unitPrice: number
}
export interface StoreInvoiceDto {
customerId: string
amount: number
dueDate: string
lineItems?: LineItemDto[]
}
export interface UpdateInvoiceDto {
amount?: number
dueDate?: string
lineItems?: LineItemDto[]
}
Rules
- Plain
interface or type — no decorators, no Lucid imports, no class instances
- One file per domain feature — group related input/output/nested shapes together
- File name matches the primary subject:
task_dto.ts, invoice_dto.ts
- Input DTO names end with
Dto: RecordPaymentDto, StoreInvoiceDto, TaskWorkCompleteDto
- Response DTO names end with
ResponseDto: PaymentResponseDto, InvoiceResponseDto
- Validators produce DTOs via VineJS inference; use an explicit interface only when you need to enrich (attach
userId, tenantId, resolved entities) before passing to the service
- Import via
#dtos/payments/record_payment_dto — never relative paths across domains
Response Serialization
Return Lucid models directly from services when the full model shape is acceptable. Use serializeAs on model columns to rename or hide fields in JSON output:
@column({ serializeAs: null })
declare internalNote: string | null
@column({ serializeAs: 'customer_id' })
declare customerId: string
When the response shape differs significantly from the model (e.g., computed fields, nested aggregates, hidden internals), the service may return a plain DTO instead of the model. For repeated or complex serialisation, extract a transformer function in app/dtos/transformers/. Never add inline serialisation logic to controllers.
AdonisJS v7 note: v7 introduces first-class transformer support. When upgrading, prefer native transformers over hand-rolled transformer functions.
Events & Listeners
import Payment from '#models/payment'
export default class PaymentReceived {
constructor(public payment: Payment) {}
}
import PaymentReceived from '#events/payment_received'
import { JournalEntryBuilder } from '#domain/accounting/journal_entry_builder'
export default class GenerateJournalEntry {
async handle(event: PaymentReceived) {
const builder = new JournalEntryBuilder()
await builder.forPayment(event.payment).save()
}
}
Register in start/events.ts:
import emitter from '@adonisjs/core/services/emitter'
const PaymentReceived = () => import('#events/payment_received')
const GenerateJournalEntry = () => import('#listeners/generate_journal_entry')
emitter.on(PaymentReceived, [GenerateJournalEntry])
Authorization (Bouncer)
Authorization lives at the controller layer, called before the service. Policies are plain classes with no DI required.
import User from '#models/user'
import Invoice from '#models/invoice'
import { BasePolicy } from '@adonisjs/bouncer'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class InvoicePolicy extends BasePolicy {
view(user: User, invoice: Invoice): AuthorizerResponse {
return user.id === invoice.userId
}
update(user: User, invoice: Invoice): AuthorizerResponse {
return user.id === invoice.userId && invoice.status === 'DRAFT'
}
}
Call bouncer.authorize() in the controller, before calling the service:
@inject()
async update({ params, request, response, bouncer }: HttpContext, invoiceService: InvoiceService) {
const invoice = await invoiceService.findOrFail(params.id)
await bouncer.with(InvoicePolicy).authorize('update', invoice)
const data = await request.validateUsing(updateInvoiceValidator)
const updated = await invoiceService.update(params.id, data)
return response.ok(updated)
}
Rules:
- Authorization checks happen in controllers only — services assume the caller is already authorized.
- Services must never call Bouncer — they have no knowledge of who made the request.
- Policies extend
BasePolicy and use the AuthorizerResponse return type.
- Never pass
bouncer into a service; keep authorization at the HTTP boundary.
Dependency Injection Strategy
Use AdonisJS v6's built-in IoC container with the @inject() decorator:
- Controllers: Use method injection —
@inject() on the handler method
- Services: Use constructor injection —
@inject() on the class. Register as singletons in a provider (they're stateless).
- Repositories: Register as singletons in a provider — they're stateless and reconstructing them per-request wastes allocations.
For abstractions (e.g., swappable payment gateways), use abstract classes + contextual bindings:
export abstract class PaymentGateway {
abstract charge(amount: number, token: string): Promise<ChargeResult>
abstract refund(chargeId: string): Promise<RefundResult>
}
Bind in a provider:
import type { ApplicationService } from '@adonisjs/core/types'
import { PaymentGateway } from '#contracts/payment_gateway'
import { StripeGateway } from '#services/gateways/stripe_gateway'
export default class PaymentProvider {
constructor(protected app: ApplicationService) {}
register() {
this.app.container.bind(PaymentGateway, () => new StripeGateway())
}
}
Key Conventions
- One class per file. No exceptions.
- snake_case file names. Enforced by AdonisJS v6 convention.
- Subpath imports. Always use
# aliases: #services/, #models/, #repositories/.
- Explicit return types on all public service/repository methods.
- No
any types. Use unknown if type is truly unknown, then narrow.
- Database transactions are managed at the service layer, not in repositories.
- Validation happens once, at the controller boundary, via VineJS.
- Error handling: throw typed domain exceptions; let AdonisJS exception handler format the HTTP response.
- Enums use ALL_CAPS for both keys and values. The enum name is PascalCase; keys and string values are SCREAMING_SNAKE_CASE. This makes enum values unmistakable in code and consistent with database column values.
export enum InvoiceStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
PAID = 'PAID',
OVERDUE = 'OVERDUE',
}
export enum InvoiceStatus {
Draft = 'draft',
Sent = 'sent',
}
Always use Object.values(MyEnum) in VineJS validators to keep enum values as the single source of truth:
method: vine.enum(Object.values(PaymentMethod)),
Performance & Memory Safety Rules
- Register stateless services/repositories as singletons. Auto-resolution creates and GC's identical objects per request. See Decision 13.
- Never do external I/O inside
db.transaction(). Transactions hold DB connections. Slow external calls exhaust the pool. See Decision 14.
- Queue heavy listener work.
emitter.emit() awaits all listeners sequentially. Email, webhooks, PDF generation must go to a job queue. See Decision 15.
- Never cache model instances in singletons. Models hold connection/relationship references. One forgotten ref = unbounded memory growth. See Decision 16.
- Repositories must not eagerly preload. Return lean models by default. Offer explicit
findWithRelations() variants. See Decision 17.
- Event listeners registered once at boot only. Never inside request handlers or constructors. Dynamic registration = memory leak. See Decision 18.
When to Use Actions vs Services
- Service: Orchestrates a domain workflow. May coordinate multiple repos, call external APIs, emit events. Has multiple related methods. E.g.,
PaymentService with processPayment(), refundPayment(), allocateOverpayment().
- Action: Single-purpose, invokable class for complex operations that deserve their own file. Use when a service method grows beyond ~50 lines or involves intricate logic. E.g.,
ProcessInstallmentPaymentAction.
Actions are optional — start with services. Extract to actions when complexity demands it.
Pattern Skills
Each layer has a dedicated skill with detailed patterns, examples, and testing guidance:
HTTP & Routing
- Controllers - Thin HTTP adapters, zero domain logic
- Routing - Route groups, middleware, permission guards
- Validation - VineJS request validation patterns
Domain Layer
- Actions - Single-purpose domain operation classes
- DTOs - Typed data transfer between layers
- Enums - Type-safe finite value sets
- Value Objects - Immutable domain primitives with behavior
- State Machines - Model lifecycle and transition rules
- Exceptions - Custom domain exceptions with static factories
Data Layer
- Models - Lucid ORM models, relationships, hooks, scopes
- Query Builders - Composable query scopes and query objects
Events & Jobs
- Events - Event classes, listeners, emitter patterns
- Jobs - Background job processing and scheduling
Authorization
- Policies - Bouncer per-model authorization policies
External Services
Infrastructure