Skip to main content
These are conventions, not compiler rules. Ship doesn’t force them, but every resource in the template and every plugin follows them — and that consistency is what lets you (and your agents) add code without first reverse-engineering the last person’s choices. Standardised patterns. Zero guesswork.

A resource owns its data access

Rule

Every read or write to a table goes through that table’s resource. Direct db.<table> calls belong in the resource’s endpoints, methods and handlers — not in unrelated resources.

Why

Keeping access inside the resource keeps every update discoverable in one folder. When the table changes, the blast radius is one directory. The users resource reads and writes db.users; nothing outside resources/users/ reaches into that table.
// resources/users/endpoints/list.ts
return db.users.findPage({ where: { deletedAt: null }, orderBy: sort, page, perPage });

Complex reads live in methods

Rule

Aggregations, multi-step queries and any non-trivial read live in the resource’s methods/ folder — not inlined across endpoints.

Why

Complex queries are the code most coupled to the schema, so they need maintenance when the data shape changes. Keeping them in resources/<name>/methods/ puts every such query in one predictable place, easy to find and update when <name>.schema.ts moves.

Put things where they’re used

Rule

Keep code as close as possible to where it runs. An endpoint’s schema, its ownership gate and its handler all live within the same resource folder.

Why

Things that change together should sit together; things that don’t should stay apart. That’s what gives each resource clear boundaries — resources/users/ contains its schema (users.schema.ts), endpoints (endpoints/), gates (middlewares/) and side effects (handlers/), and nothing it doesn’t need.

Resources talk through events, not each other

Rule

One resource shouldn’t reach into another’s data service. Compose them in an endpoint, or — better — react to the other resource’s mutation events.

Why

Calling one service from inside another creates circular dependencies and quietly couples two boundaries. Every write already publishes a typed MutationEvent ({ type, docs, prevDocs }); drop a handler in resources/<name>/handlers/ to react to another resource’s changes without importing it:
// resources/users/handlers/on-user-deleted.ts — react, don't reach in
export default async function onUserDeleted({ docs }: MutationEvent<User>) {
  // clean up things that belong to this user
}

Import from the root with @/

Rule

Import from the @/ alias (the API src root) — never with ../ chains. So:
import db from '@/db';
import endpoint from '@/endpoint';
import isAdmin from '@/middlewares/is-admin';
import { publicSchema } from '@/resources/users/users.schema';
not import service from '../../users/users.schema'.

Why

Absolute @/ imports survive moving files around and make it obvious where something actually lives. Note also that resource folders are plural (resources/users/, resources/notes/), and a resource’s schema is its <name>.schema.ts. Relative-only imports from the same folder are fine; reaching up with ../ is what we avoid.

See also

  • API action — the endpoint builder these conventions shape.
  • Validation — schemas and gates per resource.
  • How Ship works — the resource-owns-everything model in full.