Business logic lives in actions; cross-cutting effects run through events. Controllers stay thin (policy → validate → action → response). Actions take a plain input, never HttpContext. When an action needs a side effect (mail, notification, transmit, external HTTP), it emits a domain event; a listener in the module's start/events.ts receives it and runs the effect. This keeps actions synchronous domain code, keeps side effects composable (a new listener is a new file, not an edit to the action), and makes testing effortless — fake the emitter, fake the mail.
Rules
- Location:
app/<mod>/actions/<verb_noun>.ts— one file per action. - Shape: default-export a class with a single public method
async handle(input): Promise<Result | void>.inputis a plaininterface. NoHttpContext. - Return or throw: return the primary value (a model, an id, void). Throw domain exceptions from
app/<mod>/exceptions/for expected failure paths (rate limits, permission denials, invariant violations). - Side effects go through events. Anything that reaches out is emitted, not called inline:
This applies to every kind of effect without exception:emitter.emit('user:registered', { user, token })
| Kind | Example call the action must not make |
|---|---|
mail.send(new WelcomeEmail(...)) |
|
| In-app notification | facteur.notification(...).send() |
| SSE / realtime broadcast | transmit.broadcast(channel, payload) |
| External HTTP | fetch('https://api.stripe.com/...') |
| Audit log write | any write to an audits / activity_logs table that's cross-cutting |
- Listeners live in
app/<mod>/start/events.ts. Register withemitter.on('event:name', async (data) => { ... }). Preload the file fromadonisrc.tsso it wires at boot (see [[module-scaffolding]]). - Guards as helper functions at the top of the action file — e.g.
requireManageRoles(executor)throws if not permitted. Keeps the action body short and the guard reusable across actions. - Controllers call actions:
await new Action().handle(input). Never instantiate an action inside another action; if two actions need shared work, extract a service (behavior with side effects) or a query (read-only).
Repo refs
- Action shape (
.handle, noHttpContext):app/users/actions/create_user.ts. - Reusable defense-in-depth guards:
app/users/actions/sync_user_roles.ts. - Event typing (declaration merge):
app/users/types/events.ts; listener wiring:app/users/start/events.ts.
Doc refs
- AdonisJS emitter — https://docs.adonisjs.com/guides/digging-deeper/emitter
Workflow
Create a new action
- Create
app/<mod>/actions/<verb_noun>.ts(e.g.create_invoice.ts). - Declare an
interfacefor the input — everything the action needs, as plain values. Passauth/sessiononly if the action must callauth.use(...).login(user). - Default-export a class:
export default class CreateInvoice { async handle(input: CreateInvoiceInput): Promise<Invoice> { // guards, domain logic, DB writes // emit if there are side effects return invoice } } - Throw domain exceptions from
app/<mod>/exceptions/for expected failure paths.
Emit a domain event
Inside the action, at the point the side effect should trigger:
emitter.emit('<mod>:<event>', payload)
Naming: <mod>:<past-tense-verb> — user:registered, invoice:issued, role:changed.
Payload: plain, serializable. Include the primary entity + whatever the listener needs. Never reach for HttpContext fields.
Add a listener
- Open (or create)
app/<mod>/start/events.ts. - Add
emitter.on('<event>', async (data) => { ... }). - Ensure
() => import('#<mod>/start/events')is inadonisrc.tspreloads. - Multiple listeners on the same event are fine — one per side effect (one mail, one notification, one webhook).
Concurrency — actions that read-then-write a sequence
When the action derives a value from the current state of a set (next position in a list, next number in a sequence, unique slug), two concurrent invocations against the same parent will both read the same max and produce a duplicate. Two-layer defense:
unique(...)on the schema so the DB rejects the second write. A safety net, not a UX.db.transaction()withSELECT parent FOR UPDATEto serialize the compute-and-insert on the parent aggregate. Lock the parent, not the aggregated table.
async handle(input: AddItemInput): Promise<Item> {
const item = await db.transaction(async (trx) => {
// Lock the parent row for the length of the trx.
await Parent.query({ client: trx })
.where('id', input.parentId)
.forUpdate()
.firstOrFail()
const last = await Item.query({ client: trx })
.where('parent_id', input.parentId)
.max('position as max')
.first()
const position = (Number((last as any)?.$extras?.max) || 0) + 1
return Item.create(
{ parentId: input.parentId, position, ...input.payload },
{ client: trx }
)
})
await emitter.emit('item:added', { item, parentId: input.parentId })
return item
}
Same shape for deletes with reordering: hold the parent lock, delete, decrement the range.
Do heavy I/O (file upload, external HTTP) outside the transaction so the lock isn't held while bytes stream.
Testing
- Unit-test the action with sinon stubs — see [[testing]].
- Fake the emitter in endpoint tests to verify emission without running listeners:
emitter.fake(['user:registered'])thenfake.assertEmitted('user:registered'). - End-to-end listener test:
await emitter.emit('user:registered', payload)and assert the side effect (mail sent, notification row created, etc).
Anti-patterns
- ❌ Action takes
HttpContext— extract the pieces it needs and pass those as plain fields. - ❌ Action calls
mail.send(...)/facteur.notification(...).send()/transmit.broadcast(...)directly — emit an event, let the listener do it. - ❌ Read-then-write on a computed column (next position, next sequence number) without
.forUpdate()on the parent +unique(...)on the schema — the race produces duplicates. - ❌ Business logic in the controller (
if (user.isX) { ... } await something()) — move it to an action. - ❌ Multiple public methods on an action — split into separate action files.
- ❌ Listener that reaches into another module's DB directly — cross-module effects should emit their own domain event from the source module and be handled by a listener that owns the destination.
- ❌ Emitting from inside a
db.transaction(...)— if the transaction rolls back, the listeners already ran. Emit after the trx commits.
Related skills
[[module-scaffolding]] · [[routes]] · [[crud]] · [[testing]] · [[mail]] · [[notifications]]