Architecture and Error Contracts
Extend Sushi SaaS without bypassing its data, authorization, or user-safe error boundaries.
Verified against starter commit
2a1a04a.
Use this page before adding your first product-specific feature. The goal is practical: know where each file belongs, which boundary protects customer data, and which tests will tell you that an extension is safe.
Decisions Already Made for You
Sushi SaaS chooses one horizontal architecture, direct service calls from Server Components, typed API wrappers for Client Components, capability-based plan checks, and translated public errors. Keeping these defaults makes new domains predictable. You can replace a convention, but treat that as an architecture migration: update the enforcement tests and engineering docs at the same time so the codebase never has two competing patterns.
One Direction Through the Server
src/app/** routes and pages: HTTP in, HTTP out
↓
src/services/** business rules, orchestration, invariants
↓
src/models/** typed persistence; the only layer allowed to call db()
↓
src/db/** schema, migrations, and connectionRoutes authenticate, validate, and translate HTTP. Writes always go through a service. Models own queries, tenant predicates, and database transactions. tests/unit/architecture.test.ts rejects imports that cross these boundaries.
Do not create src/features/; each domain is spread across the horizontal layers. For example, reservations use models/reservation.ts, services/reservations/, config/reservations.ts, and components/reservations/.
Browser Data Flow
Server Component → service directly
Client Component → src/api/** → shared API client → /api/**Server Components never fetch the application's own API. Client Components never call raw fetch; they use a domain wrapper under src/api/, which consistently unwraps the response envelope and raises a typed client error.
Authorization Has Two Questions
Organization roles and subscription plans are intentionally separate:
const ctx = await getOrgContext(request);
if (!ctx || !can(ctx, "file:delete", file)) return respForbidden();
await requireEntitlement(ctx.orgUuid, "storage.upload");can() answers whether the member's role permits an action. Entitlement functions answer whether the organization's effective plan includes the capability or capacity.
No-Leak Error Contract
Server code throws AppError with a stable catalog code. Every route boundary ends in respError; it logs internal detail and returns translated safe copy:
throw new AppError("CREDITS_INSUFFICIENT", {
message: `org ${orgUuid} could not spend ${cost}`,
details: { required: cost, available: balance }
});{
"code": -1,
"message": "You do not have enough credits for this.",
"error_code": "CREDITS_INSUFFICIENT"
}The UI branches on error_code and resolves copy through resolveErrorMessage or resolveAuthError. It never renders error.message. Error translations live in src/lib/errors/i18n/locales/; adding a code requires all five locales.
Adding a Domain Safely
- Define constants and environment-derived flags in
src/config/. - Add typed CRUD under
src/models/. - Put invariants, authorization, idempotency, and side effects in
src/services/. - Keep route handlers thin and translate failures with the error catalog.
- Add the appropriate unit, service, API, component, or database tests.
- Run
pnpm lint,pnpm test:run, andpnpm build.
For a browser-facing feature, also decide where rendering happens. Prefer a Server Component when it can call a service directly; use a Client Component only for browser interaction, and give it a wrapper in src/api/ instead of raw fetch.
You are done when the route contains no business rules, the service owns every write invariant, the model owns every query, a rejected request exposes only a catalog error code, and the matching test tier passes.
The detailed engineering contracts remain in docs/errors.md, docs/frontend.md, and AGENTS.md inside the starter repository.
Related: Read Anatomy of a Modern SaaS for the product-level reasons behind these boundaries and the tradeoffs involved in changing them.