Best Practices
Naming conventions
Start with a verb
Name your action classes as concise, explicit phrases starting with a verb. The name should clearly describe what the action does.
Good examples:
✅ DeleteUserAction — Deletes a user
✅ SendWelcomeEmailAction — Sends a welcome email
✅ CreateCheckoutSessionAction — Creates a checkout session
✅ ResetUserPasswordAction — Resets a user's password
Bad examples:
❌ UserAction — Too vague, doesn't describe what it does
❌ UserDeleter — Uses a noun instead of a verb
❌ HandleUser — Unclear what "handle" means
Following this practice, your folder structure naturally becomes a clear catalog of all the features your application offers.
Organizing your actions
Categorize your actions
When your application grows, you might end up with a large app/actions folder. Don't hesitate to organize them into subfolders by domain or feature. Autoloading will still work correctly.
Keep actions focused, compose them for workflows
Each action should handle a single, well-defined use case. However, actions can and should call other actions to compose workflows. This allows you to build complex business processes while keeping each action focused and testable.
import { } from '@foadonis/actions'
import from '#models/order'
export default class extends {
// ✅ Good: Orchestrates other focused actions
async (: ) {
// Each step is a focused, testable action
await ChargeUserAction.run(.user, .amount)
await UpdateInventoryAction.run(.product, .quantity)
await SendOrderConfirmationAction.run(.user)
}
}import { } from '@foadonis/actions'
import from '#models/order'
export default class extends {
// ✅ Focused: Handles only charging
async (: ) {
// Charge customer logic
}
}This approach gives you the best of both worlds: focused, testable actions that can be composed into complex workflows.
Working with services
Don't throw away services
It's tempting to stop using services when you move business logic into actions. However, services are still valuable for external integrations and reusable business logic that doesn't fit into a single use case.
Keep services for:
- External API integrations (Stripe, SendGrid, etc.)
- Complex domain logic that's shared across multiple actions
- Infrastructure concerns (file storage, caching, etc.)
import from 'stripe'
export default class {
constructor(private : ) {}
async (: string, : string, : number) {
return this...({
,
,
: ,
})
}
}import { } from '@foadonis/actions'
import { } from '@adonisjs/core'
import from '#models/user'
import from '#services/stripe_service'
@()
export default class extends {
constructor(private : ) {}
async (: , : number) {
await this..chargeCustomer(.stripeId, .currency, )
}
}Keep business logic in handle
Separate context-specific code from business logic
The handle method should contain your pure business logic, independent of how the action is invoked. Context-specific code (like reading request parameters or prompting for input) should be in asController, asCommand, or asListener.
import { , AsController, AsCommand } from '@foadonis/actions'
import { } from '@adonisjs/core/ace'
import { } from '@adonisjs/core/http'
import from '#models/user'
export default class extends implements AsController, AsCommand {
// ✅ Business logic is context-agnostic
async (: ) {
await .delete()
}
// Context-specific: reads from HTTP request
async ({ , }: ) {
const = await .findOrFail(.('id'))
await this.()
return .(204)
}
// Context-specific: prompts for input
async ({ , }: ) {
const = await .('User email')
const = await .findByOrFail({ })
await this.()
.('User deleted successfully')
}
}Avoid event-driven logic
Why avoid event-driven applications?
Event-driven architectures have a significant advantage: you can run logic without caring about the entrypoint. A listener can respond to user:created whether it was triggered by a registration form, an admin panel, or an import script.
However, this flexibility comes with a major drawback: it becomes really hard to retrieve the initial path that resulted in the final event. When debugging or tracing a user creation flow, you might struggle to understand:
- Where did this event originate?
- What was the original context?
- Why was this user created?
Actions provide the best of both worlds
By using actions and decoupling business logic from the entrypoint, you get the benefits of event-driven architecture while maintaining traceability:
- Decoupled business logic — The same action can be called from HTTP, CLI, or events
- Clear execution path — You can always trace back to where the action was invoked
- Testable — Actions can be tested independently without event infrastructure
- Direct action calls — Call other actions directly instead of emitting events, maintaining full traceability
export default class extends BaseAction {
// ❌ Bad: we lose traceability of the business logic
async (: ) {
const = await User.create()
await emitter.emit('user:registered', )
return
}
}
export default class extends BaseAction {
// ✅ Good: We know that when creating a user, a welcome email is sent
async (: ) {
const = await User.create()
await SendWelcomeEmailAction.run()
return
}
}Instead of emitting events where you lose traceability, call actions directly. This way, you can always follow the execution path and understand what happens after each step.