Skip to content

Jubiloop Controller & Routing Guide

Overview

This guide provides quick getting started examples on how to create and implement controllers, routes, validators, and transformers in the Jubiloop backend application. For more detailed information, please refer to the AdonisJS documentation.

Table of Contents

Creating Controllers and Routes

When implementing new routes in Jubiloop, follow these general steps:

  1. Create validator(s) to validate incoming data
  2. Create transformer(s) to format outgoing data
  3. Create controller(s) with handlers for the specific operations
  4. Add routes to expose the controller endpoints
  5. Apply authentication middleware as needed

This guide provides examples based on the existing codebase. For comprehensive documentation, refer to the AdonisJS Controller Guide and AdonisJS Routing Guide.

Controllers

Controllers handle HTTP concerns: validation, authorization, service calls, status codes, and serialization. Business flow belongs in services, while repositories own direct Lucid access.

Controller Structure

Controllers in Jubiloop follow a class-based approach with well-defined methods for specific operations:

typescript
import type { HttpContext } from '@adonisjs/core/http'
import { inject } from '@adonisjs/core'
import SomeTransformer from '#transformers/some_transformer'
import { someValidator } from '#validators/some_validator'
import { SomeService } from '#services/some_service'

import { handleAppError } from '../lib/errors/handle_app_error.js'
import { renderSuccessResponsePayload } from '../lib/utils/response.js'

@inject()
export default class SomeController {
  constructor(private someService: SomeService) {}

  async someMethod(ctx: HttpContext) {
    const { request, response } = ctx
    const payload = await request.validateUsing(someValidator)
    const result = await this.someService.create(payload)

    if (result.isErr()) {
      return handleAppError(result.error, ctx, {
        fallback: { message: 'Failed to create resource', logKey: 'resources:create' },
      })
    }

    response.status(201)
    return renderSuccessResponsePayload({
      data: new SomeTransformer(result.value).toObject(),
      message: 'Operation completed successfully',
    })
  }
}

Authentication Boundary

Jubiloop uses Better Auth, not AdonisJS auth. Authentication routes are handled by BetterAuthController and mounted at /auth/*. Do not create session controllers that call auth.use('web'); use app/lib/auth.ts and the Better Auth API instead.

Validators

Validators ensure that incoming data meets specific criteria before processing.

Validator Structure

Validators in Jubiloop use VineJS for validation:

typescript
import vine from '@vinejs/vine'

export const someValidator = vine.compile(
  vine.object({
    field1: vine.string().trim().minLength(1),
    field2: vine.number().min(1),
    optionalField: vine.boolean().optional(),
  }),
)

Custom Error Messages

typescript
import vine, { SimpleMessagesProvider } from '@vinejs/vine'

export const someValidator = vine.compile(
  vine.object({
    // validation rules
  }),
)

someValidator.messagesProvider = new SimpleMessagesProvider({
  'field1.minLength': 'Field1 must be at least 1 character long',
})

Example: Current Newsletter Validator

typescript
import vine from '@vinejs/vine'

export const subscribeValidator = vine.compile(
  vine.object({
    email: vine.string().email(),
  }),
)

Better Auth validates its own registration requests. Application controllers use domain validators such as subscribeValidator, createEventValidator, and createEventPlanValidator.

Transformers

Transformers format model data for API responses using AdonisJS v7's BaseTransformer. They live in app/transformers/ and use this.pick() for field selection. Types are auto-generated in .adonisjs/client/data.d.ts.

Transformer Structure

typescript
import { BaseTransformer } from '@adonisjs/core/transformers'
import SomeModel from '#models/some_model'

export default class SomeTransformer extends BaseTransformer<SomeModel> {
  toObject() {
    return this.pick(this.resource, ['id', 'name', 'createdAt'])
  }

  forDetailed() {
    return {
      ...this.toObject(),
      updatedAt: this.resource.updatedAt.toJSDate().toISOString(),
    }
  }
}

Example: User Transformer

typescript
import { BaseTransformer } from '@adonisjs/core/transformers'
import User from '#models/user'

export default class UserTransformer extends BaseTransformer<User> {
  toObject() {
    return this.pick(this.resource, ['id', 'email', 'name'])
  }
}

Usage in Controllers

typescript
// Default representation
data: new SomeTransformer(model).toObject()

// Named variant
data: new SomeTransformer(model).forDetailed()

Routes

Routes define the HTTP endpoints that clients can access in your application. They are defined in start/routes.ts and organized into public (unauthenticated) and authenticated groups.

Adding Public Routes

Public routes don't require authentication:

typescript
// In start/routes.ts
function getPublicRoutes() {
  return router.group(() => {
    // Better Auth owns /auth/* outside this group.
    router.get('/some-public-data', [YourController, 'publicMethod'])
  })
}

Adding Protected Routes

Protected routes require user authentication:

typescript
// In start/routes.ts
function getAuthenticatedRoutes() {
  return router
    .group(() => {
      router.get('/some-protected-data', [YourController, 'protectedMethod'])
      router.post('/some-resource', [YourController, 'createResource'])
    })
    .use([protectedRouteIpThrottle, middleware.auth(), authenticatedUserThrottle])
}

Route Structure

typescript
if (env.get('NODE_ENV') === 'production') {
  router.get('/health', [HealthCheckController]).use(apiThrottle)
} else {
  router.get('/health', [HealthCheckController])
}

getAuthenticatedRoutes()
getPublicRoutes().use(apiThrottle)

Import the limiters from start/limiter.ts. Apply the public throttle once to the public group; protected routes use their own IP and user limiters as shown above. See API rate limiting for quotas.

Routes mount at the API host root. Do not add an /api prefix.

For more details on AdonisJS routing, see the official documentation.

Nested vs Shallow Routes

Use nested collection routes when the parent resource defines the creation scope. For example, creating a plan belongs under the organization-scoped event collection:

http
POST /organizations/:organizationId/events/:eventId/plans

Use shallow member routes when the child resource ID is enough to identify the record and the parent path adds no extra authorization or lookup value.

Authentication

Jubiloop uses Better Auth integrated with AdonisJS. Refer to the Authentication Documentation for details.

Authorization

Jubiloop uses AdonisJS Bouncer to control access to controller actions.

Authorization with Bouncer

Bouncer provides a way to define permission rules for your controllers:

  • Abilities: Functions suitable for simple authorization checks across controllers
  • Policies: Classes for controller-specific authorization logic, typically one per resource

To use Bouncer in your controllers:

typescript
// Using abilities
async someMethod({ bouncer, response }: HttpContext) {
  if (await bouncer.allows('editResource', resource)) {
    // User is authorized to edit
  } else {
    return response.forbidden()
  }
}

// Using policies
async someMethod({ bouncer, response }: HttpContext) {
  if (await bouncer.with(ResourcePolicy).denies('update', resource)) {
    return response.forbidden()
  }

  // Continue with authorized action
}

For more details, see the AdonisJS Authorization Documentation.

Response Structure

All controller responses in Jubiloop follow a standardized format to ensure consistency across the API:

typescript
export interface IApiResponse<TData = unknown, TMeta = unknown> {
  data?: TData // Main response data
  messages?: TResponseMessage[] // Success/info messages
  errors?: TResponseError[] // Error messages
  meta?: TMeta // Optional response metadata
}

export type TResponseMessage = {
  title: string // Main message text
  description?: string // Optional details
}

export type TResponseError = {
  message: string // Error message text
  code: string // Three-part error code, e.g. common.resource.not_found
  field?: string // Field name for field-specific errors
  meta?: unknown // Optional client-safe metadata
}

Helper Functions

Controllers should use these utility functions to generate consistent responses:

typescript
// Success response with data and a message
renderSuccessResponsePayload({
  data: someData,
  message: 'Operation successful',
})

// Error response
renderErrorResponsePayload({
  errorMessage: 'Something went wrong',
  code: 'common.server.internal',
})

For Result error branches, prefer handleAppError() so app exceptions keep one response/logging path:

typescript
if (result.isErr()) {
  return handleAppError(result.error, ctx, {
    fallback: {
      message: 'Failed to create event plan',
      logKey: 'events:plans:create',
    },
  })
}

App Exception Architecture

Server-owned exceptions separate client-facing API errors from backend-only logging details. Controllers pass expected Result failures to handleAppError(); each AppException supplies its client-safe error and structured log context. Repository exceptions map data-access failures, model validation exceptions represent invariants, and service exceptions represent orchestration failures.

Use RepositoryException factories for not-found, query, and persistence failures. Use a ModelValidationException when an invariant must hold for every write path. Service exceptions should add context only when the failure belongs to orchestration rather than HTTP or persistence. Public errors must use the shared three-part codes; sensitive causes belong only in structured logs.

Controller Response Examples

Success response from a controller:

json
{
  "data": {
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "name": "Example Item"
  },
  "messages": [
    {
      "title": "Item created successfully"
    }
  ]
}

Error response from a controller:

json
{
  "errors": [
    {
      "message": "Validation failed",
      "code": "input.name.required",
      "field": "name"
    }
  ]
}

Complete Controller Example

This representative mutation keeps HTTP work in the controller and delegates expected failures to a service/repository Result flow.

typescript
import type { HttpContext } from '@adonisjs/core/http'
import { inject } from '@adonisjs/core'
import ItemTransformer from '#transformers/item_transformer'
import { itemValidator } from '#validators/item'
import { ItemService } from '#services/item_service'

import { handleAppError } from '../lib/errors/handle_app_error.js'
import { renderSuccessResponsePayload } from '../lib/utils/response.js'

@inject()
export default class ItemsController {
  constructor(private itemService: ItemService) {}

  async store(ctx: HttpContext) {
    const { request, response } = ctx
    const payload = await request.validateUsing(itemValidator)
    const result = await this.itemService.create(payload)

    if (result.isErr()) {
      return handleAppError(result.error, ctx, {
        fallback: { message: 'Failed to create item', logKey: 'items:create' },
      })
    }

    response.status(201)
    return renderSuccessResponsePayload({
      data: new ItemTransformer(result.value).toObject(),
      message: 'Item created successfully',
    })
  }
}

Built with ❤️ by the Jubiloop team