Skip to content

Controllers

Group routes with shared dependencies and path prefixes

Controllers group related routes under a path prefix and share injected dependencies. They're the glue between your services and the transport layer (HTTP, CLI, WebSocket, SSE).

Creating a Controller

Use createController with a path prefix, injected services, and a routes function. The routes function receives resolved dependencies and returns route definitions:

src/controllers/ticket-controller.tsTypeScript
import { createController } from '@justscale/core';
import { ModelRepository } from '@justscale/core/models';
import { Get, Post } from '@justscale/http';
import { SSE } from '@justscale/sse';
import { auth } from '@justscale/auth';
import { Ticket } from '../domain/models';
import { HelpdeskService } from '../domain/helpdesk-service';

export const TicketController = createController('/tickets', {
  inject: {
    helpdesk: HelpdeskService,
    ticketsRepo: ModelRepository.of(Ticket),  // needed to acquire the lock
  },

  routes: ({ helpdesk, ticketsRepo }) => ({
    // GET /tickets — list all
    list: Get('/')
      .use(auth)
      .handle(async ({ res }) => {
        res.json(await helpdesk.listAll());
      }),

    // GET /tickets/:ticket — view single ticket
    get: Get('/:ticket')
      .types({ Ticket })
      .use(auth)
      .guard(Ticket.can.view)
      .handle(async ({ params, res }) => {
        const ticket = await params.ticket;
        if (!ticket) return res.status(404).json({ error: 'Not found' });
        res.json(ticket);
      }),

    // POST /tickets/:ticket/resolve — caller locks, passes proof to the service
    resolve: Post('/:ticket/resolve')
      .types({ Ticket })
      .use(auth)
      .guard(Ticket.can.resolve)
      .handle(async ({ params, res, user }) => {
        using locked = await ticketsRepo.lock(params.ticket);
        if (!locked) return res.status(404).json({ error: 'Not found' });
        await helpdesk.resolveTicket(locked, user.name);
        res.status(204).end();
      }),

    // SSE /tickets/:ticket/events — real-time stream
    events: SSE('/:ticket/events')
      .types({ Ticket })
      .handle(async function* ({ params }) {
        // params.ticket is Ref<Ticket>
        yield { event: 'connected', data: { ticket: Ticket.ref(params.ticket).identifier } };
      }),
  }),
});

Path Prefix

The first argument to createController is the path prefix. All routes are relative to it:

prefix-example.tsTypeScript
const TicketController = createController('/tickets', {
  routes: () => ({
    list:    Get('/'),                   // GET  /tickets
    get:     Get('/:ticket'),            // GET  /tickets/:ticket
    resolve: Post('/:ticket/resolve'),   // POST /tickets/:ticket/resolve
    events:  SSE('/:ticket/events'),     // SSE  /tickets/:ticket/events
  }),
});

Dependency Injection

The inject object declares what the controller needs. Dependencies are resolved from the container and passed to the routes function as a closure:

ticket-controller.tsTypeScript
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { ModelRepository } from '@justscale/core/models';
import { Customer, Agent, Ticket } from '../domain/models';
import { HelpdeskService } from '../application/helpdesk-service';

export const TicketController = createController('/tickets', {
  inject: {
    helpdesk: HelpdeskService,
    customers: ModelRepository.of(Customer),
    agents: ModelRepository.of(Agent),
  },

  // Destructured services are available to all route handlers
  routes: ({ helpdesk, customers, agents }) => ({
    list: Get('/').handle(async ({ res, user }) => {
      // Use the closure — no need to inject per-route
      const customer = await customers.findOne(
        Customer.fields.email.eq(user.email),
      );
      if (customer) {
        res.json(await helpdesk.listForCustomer(Customer.ref(customer)));
      } else {
        res.json(await helpdesk.listAll());
      }
    }),
  }),
});
ℹ️

Info

Notice there are no string IDs anywhere. The customer is found by email, then passed as a reference — Customer.ref(customer). A Persistent<Customer> is itself a valid reference. This is how JustScale keeps IDs out of domain code.

Transport Agnostic

Controllers support multiple transports in the same definition. Import route factories from the transport package you need:

multi-transport.tsTypeScript
import { createController } from '@justscale/core';
import { Get, Post } from '@justscale/http';   // HTTP routes
import { SSE } from '@justscale/sse';          // Server-Sent Events
import { Cli } from '@justscale/core/cli';     // CLI commands

export const TicketController = createController('/tickets', {
  inject: { helpdesk: HelpdeskService },

  routes: ({ helpdesk }) => ({
    // HTTP — browser and API clients
    list: Get('/').handle(async ({ res }) => {
      res.json(await helpdesk.listAll());
    }),

    // SSE — real-time event stream in browser
    events: SSE('/events').handle(async function* () {
      yield { event: 'connected', data: {} };
    }),

    // CLI — `just tickets stats` in terminal
    stats: Cli('stats').handle(async ({ output }) => {
      const all = await helpdesk.listAll();
      output.log(`Total tickets: ${all.length}`);
    }),
  }),
});

Registering Controllers

Add controllers to your app builder. Dependencies are validated at compile time:

app.tsTypeScript
import JustScale from '@justscale/core';
import { TicketController } from './controllers/ticket-controller';
import { TicketRepository, CommentRepository } from './infrastructure/pg';
import { HelpdeskService } from './application/helpdesk-service';

const app = JustScale()
  .add(TicketRepository)
  .add(CommentRepository)
  .add(HelpdeskService)
  .add(TicketController)  // TypeScript verifies all deps are satisfied
  .build();

Best Practices

  • One controller per domain entityTicketController, CustomerController
  • Keep handlers thin — delegate to services, don't inline business logic
  • Use references, not IDs — pass Persistent<T> or Reference<T> to services, never raw strings
  • Leverage .types() — transform URL params into references so handlers get Reference<Ticket> instead of string