Overview
In a standard MVC (Model, View, Controller) paradigm, a controller represents the entity which responds to an HTTP request. Controllers in Psychic are classes which inherit from the base PsychicController class, which can be imported from Psychic. They will inherit many useful methods for responding to requests, as well as many useful helpers for setting and reading cookies, accessing request parameters, and much more.
In order for a controller's methods to be reached, a corresponding route entry must be established to point to those methods, so the controllers and routes work hand-in-hand. You should never have a route that points to a controller method that doesn't exist, nor should you ever have a controller method with no corresponding route entry.
Authentication architecture
The controller directory structure is the authentication and authorization architecture. Each directory branch has its own AuthedController (and optionally UnauthedController, MaybeAuthedController) at its root. Every controller in that branch extends downward from it — looking at the @BeforeAction methods in any directory's base controller tells you exactly what auth rules are in force for the entire subtree.
Cardinal rule: authentication flows downhill — it gets stricter, never weaker. Never introduce a looser authentication pattern deeper in a directory hierarchy.
Each application surface (client-facing, admin, internal) gets its own directory branch with its own auth base controllers:
controllers/
├── ApplicationController.ts (base — universal methods, openapi namespaces)
├── AuthedController.ts (client auth — @BeforeAction, 401 if no user)
├── MaybeAuthedController.ts (client auth — currentUser null if absent)
├── UnauthedController.ts (no auth)
│
├── V1/
│ ├── SignInController.ts (extends UnauthedController)
│ ├── Visitor/ (public browse with optional auth)
│ │ ├── BaseController.ts (extends MaybeAuthedController)
│ │ └── PlacesController.ts (extends Visitor/BaseController)
│ ├── Guest/ (authed Guest endpoints)
│ │ ├── BaseController.ts (extends AuthedController, loads currentGuest)
│ │ └── PlacesController.ts (extends Guest/BaseController)
│ └── Host/ (authed Host endpoints)
│ ├── BaseController.ts (extends AuthedController, loads currentHost)
│ ├── PlacesController.ts (extends Host/BaseController)
│ └── Places/
│ ├── BaseController.ts (extends Host/BaseController, loads currentPlace)
│ └── RoomsController.ts (extends Host/Places/BaseController)
│
├── Admin/ (admin surface — separate auth chain)
│ ├── AuthedController.ts (admin auth — validates admin user)
│ ├── UnauthedController.ts
│ └── UsersController.ts (extends Admin/AuthedController)
Key principles:
Admin/AuthedControllerdoes NOT extend the clientAuthedController. Each surface authenticates its own user type independently.- Every controller extends the base controller in its own directory (or the authed/unauthed controller at the top of its branch). Controllers never reach across branches or skip levels.
- Use generators to create controllers.
pnpm psy g:resourceandpnpm psy g:controllerset up the correct inheritance chain automatically.
Routing when directory names shouldn't appear in URLs
Controller directory names reflect the auth architecture, not URL structure. When the directory name shouldn't appear in the URL (e.g. Visitor/ is the auth context, but /v1/visitor/places isn't a natural public URL), use an explicit controller reference:
// GOOD — clean URL: /v1/places, controller in Visitor/ directory for auth
import V1VisitorPlacesController from '@controllers/V1/Visitor/PlacesController.js'
r.resources('places', { only: ['index', 'show'], controller: V1VisitorPlacesController })
Routes and Controllers
Connecting routes to your controllers
// conf/routes.ts
export default (r: PsychicRouter) => {
r.get('helloworld', WelcomeController, 'helloWorld')
}
// controllers/WelcomeController.ts
export default class WelcomeController extends ApplicationController {
public async helloWorld() {
this.ok('howyadoin')
}
}
Ordinarily, controllers will be driven by resourceful patterns tied to underlying models. In these cases, we highly recommend you see the generating resources guides, since this will automatically compose sensible default functionality and testing for your controller, as well as the underlying model.
pnpm psy g:resource --owning-model=Host v1/host/places Place name:citext style:enum:place_styles:cottage,cabin,lean_to,treehouse,tent,cave,dump sleeps:integer deleted_at:datetime:optional
This will also produce route entries for the new PlacesController, using the r.resources('places') call, which will automatically provide sensible routes for indexing, showing, creating, updating, and deleting a place.
Status code responses
Psychic leverages method names which correspond to HTTP status codes to make rendering data a little more human for us:
export default class V1HostPlacesController extends V1HostBaseController {
public async create() {
let place = await this.currentHost.createAssociation(
'places',
this.extractParams(Place, ['name', 'description', 'style', 'sleeps'])
)
if (place.isPersisted) place = await place.loadFor('default').execute()
this.created(place)
}
public async update() {
const place = await this.place()
await place.update(this.extractParams(Place, ['name', 'description', 'style', 'sleeps']))
this.noContent()
}
}
Learn more about how status codes work in Psychic controllers by visiting the status code guides.
OpenAPI
Psychic Controllers contains powerful bindings to OpenAPI through the usage of the @OpenAPI decorator. This enables you to automatically generate OpenAPI documents based on the shape of your models. The below example will auto-generate an OpenAPI document with an array of serialized Place models as the response body for this endpoint:
import { OpenAPI } from '@rvoh/psychic'
import Place from '../../../models/Place.js'
import V1HostBaseController from './BaseController.js'
const openApiTags = ['places']
export default class V1HostPlacesController extends V1HostBaseController {
@OpenAPI(Place, {
status: 200,
tags: openApiTags,
description: 'Fetch multiple Places',
many: true,
serializerKey: 'summary',
})
public async index() {
const places = await this.currentHost.associationQuery('places').all()
this.ok(places)
}
}
The OpenAPI decorator is incredibly robust in terms of capabilities, and we recommend diving deep on how to leverage it to empower yourself to write less. see the Openapi guides to learn more.