Routes vs Modules

MoroJS offers two approaches for organizing your API: lightweight routes for app-level concerns and full modules for domain features. Here's when to use each.

Routes

App-level concerns like auth, health checks, and stats. Lightweight, no versioning.

  • Uses getApp() + app.group()
  • Auto-loaded via app.loadRoutes()
  • Minimal boilerplate
  • No lifecycle or versioning overhead

Modules

Domain features like users, blog, orders. Full defineModule() with versioning and middleware.

  • API versioning built-in
  • Module-level middleware
  • Lifecycle hooks (onLoad, onUnload)
  • Validation and type safety

Routes

Use routes/ for app-level concerns: health checks, authentication endpoints, stats, and other infrastructure routes. They're lightweight and use getApp() + app.group().

routes/health.ts

typescript

1import { getApp } from '@morojs/moro';
2const app = getApp();
3
4app.get('/health', (req, res) => {
5  res.json({ status: 'healthy', uptime: process.uptime() });
6});

routes/auth/index.ts

typescript

1import { getApp } from '@morojs/moro';
2import * as actions from './actions';
3import { LoginSchema } from './schemas';
4
5const app = getApp();
6
7app.group('/auth', (auth) => {
8  auth.post('/login').body(LoginSchema).handler(actions.login);
9  auth.post('/logout').handler(actions.logout);
10  auth.get('/me', requireAuth, actions.me);
11});

Modules

Use modules/ for domain features: users, blog, orders. Full defineModule() with API versioning, module-level middleware, and validation.

modules/users/index.ts

typescript

1import { defineModule } from '@morojs/moro';
2import * as actions from './actions';
3import { CreateUserSchema, UpdateUserSchema } from './schemas';
4
5export default defineModule({
6  name: 'users',
7  version: '1.0.0',
8  middleware: [requireAuth],
9  routes: [
10    { method: 'GET', path: '/users', handler: actions.listUsers },
11    { method: 'GET', path: '/users/:id', handler: actions.getUser },
12    { method: 'POST', path: '/users', body: CreateUserSchema, handler: actions.createUser },
13    { method: 'PUT', path: '/users/:id', body: UpdateUserSchema, handler: actions.updateUser },
14    { method: 'DELETE', path: '/users/:id', handler: actions.deleteUser },
15  ],
16});

When to Choose Modules

You need API versioning (/api/v1.0.0/users)
Routes share middleware (auth, rate limits)
You want lifecycle hooks (onLoad, onUnload)
Domain isolation matters for your architecture

Securing Modules by Boundary

Modules support module-level middleware that applies to all routes automatically. Split domains by security boundary — shared handlers and schemas stay together, while access control is enforced at the module level.

modules/blog/index.ts — Split by Security Boundary

typescript

1// Public routes — no auth required
2export const blogPublicModule = defineModule({
3  name: 'blog',
4  version: '1.0.0',
5  routes: [
6    { method: 'GET', path: '/posts', handler: actions.listPosts },
7    { method: 'GET', path: '/posts/:id', handler: actions.getPost },
8  ],
9});
10
11// Admin routes — auth + admin role required
12export const blogAdminModule = defineModule({
13  name: 'blog-admin',
14  version: '1.0.0',
15  middleware: [requireAuth, requireAdmin],
16  routes: [
17    { method: 'POST', path: '/posts', handler: actions.createPost },
18    { method: 'PUT', path: '/posts/:id', handler: actions.updatePost },
19    { method: 'DELETE', path: '/posts/:id', handler: actions.deletePost },
20  ],
21});

Why Split by Boundary?

  • Security by default — middleware applies to every route in the module, so you can't accidentally expose an admin endpoint
  • Shared code — both modules import from the same actions.ts and schemas.ts
  • Clear intent — reading the module definition immediately tells you the security posture
  • Independent versioning — public and admin APIs can evolve at different rates