Core Concepts
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.
Overview
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().
1import { getApp } from '@morojs/moro';
2const app = getApp();
3
4app.get('/health', (req, res) => {
5 res.json({ status: 'healthy', uptime: process.uptime() });
6});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.
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
/api/v1.0.0/users)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.
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.tsandschemas.ts - • Clear intent — reading the module definition immediately tells you the security posture
- • Independent versioning — public and admin APIs can evolve at different rates