Extending the framework

This guide covers how to extend AdonisJS with custom functionality. You will learn how to:

  • Add custom methods to framework classes using macros
  • Create computed properties with getters
  • Ensure type safety with TypeScript declaration merging
  • Organize extension code in your application
  • Extend specific framework modules like Hash, Session, and Authentication

Overview

AdonisJS provides a powerful extension system that lets you add custom methods and properties to framework classes without modifying the framework's source code. This means you can enhance the Request class with custom validation logic, add utility methods to the Response class, or extend any other framework class to fit your application's specific needs.

The extension system is built on two core concepts: macros (custom methods) and getters (computed properties). Both are added at runtime and integrate seamlessly with TypeScript through declaration merging, giving you full type safety and autocomplete in your editor.

This same extension API is used throughout AdonisJS's own first-party packages, making it a proven pattern for building reusable functionality. Whether you're adding a few helper methods for your application or building a package to share with the community, the extension system provides a clean, type-safe way to enhance the framework.

Why extend the framework?

Before diving into the mechanics, let's understand when and why you'd want to extend framework classes.

Without extensions, you'd need to write the same logic repeatedly across your application. For example, checking if a request expects JSON responses:

app/controllers/posts_controller.ts
export default class PostsController {
  async index({ request, response }: HttpContext) {
    // Repeated in every action that returns different formats
    const acceptHeader = request.header('accept', '')
    const wantsJSON = acceptHeader.includes('application/json') || 
                      acceptHeader.includes('+json')
    
    if (wantsJSON) {
      return response.json({ posts: [] })
    }
    
    return view.render('posts/index')
  }
}

With a macro, you write this logic once and use it everywhere:

src/extensions.ts
import { Request } from '@adonisjs/core/http'

/**
 * Check if the request expects a JSON response based on Accept header
 */
Request.macro('wantsJSON', function (this: Request) {
  const firstType = this.types()[0]
  if (!firstType) {
    return false
  }
  
  return firstType.includes('/json') || firstType.includes('+json')
})
app/controllers/posts_controller.ts
export default class PostsController {
  async index({ request, response }: HttpContext) {
    if (request.wantsJSON()) {
      return response.json({ posts: [] })
    }
    
    return view.render('posts/index')
  }
}

Extensions are ideal when you:

  • Have framework-specific logic reused across your application
  • Want to maintain AdonisJS's fluent API style
  • Are building a package that integrates deeply with the framework
  • Need type-safe custom functionality with autocomplete support

Understanding macros and getters

Before we start adding extensions, let's clarify what macros and getters are and when to use each.

Macros are custom methods you add to a class. They work like regular methods and can accept parameters, perform computations, and return values. Use macros when you need functionality that requires input or performs actions.

Getters are computed properties that look like regular properties when you access them. They're calculated on-demand and can optionally cache their result. Use getters for read-only derived data that doesn't require parameters.

Both macros and getters use declaration merging, a TypeScript feature that extends existing type definitions to include your custom additions. This ensures your extensions have full type safety and autocomplete support.

Under the hood, AdonisJS uses the macroable package to implement this functionality. If you want to understand the implementation details, you can refer to that package's documentation.

Creating your first macro

Let's build a simple macro step-by-step. We'll add a method to the Request class that checks if the incoming request is from a mobile device.

  1. Create the extensions file

    Create a dedicated file to hold all your framework extensions. This keeps your extension code organized in one place.

    src/extensions.ts
    // This file contains all framework extensions for your application

    The file can be named anything you like, but extensions.ts clearly communicates its purpose.

  2. Import the class you want to extend

    Import the framework class you want to add functionality to. For our example, we'll extend the Request class.

    src/extensions.ts
    import { Request } from '@adonisjs/core/http'
  3. Add the macro method

    Use the macro method to add your custom functionality. The method receives the class instance as this, giving you access to all the class's existing properties and methods.

    src/extensions.ts
    import { Request } from '@adonisjs/core/http'
    
    Request.macro('isMobile', function (this: Request) {
      /**
       * Get the User-Agent header, defaulting to empty string if not present
       */
      const userAgent = this.header('user-agent', '')
      
      /**
       * Check if the User-Agent contains common mobile identifiers
       */
      return /mobile|android|iphone|ipad|phone/i.test(userAgent)
    })

    The function (this: Request) syntax is important because it gives you the correct this context. Don't use arrow functions here, as they don't preserve the this binding.

  4. Add TypeScript type definitions

    Tell TypeScript about your new method using declaration merging. Add this at the end of your extensions file.

    src/extensions.ts
    declare module '@adonisjs/core/http' {
      interface Request {
        isMobile(): boolean
      }
    }

    The module path in declare module must exactly match the import path you use. The interface name must exactly match the class name.

  5. Load extensions in your provider

    Import your extensions file in a service provider's boot method to ensure the extensions are registered when your application starts.

    providers/app_provider.ts
    export default class AppProvider {
      async boot() {
        await import('../src/extensions.ts')
      }
    }
  6. Use your macro

    Your macro is now available throughout your application with full type safety and autocomplete.

    app/controllers/home_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    
    export default class HomeController {
      async index({ request, view }: HttpContext) {
        /**
         * TypeScript knows about isMobile() and provides autocomplete
         */
        if (request.isMobile()) {
          return view.render('mobile/home')
        }
        
        return view.render('home')
      }
    }

Creating your first getter

Getters are computed properties that work like regular properties but are calculated on-demand. Let's add a getter to the Request class that provides a cleaned version of the request path.

src/extensions.ts
import { Request } from '@adonisjs/core/http'

Request.getter('cleanPath', function (this: Request) {
  /**
   * Get the current URL path
   */
  const path = this.url()
  
  /**
   * Remove trailing slashes and convert to lowercase
   */
  return path.replace(/\/+$/, '').toLowerCase()
})
src/extensions.ts
declare module '@adonisjs/core/http' {
  interface Request {
    cleanPath: string  // Note: property, not a method
  }
}

Notice the type declaration differs from macros. Getters are properties, not methods, so you don't include () in the type definition.

You can use getters like regular properties:

app/middleware/log_middleware.ts
export default class LogMiddleware {
  async handle({ request, logger }: HttpContext, next: NextFn) {
    /**
     * Access the getter like a property, not a method
     */
    logger.info('Request path: %s', request.cleanPath)
    
    await next()
  }
}
Note

Getter callbacks cannot be async because JavaScript getters are synchronous by design. If you need async computation, use a macro instead.

Singleton getters

By default, getters recalculate their value every time you access them. For expensive computations, you can make a getter a singleton, which caches the result after the first calculation.

src/extensions.ts
import { Request } from '@adonisjs/core/http'

/**
 * The third parameter (true) makes this a singleton getter
 */
Request.getter('ipAddress', function (this: Request) {
  /**
   * Check for proxy headers first, fall back to direct IP
   * This only runs once per request instance
   */
  return this.header('x-forwarded-for') || 
         this.header('x-real-ip') || 
         this.ips()[0] || 
         this.ip()
}, true)
src/extensions.ts
declare module '@adonisjs/core/http' {
  interface Request {
    ipAddress: string
  }
}

With singleton getters, the function executes once per instance of the class, and the return value is cached for that instance:

const ip1 = request.ipAddress  // Executes the getter function
const ip2 = request.ipAddress  // Returns cached value, doesn't re-execute
const ip3 = request.ipAddress  // Still returns cached value
Tip

Use singleton getters when the computed value won't change during the instance's lifetime. For example, a request's IP address won't change during a single HTTP request, so caching it makes sense.

Don't use singleton getters for values that might change, like computed properties based on mutable state.

When to use macros vs getters

Choosing between macros and getters depends on your use case. Here's a practical guide.

Use macros when you need to:

  • Accept parameters
  • Perform actions with side effects
  • Return different values based on input
  • Execute async operations
src/extensions.ts
/**
 * Macro example: Accepts a role parameter
 */
Request.macro('hasRole', function (this: Request, role: string) {
  const user = this.ctx.auth.user
  return user?.role === role
})

// Usage: request.hasRole('admin')

Use getters when you need to:

  • Provide computed read-only properties
  • Calculate derived data from existing properties
  • Cache expensive computations (with singleton)
  • Maintain a property-like API
src/extensions.ts
/**
 * Getter example: Computed property with no parameters
 */
Request.getter('isAuthenticated', function (this: Request) {
  return this.ctx.auth.isAuthenticated
})

// Usage: request.isAuthenticated

Both can coexist on the same class. Choose based on the API you want to provide.

Understanding declaration merging

Declaration merging is how TypeScript learns about your runtime extensions. Getting this right is crucial for type safety.

The module path in your declare module statement must exactly match the path you use to import the class:

src/extensions.ts
// If you import like this:
import { Request } from '@adonisjs/core/http'

// You must declare like this (exact same path):
declare module '@adonisjs/core/http' {
  interface Request {
    isMobile(): boolean
  }
}
Warning

Why this matters: TypeScript uses the module path to determine which type definition to merge with.

What happens: If the paths don't match, TypeScript won't recognize your extension. You'll see errors like "Property 'isMobile' does not exist on type 'Request'" even though your code runs correctly.

Solution: Always copy the exact import path when writing your declaration:

// ✅ Correct: Paths match
import { Request } from '@adonisjs/core/http'
declare module '@adonisjs/core/http' { ... }

// ❌ Wrong: Paths don't match
import { Request } from '@adonisjs/core/http'
declare module '@adonisjs/http-server' { ... }

You can declare multiple extensions in the same declare module block:

src/extensions.ts
declare module '@adonisjs/core/http' {
  interface Request {
    isMobile(): boolean
    hasRole(role: string): boolean
    cleanPath: string
    ipAddress: string
  }
}

Or split them across multiple blocks if you prefer:

src/extensions.ts
declare module '@adonisjs/core/http' {
  interface Request {
    isMobile(): boolean
  }
}

declare module '@adonisjs/core/http' {
  interface Request {
    hasRole(role: string): boolean
  }
}

Both approaches work identically. Choose based on your organization preferences.

Common mistakes

Here are the most common issues developers encounter when extending the framework and how to fix them.

Tip

Mistake: Using arrow functions for macros

Why it fails: Arrow functions don't have their own this binding, so you can't access the class instance.

// ❌ Wrong: Arrow function
Request.macro('isMobile', () => {
  return this.header('user-agent')  // `this` is undefined!
})

// ✅ Correct: Regular function
Request.macro('isMobile', function (this: Request) {
  return this.header('user-agent')  // `this` is the Request instance
})
Tip

Mistake: Forgetting the singleton parameter defaults to false

What happens: Your getter recalculates every time it's accessed, even if the value won't change.

// This executes the function every single time
Request.getter('expensiveCalculation', function (this: Request) {
  return someExpensiveOperation()
})

// Add true for singleton to cache the result
Request.getter('expensiveCalculation', function (this: Request) {
  return someExpensiveOperation()
}, true)  // Caches after first access
Tip

Mistake: Treating getters like methods

What happens: You'll get errors because getters are properties, not functions.

Request.getter('ipAddress', function (this: Request) {
  return this.ip()
})

// ❌ Wrong: Calling it like a method
const ip = request.ipAddress()

// ✅ Correct: Accessing it like a property
const ip = request.ipAddress

Macroable classes

The following framework classes support macros and getters. Each entry includes the import path and typical use cases.

Application

The main application instance. Extend this to add application-level utilities.

Common use cases: Add custom environment checks, application state getters, or global configuration accessors.

Example: Add a getter to check if the app is running in a specific mode.

import app from '@adonisjs/core/services/app'

app.getter('isProduction', function () {
  return this.inProduction
})

View source

Request

The HTTP request class. Extend this to add request validation or parsing logic.

Common use cases: Add methods for checking request characteristics, parsing custom headers, or validating request types.

Example: Add a method to check if the request is an AJAX request.

import { Request } from '@adonisjs/core/http'

Request.macro('isAjax', function (this: Request) {
  return this.header('x-requested-with') === 'XMLHttpRequest'
})

View source

Response

The HTTP response class. Extend this to add custom response methods or formatters.

Common use cases: Add methods for sending formatted responses, setting common headers, or handling specific response types.

Example: Add a method for sending paginated JSON responses.

import { Response } from '@adonisjs/core/http'

Response.macro('paginated', function (this: Response, data: any, meta: any) {
  return this.json({ data, meta })
})

View source

HttpContext

The HTTP context class passed to route handlers and middleware. Extend this to add context-level utilities.

Common use cases: Add helpers that combine request and response logic, or add shortcuts for common operations.

Example: Add a method to get the current user or fail.

import { HttpContext } from '@adonisjs/core/http'

HttpContext.macro('getCurrentUser', async function (this: HttpContext) {
  return await this.auth.getUserOrFail()
})

View source

Route

Individual route instances. Extend this to add custom route configuration methods.

Common use cases: Add methods for applying common middleware patterns, setting route metadata, or configuring routes in specific ways.

Example: Add a method to mark routes as requiring authentication.

import { Route } from '@adonisjs/core/http'

Route.macro('protected', function (this: Route) {
  return this.middleware('auth')
})

View source

RouteGroup

Route group instances. Extend this to add custom group-level configuration.

Common use cases: Add methods for applying common patterns to groups of routes.

Example: Add a method to apply API versioning to a group.

import { RouteGroup } from '@adonisjs/core/http'

RouteGroup.macro('apiVersion', function (this: RouteGroup, version: number) {
  return this.prefix(`/api/v${version}`)
})

View source

RouteResource

Resourceful route instances. Extend this to customize resource route behavior.

Common use cases: Add methods for customizing which resource routes are created or adding resource-specific middleware.

View source

BriskRoute

Brisk (quick) route instances used for simple route definitions. Extend this for shortcuts.

Common use cases: Add convenience methods for quick route configurations.

View source

ExceptionHandler

The global exception handler. Extend this to add custom error handling methods.

Common use cases: Add methods for handling specific error types or formatting error responses.

Example: Add a method to handle validation errors consistently.

import { ExceptionHandler } from '@adonisjs/core/http'

ExceptionHandler.macro('handleValidationError', function (error: any) {
  return this.ctx.response.status(422).json({ errors: error.messages })
})

View source

MultipartFile

Uploaded file instances. Extend this to add file validation or processing methods.

Common use cases: Add methods for validating file types, processing images, or generating thumbnails.

Example: Add a method to check if a file is an image.

import { MultipartFile } from '@adonisjs/core/bodyparser'

MultipartFile.macro('isImage', function (this: MultipartFile) {
  return this.type?.startsWith('image/')
})

View source

Extending specific modules

Beyond macros and getters, many AdonisJS modules provide dedicated extension APIs for adding custom implementations. These are designed for more complex integrations like custom drivers or loaders.

The following modules can be extended with custom implementations:

These extension points go beyond simple methods and properties, allowing you to deeply integrate custom functionality into the framework.

Next steps

Now that you understand how to extend the framework, you can: