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:

Files
src/controllers/users.tsTypeScript
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 body
  • res.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:

players-controller.tsTypeScript
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:

users-controller.tsTypeScript
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:

games-controller.tsTypeScript
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 - logger with controller name scoped

Services are accessed via the services parameter from the routes function closure, not in the handler context:

players-controller.tsTypeScript
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
bad-controller.tsTypeScript
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:

Files
app.tsTypeScript
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:

users-controller.tsTypeScript
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 ResourceController pattern
  • 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