Skip to content

BananaJSAI-first, DDD-ready Node.js framework for high-velocity teams

DX · Decorators · Express · Plugins · AI-native CLI

BananaJS — Express, plugins, CLI, AI

Exceptional DX on Express — structure and automation for teams that want clear APIs and a path to domain-driven design, with a CLI that fits how modern teams work — without dragging in a heavyweight runtime.

Why BananaJS

A deliberate stack. Not a thin wrapper.

Not a thin wrapper around Express — a deliberate stack for teams who want great DX, structure, automation, and maintainable domains without adopting a monolithic platform. Invest in tooling so you spend time on product behavior, not repetitive files. Plugins let capabilities grow without bloating the core.

If you've shipped on Express and want NestJS-style structure without leaving the runtime, you're the target user.

Philosophy · Layered architecture · Recipes

End-to-end walkthrough

Building a Jira-style issue tracker

Six phases — LLM setup to a reviewed, tested, production-ready module. Every command is real and runs today.

01bjs ai setup
Connect your LLM. One time.

Run once at project root. The wizard writes .bananarc.json — every subsequent bjs ai command reads it automatically. No flags to repeat, no env vars to juggle between sessions.

  • OpenAI, Anthropic, or Gemini — your choice
  • Default ORM and output directory stored
  • API key auto-added to .gitignore — never commits
AI commands →
bash
$ bjs ai setup           # one-time per project

  ? LLM provider  Anthropic
  ? Model  claude-3-5-sonnet-20241022
  ? API key  sk-ant-••••••••••••••••••••

  Written .bananarc.json
  Added to .gitignore

  All bjs ai commands now read this config automatically.
  Run  bjs ai generate  to scaffold your first module.
json
{
  "llm": {
    "provider": "anthropic",
    "model": "claude-3-5-sonnet-20241022",
    "apiKey": "sk-ant-••••••••••••••••••••"
  },
  "generate": {
    "outDir": "src",
    "orm": "mongoose"
  }
}
02bjs ai generate --module
Three ways to scaffold. Same layered output.

Plain English → --module. Existing spec → --from-schema. Preview without writing → --dry-run. All three produce the same domain/ · application/ · infrastructure/ layout.

  • --module: describe in plain English (most flexible)
  • --from-schema: deterministic from JSON Schema / OpenAPI
  • --dry-run: review file list before anything is written
AI codegen docs →
bash
$ bjs ai generate --module \
    "Issues: title, description, status (open/in-progress/done),
     priority (low/medium/high/critical), assignee, project" \
    --orm mongoose

  Analyzing requirements...
  Planning module...
  Writing 8 files...

  src/modules/issue/
    domain/
      Issue.entity.ts entity + value types
      Issue.repository.ts port interface + token
    application/
      Issue.service.ts application service
    infrastructure/
      Issue.mongoose-model.ts Mongoose schema
      Issue.mongoose-repository.ts adapter (implements port)
    Issue.dto.ts Zod validation schemas
    Issue.controller.ts @Controller + routes
    index.ts createModule() DI wiring

  issueModule registered in bootstrap.ts
bash
# Generate from an existing JSON Schema or OpenAPI file
$ bjs ai generate --from-schema ./docs/issue-openapi.json

  Loading JSON Schema...
  Extracting shapes from 3 schemas...
  Writing flat files...

  src/
    IssueController.ts GET / POST / PUT / DELETE routes
    issue.dto.ts Zod schemas derived from the spec
    IssueService.ts application service stub

  Tip: add --module to produce a DDD-layered module instead
       of a flat controller + service output.
bash
# Preview which files would be created — nothing touches disk
$ bjs ai generate --module "Issues tracker" \
    --orm mongoose --dry-run

  Planning module...
  [dry-run] would write:
    src/modules/issue/domain/Issue.entity.ts
    src/modules/issue/domain/Issue.repository.ts
    src/modules/issue/application/Issue.service.ts
    src/modules/issue/infrastructure/Issue.mongoose-model.ts
    src/modules/issue/infrastructure/Issue.mongoose-repository.ts
    src/modules/issue/Issue.dto.ts
    src/modules/issue/Issue.controller.ts
    src/modules/issue/index.ts

  Nothing written. Remove --dry-run to generate.
03Controller · DTOs · Validation
Routes declared. Bad input rejected before your code runs.

Zod schemas in Issue.dto.ts are the single source of shape rules. Attach one as a decorator and invalid payloads return a structured 400 automatically — your handler never sees bad data.

  • DTOs: Zod schemas, fully type-inferred — zero duplication
  • @Body() / @Params(): automatic 400 if schema fails
  • Controller: HTTP concerns only, zero business logic
Validation docs →
typescript
// Issue.dto.ts — generated schemas (edit freely)
import { z } from 'zod'

export const CreateIssueSchema = z.object({
  title:      z.string().min(1).max(200),
  projectId:  z.string().min(1),
  priority:   z.enum(['low', 'medium', 'high', 'critical']),
  assigneeId: z.string().optional(),
})

export const UpdateIssueSchema = CreateIssueSchema
  .partial()
  .extend({ status: z.enum(['open', 'in-progress', 'done']).optional() })

// Types are fully inferred — no separate interface needed
export type CreateIssueDto = z.infer<typeof CreateIssueSchema>
export type UpdateIssueDto = z.infer<typeof UpdateIssueSchema>
typescript
// Issue.controller.ts — HTTP layer only
@injectable()
@Controller('issues')
export class IssueController extends BaseController {
  constructor(
    @inject(IssueAppService)
    private readonly app: IssueAppService,
  ) { super() }

  @Post('')
  @Body(CreateIssueSchema)            // 400 on schema failure — automatic
  async create(req: Request, res: Response) {
    return this.ok(res, 'created', await this.app.create(req.body))
  }

  @Put(':id')
  @Params(z.object({ id: z.string().min(1) }))
  @Body(UpdateIssueSchema)
  async update(req: Request, res: Response) {
    return this.ok(res, 'ok', await this.app.update(req.params.id, req.body))
  }

  @Get('')
  @Query(z.object({ projectId: z.string().min(1) }))
  async list(req: Request, res: Response) {
    return this.ok(res, 'ok', await this.app.findByProject(req.query.projectId as string))
  }
}
bash
# POST /issues  { "title": "" }
# HTTP 400 Bad Request — handler was never called

{
  "status": "error",
  "message": "Validation failed",
  "errors": [
    {
      "field": "title",
      "message": "String must contain at least 1 character(s)"
    },
    {
      "field": "projectId",
      "message": "Required"
    },
    {
      "field": "priority",
      "message": "Invalid enum value. Expected low | medium | high | critical"
    }
  ]
}
04Service · Repository · MongoDB
Logic isolated. Persistence swappable.

The application service holds all business rules, injected by tsyringe. It depends only on the repository port interface — Mongoose never leaks into logic. The adapter in infrastructure/ can be swapped without touching a single rule.

  • App service: @injectable(), constructor-injected port
  • Domain port: pure TS interface + Symbol token in domain/
  • Mongoose adapter: implements port, isolated in infrastructure/
DI guide →
typescript
// application/Issue.service.ts
@injectable()
export class IssueAppService {
  constructor(
    @inject(IssueRepositoryToken)
    private readonly repo: IssueRepository,   // port, not Mongoose
  ) {}

  async create(dto: CreateIssueDto): Promise<Issue> {
    const issue = this.repo.create({ ...dto, status: 'open' })
    return this.repo.save(issue)
  }

  async update(id: string, dto: UpdateIssueDto): Promise<Issue> {
    const issue = await this.repo.findById(id)
    if (!issue) throw new NotFoundError('Issue', id)
    issue.update(dto)             // domain rule lives in the entity
    return this.repo.save(issue)
  }

  async findByProject(projectId: string): Promise<Issue[]> {
    return this.repo.findByProject(projectId)
  }
}
typescript
// domain/Issue.repository.ts — ORM-agnostic interface (port)
export interface IssueRepository {
  save(issue: Issue): Promise<Issue>
  findById(id: string): Promise<Issue | null>
  findByProject(projectId: string): Promise<Issue[]>
}

// Symbol token — used by tsyringe for DI
export const IssueRepositoryToken = Symbol('IssueRepository')

// Wired once in index.ts → createModule({
//   providers: [
//     { token: IssueRepositoryToken,
//       useClass: IssueMongooseRepository },  ← swap freely
//   ]
// })
// The service never changes when you swap adapters.
typescript
// infrastructure/Issue.mongoose-repository.ts
@injectable()
export class IssueMongooseRepository implements IssueRepository {
  async save(issue: Issue): Promise<Issue> {
    await IssueModel.findOneAndUpdate(
      { _id: issue.id },
      toDocument(issue),
      { upsert: true },
    )
    return issue
  }

  async findById(id: string): Promise<Issue | null> {
    const doc = await IssueModel.findById(id).lean()
    return doc ? toDomain(doc) : null
  }

  async findByProject(projectId: string): Promise<Issue[]> {
    const docs = await IssueModel
      .find({ projectId })
      .sort({ createdAt: -1 })
      .lean()
    return docs.map(toDomain)
  }
}
typescript
// src/modules/issue/index.ts — generated DI wiring
import { createModule } from '@banana-universe/bananajs'
import { IssueController } from './Issue.controller.js'
import { IssueAppService } from './application/Issue.service.js'
import { IssueRepositoryToken } from './domain/Issue.repository.js'
import { IssueMongooseRepository } from './infrastructure/Issue.mongoose-repository.js'

export const issueModule = createModule({
  id: 'issue',
  controller: IssueController,
  providers: [
    IssueAppService,
    {
      token: IssueRepositoryToken,
      useClass: IssueMongooseRepository,   // ← swap for any adapter
    },
  ],
})

// Register in bootstrap.ts:
import { BananaApp } from '@banana-universe/bananajs'
import { mongoosePlugin } from '@banana-universe/plugin-mongoose'

const app = await BananaApp.create({
  plugins: [mongoosePlugin({ uri: process.env.MONGO_URI! })],
  modules: [issueModule],   // ← add more modules here
})
app.listen(3000)
05bjs ai doc · openapi enrich
Docs generated. Spec enriched. Zero drift.

bjs ai doc reads your controller and writes JSDoc for every route. bjs ai openapi enrich reads the exported spec and adds descriptions, request examples, and error schemas derived from the same live decorators — the contract never drifts.

  • bjs ai doc: JSDoc on every route in one pass
  • openapi enrich: descriptions + examples + 40x schemas
  • Spec always matches running code — no YAML to hand-write
OpenAPI docs →
bash
$ bjs ai doc --file src/modules/issue/Issue.controller.ts

  Reading Issue.controller.ts...
  Generating JSDoc for 3 routes...
  Written 3 descriptions added

# Generated JSDoc (sample):
  /**
   * Create a new issue in the specified project.
   * @route   POST /issues
   * @body    {CreateIssueDto} Issue creation payload
   * @returns {Issue}            201 - Created issue object
   * @returns {ValidationError}  400 - Schema validation failed
   */
  async create(req: Request, res: Response) { ... }
bash
$ bjs ai openapi enrich

  Loading openapi.json (12 endpoints)...
  Enriching /issues endpoints (3 routes)...
  Added summaries and descriptions to 3 routes
  Added request body examples to 2 routes
  Added 400, 404, 422 response schemas to 3 routes
  Written docs/openapi.json

# Before:  "summary": ""
# After:   "summary": "Create a new issue",
#          "description": "Creates an issue in the given project.",
#          "requestBody": {
#            "example": { "title": "Fix login bug", "priority": "high" }
#          }
06bjs ai review · test · perf
Review it. Test it. Ship it.

Three AI checks before a PR. bjs ai review catches missing guards with file+line references. bjs ai test scaffolds a Supertest file matched to your actual routes. bjs ai perf scans for N+1 patterns and missing indexes.

  • review: security, logic, patterns — with file + line refs
  • test: Supertest scaffold matched to your actual routes
  • perf: query efficiency + missing DB index coverage
All AI commands →
bash
$ bjs ai review --module src/modules/issue

  Scanning 5 files in src/modules/issue...

  Issue.controller.ts:8   No @Auth() guard all routes are public.
                             Add @Auth() at the controller class level.
  Issue.controller.ts:32  GET /issues has no pagination add
                             limit/offset or cursor query params.
  Issue.entity.ts         Domain model clean, no ORM leakage
  Issue.repository.ts     Port correctly typed and isolated
  index.ts                DI wiring matches repository token

  2 warnings · 0 errors · 3 passing
typescript
// bjs ai test → writes src/__tests__/issue.test.ts
import { BananaTestApp } from '@banana-universe/bananajs/testing'
import { issueModule } from '../modules/issue/index.js'
import assert from 'node:assert/strict'
import { describe, it, before, after } from 'node:test'
import request from 'supertest'

describe('IssueController', () => {
  let app: BananaTestApp

  before(async () => {
    app = await BananaTestApp.create({ modules: [issueModule] })
  })
  after(() => app.close())

  it('POST /issues returns 201', async () => {
    const res = await request(app.server)
      .post('/issues')
      .send({ title: 'Fix login bug', projectId: 'p1', priority: 'high' })
    assert.equal(res.status, 201)
  })

  it('POST /issues returns 400 on bad payload', async () => {
    const res = await request(app.server)
      .post('/issues').send({ title: '' })
    assert.equal(res.status, 400)
  })
})
bash
$ bjs ai perf --file src/modules/issue/Issue.service.ts

  Analyzing Issue.service.ts...

  findByProject (line 24): result set unbounded
 add .limit() or pagination to the Mongoose query
  Issue.mongoose-model.ts: no index for { projectId }
 add: IssueSchema.index({ projectId: 1, createdAt: -1 })

  No synchronous blocking operations detected
  No N+1 query patterns found in service methods

  2 warnings · 0 errors
Built-in decorators

Framework capabilities

Every decorator below ships in @banana-universe/bananajs — no extra packages, no configuration files.

Auth · Roles · Public

Plug in any guard via AuthGuard. Lock a whole controller with one decorator, open individual routes with @Public(), and restrict by role string.

  • One @Auth() on the class secures all routes
  • @Public() carves out opt-out exceptions
  • @Roles('admin') adds role checks on top of auth
Auth docs →
typescript
@Auth()                         // all routes require auth
@Controller('issues')
export class IssueController extends BaseController {

  @Get('')                      // inherits @Auth()
  async list(req, res) { ... }

  @Public()                     // opt-out for this route only
  @Get('health')
  async health(_req, res) {
    return this.ok(res, 'ok', 'up')
  }

  @Roles('admin', 'manager')    // role check on top of auth
  @Delete(':id')
  async remove(req, res) { ... }
}
ABAC — attribute-based access

Define policies once in your AuthGuard. Annotate each route with the action and resource it represents — no policy logic scattered across controllers.

  • Policy check at route level: @Can('create', 'issue')
  • Guard receives action + resource + user context
  • Composes cleanly with @Auth() and @Roles()
ABAC docs →
typescript
@Auth()
@Controller('issues')
export class IssueController extends BaseController {

  @Can('create', 'issue')
  @Post('')
  @Body(CreateIssueSchema)
  async create(req, res) { ... }

  @Can('delete', 'issue')
  @Delete(':id')
  async remove(req, res) { ... }

  @Can('read', 'issue')
  @Get('')
  async list(req, res) { ... }
}
Multi-tenancy

Per-request tenant isolation via AsyncLocalStorage. Tenant ID is resolved from an x-tenant-id header or a JWT tid claim — available everywhere in the call stack without prop-drilling.

  • @Tenant() on the class or a single method
  • getTenantId() in services and repositories — no args
  • Configurable: header name and JWT claim are overridable
Multi-tenancy docs →
typescript
@Tenant()
@Auth()
@Controller('issues')
export class IssueController extends BaseController {

  @Get('')
  async list(_req, res) {
    const tenantId = getTenantId()
    return this.ok(res, 'ok',
      await this.app.findByProject(tenantId!))
  }
}

// In your service or repository — no extra arguments:
import { getTenantId } from '@banana-universe/bananajs'
const tenantId = getTenantId()   // same ALS store
Caching

In-memory response cache with TTL and pattern-based eviction. Swap to Redis via the CacheStore interface. Keys auto-namespace per tenant when @Tenant() is present.

  • @Cache({ ttl: 30 }) caches the full response
  • @CacheEvict({ pattern: 'list:*' }) busts on write
  • Custom key function: key: (req) => ...
Caching docs →
typescript
@Cache({ ttl: 30 })
@Get('')
async list(_req, res) {
  return this.ok(res, 'ok', await this.app.findByProject(...))
}

@CacheEvict({ pattern: 'list:*' })
@Post('')
@Body(CreateIssueSchema)
async create(req, res) {
  return this.ok(res, 'created', await this.app.create(req.body))
}

// Tenant-scoped key:
@Cache({ ttl: 60, key: (req) => `issues:${getTenantId()}` })
@Get('')
async listForTenant(req, res) { ... }
Throttle · Rate limit

Sliding-window throttle per user or IP. Fixed-window rate limit at class or method level. Plug in a Redis ThrottleStore for distributed deployments.

  • @Throttle — sliding window, keyed by userId or IP
  • @RateLimit — simpler fixed window, class or method
  • Redis store interface for distributed environments
Throttle docs →
typescript
// Per-user sliding window
@Throttle({ windowMs: 10_000, max: 5, keyBy: 'userId' })
@Post('')
@Body(CreateIssueSchema)
async create(req, res) { ... }

// Controller-wide fixed window
@RateLimit({ windowMs: 60_000, max: 100 })
@Auth()
@Controller('issues')
export class IssueController extends BaseController { ... }

// Distributed: Redis store
@Throttle({ windowMs: 60_000, max: 20,
            keyBy: 'userId', store: redisThrottleStore })
@Post('comments')
async addComment(req, res) { ... }
Input sanitization

Strip HTML from all body string fields before the handler runs. Uses sanitize-html as an optional peer dep — configure allowed tags per route for rich-text fields.

  • @Sanitize() — strips all HTML, safe default
  • Configurable allowlist: tags and attributes per route
  • Runs before Zod validation — body arrives clean
Sanitize docs →
typescript
// Strip all HTML
@Sanitize()
@Post('')
@Body(CreateIssueSchema)
async create(req, res) {
  return this.ok(res, 'created', await this.app.create(req.body))
}

// Allow a safe subset for rich-text fields
@Sanitize({
  allowedTags: ['b', 'i', 'em', 'strong', 'a'],
  allowedAttributes: { a: ['href'] },
})
@Put(':id')
@Body(UpdateIssueSchema)
async update(req, res) { ... }