Skip to content

Core concepts ​

This page builds a mental model for the five pillars of every BananaJS app: controllers, routing, validation, responses, and errors. If you ran the Quickstart, you've already seen these in action — this page explains the "why" behind each decision.

What is a controller? ​

A controller is a TypeScript class whose methods map to HTTP endpoints. Think of it as a router file that self-describes what it handles — no separate route registration needed.

typescript
import { Controller, Get } from '@banana-universe/bananajs'
import type { Request, Response } from 'express'

@Controller('users')           // Base path: /users
export class UserController {
  @Get('')                     // GET /users
  list(_req: Request, res: Response) {
    res.json({ users: [] })
  }

  @Get(':id')                  // GET /users/:id
  getOne(req: Request, res: Response) {
    res.json({ id: req.params.id })
  }
}

Key rule: Both @Controller and route decorators use slash-free segments. The framework joins them into Express paths automatically. Write 'users', not '/users'.

Registering controllers ​

Controllers live inside modules. A module is a feature slice that groups a controller with its providers:

typescript
import { createModule, BananaApp } from '@banana-universe/bananajs'

export const userModule = createModule({
  id: 'user',
  controller: UserController,
  providers: [],              // services, repos, etc. added here
})

await BananaApp.create({ modules: [userModule] })

BananaJS reads the decorator metadata at startup, creates an Express Router per controller, and wires everything together. You never call new UserController() yourself.

HTTP method decorators ​

DecoratorMethodExample path
@Get(path)GET@Get('list') → GET /users/list
@Post(path)POST@Post('') → POST /users
@Put(path)PUT@Put(':id') → PUT /users/:id
@Patch(path)PATCH@Patch(':id/status') → PATCH /users/:id/status
@Delete(path)DELETE@Delete(':id') → DELETE /users/:id

All five accept optional per-route middlewares as additional arguments:

typescript
@Get('protected', authMiddleware, logMiddleware)
protectedRoute(req: Request, res: Response) { ... }

Validation ​

BananaJS validates request data declaratively using validation schemas. Add a decorator and the framework runs validation before your handler — you never write if (!req.body.email) return res.status(400)... manually.

typescript
import { Body, Params, Query } from '@banana-universe/bananajs'
import { z } from 'zod'

// --- Schemas ---
const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  age: z.coerce.number().int().min(0).optional(),
})

const UserIdSchema = z.object({
  id: z.string().uuid(),
})

const SearchSchema = z.object({
  q: z.string().optional(),
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
})

// --- Controller ---
@Controller('users')
export class UserController extends BaseController {

  @Get('')
  @Query(SearchSchema)          // Validates req.query
  list(req: Request, res: Response) {
    const { q, page, limit } = req.query as z.infer<typeof SearchSchema>
    // page and limit are already numbers, not strings
    return this.ok(res, 'Users fetched', { q, page, limit })
  }

  @Post('')
  @Body(CreateUserSchema)       // Validates req.body
  create(req: Request, res: Response) {
    const data = req.body as z.infer<typeof CreateUserSchema>
    // data.email, data.name, data.age are fully typed
    return this.ok(res, 'User created', data)
  }

  @Get(':id')
  @Params(UserIdSchema)         // Validates req.params
  getOne(req: Request, res: Response) {
    const { id } = req.params as z.infer<typeof UserIdSchema>
    return this.ok(res, 'User found', { id })
  }
}

What happens when validation fails? BananaJS throws a BadRequestError automatically. Your handler never runs. The response shape is consistent:

json
{
  "statusCode": "error",
  "status": 400,
  "message": "Validation failed: email: Invalid email"
}

@Headers(schema) works the same way for request headers.

Schema coercion for query params

Query string values are always strings in Express. Use z.coerce.number() instead of z.number() for numeric query params — it converts "42" to 42 before passing to your handler.

Responses ​

BananaJS response types enforce a single JSON shape across every endpoint. That predictability is what lets frontend clients and API consumers behave reliably.

BaseController helpers ​

Extend BaseController to get this.ok() and this.error():

typescript
import { Controller, Get, BaseController } from '@banana-universe/bananajs'
import type { Request, Response } from 'express'

@Controller('products')
export class ProductController extends BaseController {

  @Get(':id')
  async getProduct(req: Request, res: Response) {
    const product = await fetchProduct(req.params.id)
    if (!product) {
      return this.error(res, new NotFoundError('Product not found'))
    }
    return this.ok(res, 'Product found', product)
    //   ↑ statusCode: "success", status: 200, message: ..., data: product
  }
}

this.ok(res, message, data) calls SuccessResponse under the hood.

Pagination ​

For paginated lists, use PaginatedResponse and PaginationQuerySchema:

typescript
import { PaginatedResponse, PaginationQuerySchema, Query } from '@banana-universe/bananajs'
import { z } from 'zod'

@Get('list')
@Query(PaginationQuerySchema)
async list(req: Request, res: Response) {
  const { page, limit } = req.query as z.infer<typeof PaginationQuerySchema>
  const [items, total] = await getItems(page, limit)

  const response = new PaginatedResponse(
    'Items fetched',
    items,
    { page, limit, total, totalPages: Math.ceil(total / limit) },
  )
  response.send(res)
}

Response shape includes a meta object: { page, limit, total, totalPages }.

Errors ​

BananaJS uses typed error classes so every error produces an expected, consistent HTTP response. The pattern: throw a specific error class; ErrorMiddleware catches it.

Throwing errors ​

typescript
import {
  NotFoundError,
  BadRequestError,
  UnauthorisedError,
  ForbiddenError,
  ConflictError,
} from '@banana-universe/bananajs'

// Inside any controller method or async handler:
throw new NotFoundError('User not found')         // → 404
throw new BadRequestError('Email already exists') // → 400
throw new UnauthorisedError()                      // → 401
throw new ForbiddenError('Admin only')             // → 403
throw new ConflictError('Duplicate entry')         // → 409

Wiring the error middleware ​

Add ErrorMiddleware after all routes. BananaJS does this for you when using BananaApp, but if you're using BananaRouter for incremental adoption, you must add it yourself:

typescript
import { ErrorMiddleware } from '@banana-universe/bananajs'

// After all routes:
app.use(ErrorMiddleware)

Error response shape ​

Every error follows the same envelope:

json
{
  "statusCode": "error",
  "status": 404,
  "message": "User not found"
}

In production (NODE_ENV=production), InternalError messages are masked to prevent leaking stack traces or sensitive internals.

Full error class list: Error types reference.

App bootstrap: new BananaApp vs BananaApp.create ​

MethodWhen to use
new BananaApp(options)Synchronous-only apps with no plugins needing async setup
await BananaApp.create(options)Whenever you use plugins (TypeORM, Mongoose, OTel, WebSocket)
typescript
// Simple sync bootstrap
new BananaApp({
  modules: [userModule],
})

// Async bootstrap with plugins
const app = await BananaApp.create({
  modules: [userModule],
  plugins: [TypeOrmPlugin({ ... })],
})

One options object, always

Whether you use new BananaApp or BananaApp.create, the argument is always one plain object. There is no second argument or method chaining for configuration.

How it all connects ​

Request → Express HTTP pipeline
       → Validation middleware (@Body / @Query / @Params / @Headers)
            ↓ invalid: BadRequestError → ErrorMiddleware → 400 JSON
            ↓ valid: req.body / req.query / req.params updated with parsed data
       → Your controller method
            ↓ business logic
       → this.ok(res, ...) OR throw ApiError subclass
            ↓ thrown: ErrorMiddleware → typed JSON response
            ↓ ok: SuccessResponse JSON

Next steps ​