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.

login_user_action.ts
register_user_action.ts
reset_user_password_action.ts
create_checkout_session_action.ts
update_payment_method_action.ts
create_user_action.ts
delete_user_action.ts
update_user_action.ts

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.

app/actions/orders/process_order_action.ts
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)
  }
}
app/actions/orders/charge_order_action.ts
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.)
app/services/stripe_service.ts
import  from 'stripe'

export default class  {
  constructor(private : ) {}

  async (: string, : string, : number) {
    return this...({
      ,
      ,
      : ,
    })
  }
}
app/actions/users/charge_user_action.ts
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.

app/actions/delete_user_action.ts
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
app/actions/create_user_action.ts
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.

On this page