<!-- Markdown mirror of https://justscale.sh/docs/fundamentals/controllers -->

# 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

```typescript
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

```typescript
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

```typescript
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` 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

```typescript
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

```typescript
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 or Reference to services, never raw strings
- Leverage .types() — transform URL params into references so handlers get Reference instead of string

## Next Steps

- Routes
- Middleware
- Guards
