Controllers
Transport-agnostic route definitions with dependency injection
Controllers group related routes together and inject services they need. They define what your API does, not how it's exposed (HTTP, gRPC, CLI, etc). The same controller works across all transports.
Creating a Controller
Use createController to define a controller with a path prefix, dependencies, and routes. A complete controller includes request/response schemas for type safety and OpenAPI generation:
import { createController } from '@justscale/core'
import { Delete, Get, Post, Put, body } from '@justscale/http/builder'
import {
CreateUserBody,
DeletedResponse,
UpdateUserBody,
UserResponse,
UsersListResponse,
} from '../schemas/user'
import { UserService } from '../services/user-service'
export const UsersController = createController('/users', {
inject: { users: UserService },
routes: ({ users }) => ({
// GET /users - List all users
list: Get('/')
.returns(UsersListResponse)
.handle(({ res }) => {
const allUsers = users.findAll()
res.json({ users: allUsers })
}),
// GET /users/:userId - Get single user
getOne: Get('/:userId')
.returns(UserResponse)
.returns(404)
.handle(({ params, res }) => {
const user = users.findById(params.userId)
if (!user) {
res.error('User not found', 404)
return
}
res.json({ user })
}),
// POST /users - Create user
create: Post('/')
.apply(body(CreateUserBody))
.returns(UserResponse, 201)
.handle(({ body, res }) => {
const user = users.create(body)
res.status(201).json({ user })
}),
// PUT /users/:userId - Update user
update: Put('/:userId')
.apply(body(UpdateUserBody))
.returns(UserResponse)
.returns(404)
.handle(({ params, body, res }) => {
const user = users.update(params.userId, body)
if (!user) {
res.error('User not found', 404)
return
}
res.json({ user })
}),
// DELETE /users/:userId - Delete user
remove: Delete('/:userId')
.returns(DeletedResponse)
.returns(404)
.handle(({ params, res }) => {
const deleted = users.delete(params.userId)
if (!deleted) {
res.error('User not found', 404)
return
}
res.json({ deleted })
}),
}),
})Key patterns shown above:
.returns(Schema)declares the 200 response type.returns(Schema, 201)declares success with specific status code.returns(404)declares status-only responses (no body)body(Schema)validates and types the request bodyres.error('message', 404)sends an error response with status
Path Prefixes
The first argument to createController is the path prefix. All routes in the controller are relative to this prefix:
import { createController } from '@justscale/core';
import { Get, Post, Put } from '@justscale/http';
import { PlayerService } from './player-service';
// Prefix: /api/players
export const PlayersController = createController('/api/players', {
inject: { players: PlayerService },
routes: (services) => ({
list: Get('/'), // -> GET /api/players
getOne: Get('/:id'), // -> GET /api/players/:id
create: Post('/'), // -> POST /api/players
update: Put('/:id'), // -> PUT /api/players/:id
}),
});Alternative: Object Form
For non-HTTP transports or advanced configuration, use the object form:
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { UserService } from './user-service';
export const UsersController = createController({
prefix: '/users',
inject: { users: UserService },
routes: (services) => ({
list: Get('/').handle(({ res }) => {
res.json({ users: services.users.findAll() });
}),
}),
});Injecting Services
List your dependencies in the inject object. They're automatically resolved and available via the services parameter in the routes function:
import { createController } from '@justscale/core';
import { Post } from '@justscale/http';
import { body } from '@justscale/http/builder';
import { PlayerService, GameService, LoggerService } from './services';
import { GameResponse, ErrorResponse, CreateGameBody } from './schemas';
export const GamesController = createController('/games', {
inject: {
players: PlayerService,
games: GameService,
logger: LoggerService,
},
routes: (services) => ({
// POST /games - Create a new game
create: Post('/')
.apply(body(CreateGameBody))
.returns(GameResponse, 201)
.returns(ErrorResponse, 400)
.handle(async ({ body, res }) => {
services.logger.info('Creating new game');
const activePlayers = await services.players.findActive();
if (activePlayers.length < body.minPlayers) {
return res.status(400).json({
error: `Need at least ${body.minPlayers} players`,
});
}
const game = await services.games.create(activePlayers);
res.status(201).json({ game });
}),
}),
});Handler Context
Route handlers receive a context object with transport-specific data:
- Transport context -
body,query,params,res(HTTP) - Middleware additions - Properties added by
.use()calls - Built-in context -
loggerwith controller name scoped
Services are accessed via the services parameter from the routes function closure, not in the handler context:
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { populate } from '@justscale/http';
import { PlayerService } from './player-service';
import { PlayerResponse } from './schemas';
export const PlayersController = createController('/players', {
inject: { players: PlayerService },
routes: (services) => ({
getOne: Get('/:playerId')
.use(populate(services.players, 'player', 'playerId'))
.returns(PlayerResponse)
.returns(404)
.handle(({
// From middleware
player, // Player entity (loaded by populate)
// Transport (HTTP)
params, // { playerId: string }
query, // Record<string, string>
res, // JsonResponse
// Built-in
logger, // Logger scoped to "PlayersController"
}) => {
logger.info(`Returning player ${player.id}`);
res.json({ player });
}),
}),
});Type Safety
Controllers are fully type-safe. TypeScript validates:
- All injected services are provided in your app
- Route params match the path pattern
- Middleware accumulates context correctly
- Response types match your schemas
import { createController, createClusterBuilder } from '@justscale/core';
import { Get } from '@justscale/http';
import { SomeService } from './some-service';
import { PlayersController } from './players-controller';
// TypeScript error: Dependency name conflicts with transport context
export const BadController = createController('/users', {
inject: {
res: SomeService, // Error! 'res' is reserved by HTTP transport
},
routes: () => ({ /* ... */ }),
});
// TypeScript error: Missing service
const app = createClusterBuilder()
.add(PlayersController) // Error! PlayerService not added
.build();Organizing Controllers
Group controllers by domain or resource. Each controller should handle a specific area of your application:
import { createClusterBuilder } from '@justscale/core';
import { PlayersController, GamesController } from './controllers';
import { PlayerService, GameService } from './services';
const app = createClusterBuilder()
.add(PlayerService)
.add(GameService)
.add(PlayersController)
.add(GamesController)
.build()
.compile();Transport Agnostic
Controllers don't know about HTTP, gRPC, or CLI. The transport layer adapts them to the specific protocol. This means you can switch transports or support multiple simultaneously:
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { UserService } from './user-service';
// Same controller works for HTTP and CLI
export const UsersController = createController('/users', {
inject: { users: UserService },
routes: (services) => ({
list: Get('/').handle(({ res }) => {
res.json({ users: services.users.findAll() });
}),
}),
});
// HTTP: GET /users
// CLI: users list (if CLI transport loaded)Best Practices
- One controller per resource - Keep controllers focused on a single domain entity
- Consistent naming - Use
ResourceControllerpattern - Logical prefixes - Use path prefixes that map to your resource (
/users,/games) - Inject what you need - Only inject services actually used in routes
- Keep handlers thin - Delegate business logic to services