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:
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:
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:
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
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:
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:
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 entity —
TicketController,CustomerController - Keep handlers thin — delegate to services, don't inline business logic
- Use references, not IDs — pass
Persistent<T>orReference<T>to services, never raw strings - Leverage .types() — transform URL params into references so handlers get
Reference<Ticket>instead ofstring