Back to Scaling Node.js Applications guides

AdonisJS Error Handling Patterns

Stanley Ulili
Updated on June 23, 2025

Error handling is what makes your AdonisJS app bulletproof. When things go wrong, good error handling means your users see helpful messages instead of crashes. Poor error handling means your app crashes, displays unappealing technical errors, or confuses users with nonsensical messages.

This guide shows you how to handle errors properly in AdonisJS applications. You'll learn to use the framework's built-in tools and patterns to catch problems before they break your app.

Understanding AdonisJS error landscape

AdonisJS apps face different types of errors because of how the framework works. The MVC structure, dependency injection, and middleware pipeline all create specific places where things can go wrong. Once you understand these patterns, you can handle them better.

Application-level runtime exceptions

These are errors you expect to happen during normal use. They're not bugs - they're situations your app should handle gracefully.

Your AdonisJS app will commonly see these:

  • Database connections failing when external services go down
  • Validation errors when users enter bad data
  • Authentication failures when tokens expire
  • Authorization problems when users try to access forbidden resources
  • File upload errors when files are too big or the wrong type

Programming errors and implementation defects

These are actual bugs in your code that you need to fix. In AdonisJS, you'll often see these patterns:

  • Route model binding configured wrong, causing type errors in controllers
  • IoC container failing to inject dependencies properly
  • Database relationships set up incorrectly, breaking queries
  • Middleware that doesn't call next(), stopping the request pipeline

Framework constraint violations

AdonisJS has guidelines on how to structure your app. Break these rules and you get specific errors:

  • Controller methods with wrong parameters break routing
  • Middleware that doesn't follow the pipeline pattern
  • Database migrations with syntax errors prevent app startup
  • Providers configured incorrectly stop the entire app from loading

Understanding these categories helps you pick the right handling strategy for each part of your app.

Global exception handling

AdonisJS gives you a central place to handle all unhandled errors. This keeps your error responses consistent and makes logging easier.

Exception handler implementation

The Global Exception Handler catches every unhandled error and turns it into a proper response:

 
// app/Exceptions/Handler.js
class ExceptionHandler extends BaseExceptionHandler {
  async handle(error, { response }) {
    if (error.name === 'ValidationException') {
      return response.status(422).json({
        success: false,
        error: 'Validation failed',
        details: error.messages
      })
    }

    if (error.code === 'ER_DUP_ENTRY') {
      return response.status(409).json({
        success: false,
        error: 'Resource already exists'
      })
    }

    return response.status(500).json({
      success: false,
      error: 'Internal server error'
    })
  }
}

This handler categorizes different error types and provides corresponding responses. Validation errors get detailed feedback, database conflicts become user-friendly messages, and everything else becomes a generic server error.

You can extend this to report serious errors to external services while keeping client errors quiet.

Custom exception classes

Create specific exception classes for different error types. This makes your error handling more precise:

 
// app/Exceptions/BusinessLogicException.js
class BusinessLogicException extends LogicalException {
  constructor(message, status = 400) {
    super(message, status, 'E_BUSINESS_LOGIC')
  }

  handle(error, { response }) {
    return response.status(this.status).json({
      success: false,
      error: this.message
    })
  }
}
 
// app/Exceptions/ResourceNotFoundException.js
class ResourceNotFoundException extends LogicalException {
  constructor(resource = 'Resource', id = null) {
    super(`${resource} not found`, 404, 'E_NOT_FOUND')
  }

  handle(error, { response }) {
    return response.status(404).json({
      success: false,
      error: this.message
    })
  }
}

These custom exceptions handle specific scenarios with relevant context. They include their own response logic, so the Global Exception Handler can delegate to them automatically.

Custom exceptions make your code clearer by making error conditions explicit. They also ensure consistent error responses across your entire app.

Controller error handling

Controllers sit between HTTP requests and your business logic. They need solid error handling to maintain consistent API responses.

Try-catch patterns in controllers

Controllers should expect failures and provide meaningful responses:

 
// app/Controllers/Http/UserController.js
class UserController {
  async store({ request, response }) {
    try {
      const userData = request.only(['email', 'password', 'name'])

      const existingUser = await User.findBy('email', userData.email)
      if (existingUser) {
        throw new BusinessLogicException('User already exists')
      }

      const user = await User.create(userData)
      return response.status(201).json({ success: true, data: user })
    } catch (error) {
      if (error.code === 'ER_DUP_ENTRY') {
        throw new BusinessLogicException('Email already in use')
      }
      throw error
    }
  }

  async show({ params, response }) {
    const user = await User.find(params.id)

    if (!user) {
      throw new ResourceNotFoundException('User')
    }

    return response.json({ success: true, data: user })
  }
}

This controller checks for business rule violations early and transforms database errors into user-friendly messages. Custom exceptions handle specific cases while unexpected errors bubble up to the global handler.

Each method provides appropriate error context, making debugging easier and giving API consumers meaningful feedback.

Service layer error delegation

For complex business logic, let service classes handle domain-specific errors:

 
// app/Services/UserService.js
class UserService {
  async createUser(userData) {
    const existingUser = await User.findBy('email', userData.email)
    if (existingUser) {
      throw new BusinessLogicException('User already exists')
    }

    const user = await User.create(userData)

    try {
      await EmailService.send('welcome', user.email, { name: user.name })
    } catch (error) {
      console.warn('Welcome email failed')
    }

    return user
  }
}

This service handles domain-specific validations and error scenarios. The controller focuses on HTTP concerns while the service manages business logic errors.

This pattern enables complex error handling like graceful degradation when secondary services fail, while keeping clear boundaries between application layers.

Middleware error handling

Middleware runs in the request-response pipeline. You need careful error handling to prevent pipeline breaks and ensure consistent error responses.

Authentication middleware errors

Authentication middleware must handle various failure scenarios with clear feedback:

 
// app/Middleware/Auth.js
class Auth {
  async handle({ request, response }, next) {
    const token = request.header('Authorization')?.replace('Bearer ', '')

    if (!token) {
      return response.status(401).json({
        success: false,
        error: 'Token required'
      })
    }

    try {
      const decoded = jwt.verify(token, process.env.JWT_SECRET)
      const user = await User.find(decoded.userId)

      if (!user) {
        return response.status(401).json({
          success: false,
          error: 'Invalid token'
        })
      }

      request.user = user
      await next()
    } catch (error) {
      return response.status(401).json({
        success: false,
        error: 'Invalid token'
      })
    }
  }
}

This middleware handles missing tokens, expired tokens, and invalid tokens with appropriate responses. It returns responses directly rather than throwing exceptions, preventing errors from breaking the middleware pipeline.

Request validation middleware

Validation middleware should provide detailed error information while stopping invalid data from reaching your app:

 
// app/Middleware/ValidateRequest.js
class ValidateRequest {
  async handle({ request, response }, next) {
    const validation = await validate(request.all(), this.rules)

    if (validation.fails()) {
      return response.status(422).json({
        success: false,
        error: 'Validation failed',
        details: validation.messages()
      })
    }

    await next()
  }
}

This middleware gives structured error responses with field-level feedback, helping client apps show specific validation errors to users.

Error logging middleware

Add request logging middleware to capture comprehensive error context:

 
// app/Middleware/RequestLogger.js
class RequestLogger {
  async handle({ request }, next) {
    const start = Date.now()

    try {
      await next()
      console.log(`${request.method()} ${request.url()} - ${Date.now() - start}ms`)
    } catch (error) {
      console.error(`${request.method()} ${request.url()} - ERROR: ${error.message}`)
      throw error
    }
  }
}

This logging middleware captures request context while maintaining the error handling pipeline. It provides valuable debugging information when issues occur.

Model and database error handling

Database operations cause many potential errors in AdonisJS apps. Proper error handling at the model level ensures data integrity while providing meaningful feedback about database failures.

Model-level error handling

Your Lucid models should handle common database scenarios:

 
// app/Models/User.js
class User extends Model {
  static async createUser(userData) {
    try {
      return await this.create(userData)
    } catch (error) {
      if (error.code === 'ER_DUP_ENTRY') {
        throw new BusinessLogicException('Email already exists')
      }
      throw error
    }
  }

  static async findOrFail(id) {
    const user = await this.find(id)
    if (!user) {
      throw new ResourceNotFoundException('User')
    }
    return user
  }
}

This model transforms database errors into meaningful business exceptions. The pattern enables consistent error handling across different database operations while maintaining the model's interface.

Database transaction error handling

Database transactions need careful error handling to ensure data consistency:

 
// app/Services/OrderService.js
class OrderService {
  async createOrder({ userId, items }) {
    const trx = await Database.beginTransaction()

    try {
      const order = await Order.create({
        user_id: userId,
        total: this.calculateTotal(items)
      }, trx)

      for (const item of items) {
        await this.processOrderItem(order.id, item, trx)
      }

      await trx.commit()
      return order
    } catch (error) {
      await trx.rollback()

      if (error.code === 'ER_LOCK_WAIT_TIMEOUT') {
        throw new BusinessLogicException('Processing timeout, retry')
      }

      throw new BusinessLogicException('Order creation failed')
    }
  }
}

This transaction handling ensures data consistency by rolling back changes when errors occur. The service distinguishes between business logic errors and database-specific errors, providing appropriate messages for each scenario.

Async operation error handling

Async operations in AdonisJS need careful error handling to prevent unhandled promise rejections and ensure proper error propagation.

Promise error handling

Handle async operations with comprehensive error management:

 
// app/Services/NotificationService.js
class NotificationService {
  async sendNotifications(users, message) {
    const results = await Promise.allSettled(
      users.map(user => this.sendNotification(user, message))
    )

    const failed = results.filter(r => r.status === 'rejected').length

    return { sent: results.length - failed, failed }
  }

  async sendNotification(user, message) {
    try {
      await this.sendEmail(user, message)
    } catch (error) {
      await this.sendSMS(user, message)
    }
  }

  async sendEmail(user, message) {
    await Mail.send('notification', { message }, mail => {
      mail.to(user.email).subject('Notification')
    })
  }
}

This service handles multiple async operations with fallback mechanisms. It uses Promise.allSettled to handle batch operations gracefully, providing detailed results about successful and failed operations.

The error handling implements a cascade pattern where email failures trigger SMS attempts, ensuring notification delivery through alternative methods.

Background job error handling

Background jobs need robust error handling with retry mechanisms:

 
// app/Jobs/ProcessPayment.js
class ProcessPayment extends Job {
  async handle({ paymentId, attempt = 1 }) {
    try {
      const payment = await Payment.find(paymentId)
      if (!payment || payment.status === 'completed') return

      const result = await PaymentGateway.processPayment(payment)

      payment.status = 'completed'
      await payment.save()
    } catch (error) {
      if (attempt < 3 && this.shouldRetry(error)) {
        await this.retry({ paymentId, attempt: attempt + 1 }, 2000 * attempt)
        return
      }

      await this.markFailed(paymentId)
      throw error
    }
  }

  shouldRetry(error) {
    return ['TIMEOUT', 'NETWORK_ERROR'].includes(error.code)
  }

  async markFailed(paymentId) {
    const payment = await Payment.find(paymentId)
    if (payment) {
      payment.status = 'failed'
      await payment.save()
    }
  }
}

This job handles payment processing with intelligent retry logic. It distinguishes between retryable errors and permanent failures, implementing exponential backoff for temporary issues while avoiding unnecessary retries for client errors.

Testing error scenarios

Good error handling requires thorough testing to ensure all error paths work correctly under various failure conditions.

Unit testing error conditions

Test error handling by simulating various failure scenarios:

 
// test/unit/user-service.spec.js
test('should throw for duplicate email', async ({ assert }) => {
  const userData = { email: 'test@example.com', password: 'password123' }

  await UserService.createUser(userData)

  await assert.rejects(
    () => UserService.createUser(userData),
    BusinessLogicException
  )
})
 
// test/functional/user-controller.spec.js
test('should return 422 for invalid data', async ({ client }) => {
  const response = await client
    .post('/api/users')
    .send({ email: 'invalid', password: '123' })
    .expect(422)

  response.assertJSONSubset({
    success: false,
    error: 'Validation failed'
  })
})

test('should return 400 for duplicate email', async ({ client }) => {
  await client
    .post('/api/users')
    .send({ email: 'test@example.com', password: 'password123' })
    .expect(201)

  await client
    .post('/api/users')
    .send({ email: 'test@example.com', password: 'password456' })
    .expect(400)
})

These tests verify that error handling works correctly across different application layers, ensuring consistent error responses and proper error propagation.

Final thoughts

Effective error handling in AdonisJS requires a layered approach that addresses errors at multiple levels while maintaining a consistent user experience.

The framework's built-in exception handling tools, combined with custom error classes and robust middleware patterns, provide a strong foundation for managing failures gracefully.

When you implement error handling well, you turn potential disasters into manageable user experiences. You provide clear feedback while maintaining the stability of your app.

Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github