Backend Development

Routes & Controllers

HTTP routing and the controller-per-action pattern.

Nuda Kit uses a controller-per-action pattern where each HTTP action has its own dedicated controller. Routes are organized by domain and automatically documented with Swagger.

API Documentation

The backend comes with adonis-autoswagger pre-configured. This generates interactive API documentation from your controller comments.

Accessing the Docs

URLDescription
http://localhost:3333/docsSwagger UI (interactive)
http://localhost:3333/swaggerRaw OpenAPI JSON
The docs UI is password-protected in production. Set SWAGGER_USERNAME and SWAGGER_PASSWORD in your .env file.

Route Organization

Routes are split by domain in start/routes/:

start/routes/
├── users.ts        # Authentication, profile
├── teams.ts        # Team management
├── invitations.ts  # Invitation handling
├── billing.ts      # Stripe integration
├── plans.ts        # Subscription plans
├── ai.ts           # AI endpoints
├── webhooks.ts     # External webhooks
└── docs.ts         # Swagger documentation

Defining Routes

Routes use lazy-loaded controller imports for better performance:

import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'

// Lazy-load controllers
const CreateTeamController = () => import('#controllers/teams/create_team_controller')
const GetAllTeamsController = () => import('#controllers/teams/get_all_teams_controller')

router
  .group(() => {
    router.post('/', [CreateTeamController, 'execute'])
    router.get('/', [GetAllTeamsController, 'execute'])
  })
  .prefix('/teams')
  .middleware(middleware.auth({ guards: ['api'] }))

Controller-per-Action Pattern

Each controller handles exactly one action with an execute method:

// app/controllers/teams/create_team_controller.ts

export default class CreateTeamController {
  public async execute({ request, response, auth }: HttpContext) {
    const user = auth.user!
    const payload = await request.validateUsing(createTeamValidator)

    const team = await Team.create({ name: payload.name })
    
    return response.created({
      message: 'team created',
      data: { team: team.serialize() },
    })
  }
}

Why This Pattern?

Single Responsibility

Each file does one thing. Easy to understand at a glance.

Easy Testing

Test one action without loading unrelated code.

Better Navigation

Find any endpoint instantly by searching the controller name.

Smaller Files

No 500-line resource controllers with 10 methods.

Swagger Documentation

Document your controllers with JSDoc comments. The annotations are automatically parsed to generate OpenAPI documentation.

Basic Annotations

export default class CreateTeamController {
  /**
   * @execute
   * @tag Teams
   * @summary Creates a new team
   * @description Detailed description of what this endpoint does
   * @requestBody <createTeamValidator>
   * @responseBody 201 - {"message": "team created", "data": {"team": "<Team>"}}
   * @responseBody 401 - {errors: [{"message": "Unauthorized access"}]}
   * @responseBody 422 - {"errors": [{"message": string, "rule": string, "field": string}]}
   */
  public async execute({ request, response, auth }: HttpContext) {
    // ...
  }
}

Available Annotations

AnnotationPurpose
@tagGroups endpoints in Swagger UI
@summaryShort description (shown in endpoint list)
@descriptionDetailed description
@requestBodyRequest body schema (reference validator)
@responseBodyResponse format for status code
@paramPathPath parameter description
@paramQueryQuery parameter description

Referencing Validators

Use <validatorName> to reference VineJS validators:

/**
 * @requestBody <createTeamValidator>
 */

The validator schema is automatically converted to OpenAPI format.

Referencing Models

Use <ModelName> to reference Lucid models:

/**
 * @responseBody 200 - {"data": {"team": "<Team>"}}
 */

Path Parameters

/**
 * @paramPath id - The team ID - @type(number) @required
 */

Query Parameters

/**
 * @paramQuery page - Page number - @type(number)
 * @paramQuery limit - Items per page - @type(number)
 */

Route Groups

Group routes with common middleware or prefixes:

router
  .group(() => {
    // All routes in this group require authentication
    router.get('/me', [GetAuthenticatedUserController, 'execute'])
    router.put('/', [UpdateUserDetailController, 'execute'])
    router.delete('/', [DeleteUserController, 'execute'])
  })
  .prefix('/users')
  .middleware(middleware.auth({ guards: ['api'] }))

Named Routes

Name routes for URL generation:

router
  .get('/login/magic-link/:email/verify', [VerifyUserMagicLinkController, 'execute'])
  .as('users.login_user_magic_link_verify')

Use in your code:

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

const url = router.builder()
  .params({ email: 'user@example.com' })
  .makeSigned('users.login_user_magic_link_verify', { expiresIn: '5 minutes' })

Response Conventions

Nuda Kit follows consistent response formats:

Success Responses

// 200 OK - Read operations
return response.ok({
  message: 'teams retrieved',
  data: { teams: teams.map(t => t.serialize()) },
})

// 201 Created - Create operations
return response.created({
  message: 'team created',
  data: { team: team.serialize() },
})

// 204 No Content - Delete operations
return response.noContent()

Error Responses

// 401 Unauthorized
return response.unauthorized({
  errors: [{ message: 'Unauthorized access' }],
})

// 403 Forbidden
return response.forbidden({
  errors: [{ message: 'You do not have permission' }],
})

// 404 Not Found
return response.notFound({
  errors: [{ message: 'Team not found' }],
})

// 422 Validation Error (automatic from VineJS)
{
  errors: [
    { message: 'The name field is required', rule: 'required', field: 'name' }
  ]
}

Creating a New Endpoint

Create the Controller

Create a new file in the appropriate domain folder:

// app/controllers/teams/archive_team_controller.ts

export default class ArchiveTeamController {
  /**
   * @execute
   * @tag Teams
   * @summary Archive a team
   * @paramPath id - Team ID - @type(number) @required
   * @responseBody 200 - {"message": "team archived"}
   * @responseBody 404 - {"errors": [{"message": "Team not found"}]}
   */
  public async execute({ params, response }: HttpContext) {
    const team = await Team.findOrFail(params.id)
    await team.merge({ archived: true }).save()
    
    return response.ok({ message: 'team archived' })
  }
}

Add the Route

Register in the appropriate route file:

// start/routes/teams.ts

const ArchiveTeamController = () => import('#controllers/teams/archive_team_controller')

router.post('/:id/archive', [ArchiveTeamController, 'execute'])

Verify in Swagger

Visit http://localhost:3333/docs to see your new endpoint documented automatically.


Resources