Skip to content

resource and controller layout

github-actions[bot] edited this page Jun 1, 2026 · 2 revisions

Resource and Controller Layout

Convention for ordering declarations in resource files and controllers. Apply to every **/resources/*.ts and *.Controllers.ts file.

Naming

Request classes use the following name patterns. Pick the most specific one that fits.

Pattern Use Examples
List Sole list query in the resource. List
List* Additional list queries; suffix disambiguates. ListByCDC, ListLogin, ListOrders
Get Sole singular query (e.g. by-id). Non-nullable success schema. Missing row → typed NotFoundError (most cases) or Effect.die (only when input is not user-controllable). Get
Get* Additional singular queries; suffix names the read. Same non-nullable rule. GetById, GetCloseList, GetSettings, GetLabelPreview
Find / Find* Singular query that may return null / no result. Absence is part of the normal contract. Find, FindByGTIN, FindActiveCart

Get vs Find split is non-nullable success vs nullable success. How missing rows surface from a Get depends on who picked the input: typed NotFoundError when the user could plausibly have picked a stale/invalid key, Effect.die only when the input is not user-controllable (tenant enum, own dashboard's own workflow). See query-shape-list-vs-get.md for the full rule. | <Verb> | Commands. Verb first, alphabetical within commands. | ChangeBlocked, Close, RetryLabel, Update |

Do not name queries with bare nouns (Settings, Orders) or with Preview* prefixes. Use Get*/List*/Find* so the request kind is visible at the call site.

Resource file order

  1. Imports.
  2. // codegen:start ... // codegen:end header block (contains const Req = TaggedRequestFor(...)). Do not move.
  3. Request classes, in this order:
    1. List
    2. List* — alphabetical
    3. Get
    4. Get* — alphabetical
    5. Find
    6. Find* — alphabetical
    7. Commands — alphabetical
  4. Helper classes (S.Opaque, S.TaggedStruct, S.TaggedError, S.Class, plain S.Struct views, etc.) live immediately before the first request that references them. If a helper is shared by several requests, place it before the first user in the new order. Helpers not referenced by any request stay grouped near their domain.
  5. Comments above a class travel with that class.
  6. Trailing // codegen:start {preset: model} ... // codegen:end block and export namespace ... declarations stay at the bottom.

The class body is preserved byte-for-byte during a reorder. No reformatting.

Splitting queries from commands

Keep a single public resource module for ordinary consumers, but split query classes into a query-only sibling when command invalidation would otherwise create a circular import.

Pattern:

  • resources/Foo.Queries.ts contains only query request classes and their view schemas.
  • resources/Foo.ts re-exports Foo.Queries.ts, defines the same Req namespace, then defines commands.
  • Commands in other resources import query classes from Foo.Queries.ts for invalidation.
  • Frontend and controllers keep importing resources/Foo unless they only need a query class for resource-level invalidation.
  • Query-only split files must use the original resource module name in TaggedRequestFor(...). The shared codegen config strips .Queries from meta-generated module names; if a project does not have that shared default, configure stripSuffixes: [.Queries] at the plugin/config level or on the file. File-level stripSuffixes overrides plugin/config defaults.
// resources/PickCarts.Queries.ts
// codegen:start {preset: meta, sourcePrefix: src/EasyLife/}
const Req = TaggedRequestFor("Standard/PickCarts")
// codegen:end

export class List extends Req.Query<List>()("List", {}, {
  allowRoles: ["user"],
  success: S.Struct({ carts: S.Array(CartState) })
}) {}
// resources/PickCarts.ts
import { List as DropshippingPickList } from "../../Dropshipping/resources/PickList.Queries.ts"
import { GetStats } from "./PickCarts.Queries.ts"

export * from "./PickCarts.Queries.ts"

const Req = TaggedRequestFor("Standard/PickCarts")

export class Assign extends Req.Command<Assign>()(
  "Assign",
  { cartId: OneOrMoreCarts },
  { allowRoles: ["user"] },
  (queryKey) => [queryKey, GetMe, GetStats, DropshippingPickList]
) {}

Do not solve circular invalidation by configuring clientFor(Resource, () => …) in a page. Resource definitions are the source of truth for which query caches a mutation changes.

Controller file order

Inside return match({ ... }), handler keys follow the same order as the resource. Exactly one blank line between handler blocks; no blank line before the closing }).

Everything outside the match({...}) object (imports, layer deps, *effect setup, helper functions) is untouched.

When adding a new request

  1. Pick the name pattern from the table above.
  2. Place the class in the resource at the correct slot.
  3. Place any helper class immediately before its first user in that order.
  4. Add the matching handler in the controller at the same slot, with a blank line separator.
  5. Run pnpm check from the repo root.

Mechanical reorder of an existing file

For a refactoring pass:

  1. Identify each top-level export class block (request or helper) from export class to its terminating }. Comments directly above the class belong to that block.
  2. Classify each request as query (extends Req.Query<...>) or command (extends Req.Command<...>).
  3. Map each helper to its first-user request by name reference inside the request body.
  4. Emit blocks in the order above. Verify the count of extends Req.\(Query\|Command\) matches before and after.
  5. For controllers, reorder handler keys inside match({...}), insert single blank lines, verify handler count.

Clone this wiki locally