Evaluate and adapt

How Sushi SaaS Is Structured

Understand the starter's enforced layers, the choices behind them, and how to add a feature without creating a second architecture.

This guide is for adopters deciding where their first product feature belongs. Sushi SaaS uses one horizontal architecture across every domain, and tests enforce it.

The path a request follows

src/app/**       routes and pages — HTTP in, HTTP out

src/services/**  business rules, orchestration, invariants

src/models/**    typed persistence; the only layer that calls db()

src/db/**        schema, migrations, connection

A route parses and responds. A service decides whether work is allowed and coordinates side effects. A model owns database reads and writes. Database constraints remain the final protection against concurrent requests.

The browser follows a second boundary:

Server Component  → service directly
Client Component  → src/api/** → shared API client → /api/**

This avoids an HTTP round trip from the server to itself and gives client errors one response contract. See Architecture and Error Contracts for the exact rules.

Why Sushi chose horizontal layers

Billing, credits, storage, reservations, and tasks all need authentication, tenant scoping, errors, and persistence. A single set of layers makes each responsibility predictable and lets architecture tests catch accidental shortcuts.

The tradeoff is that one feature is spread across several directories. A vertical feature folder can feel faster to browse in a large product, but mixing vertical and horizontal structures is worse than either: the next query or business rule has two plausible homes.

Your options

  • Keep the shipped structure when a small team wants strong conventions and shared infrastructure across domains.
  • Move fully to vertical slices if that is already your team's standard. Move complete domains and update tests/unit/architecture.test.ts; do not add a src/features island while keeping the old rules elsewhere.
  • Simplify for a short-lived prototype only if you accept a later migration. If routes write directly to the database, remove or change the test deliberately instead of creating exceptions one by one.

Tenancy is an architectural choice

Every user gets a personal organization, so a solo customer and a team use the same organization-scoped billing, credits, files, and limits. This avoids parallel “user-owned” and “team-owned” query paths.

Keep that model if teams are possible later. If the product will always be single-user, removing organizations is valid—but it is a coordinated change to authorization, schema, models, billing, storage, and tests, not a UI toggle. Review Organizations and Teams before deciding.

Why the admin console is separate

apps/admin has its own pages, admin APIs, data access, MFA gate, and operator roles. It shares the schema and authentication data but can run on another origin. The cost is a second build and deployment; the benefit is a clear operator boundary.

You can merge it into the customer application if your team prefers one deployment. Preserve server-side admin authorization and audit rules—the navigation boundary alone is not security. Start with Admin Console Setup.

A complete vertical journey through horizontal layers

The image-generation reference shows why the structure matters. Its HTTP route authenticates and validates; a task service fingerprints the request, reserves one deterministic five-credit spend, and dispatches durable work; task/credit/file models own SQL; database constraints bind the relationships; the provider adapter remains replaceable; the worker stores private output or compensates the ledger. Route, service, database, component, and Playwright tests each prove a different boundary.

Read Why One Five-Credit Task Tests the Whole SaaS before designing your first paid domain.

Add your first domain

  1. Define product vocabulary and limits in src/types and src/config.
  2. Add schema and an expand-safe migration in src/db.
  3. Put typed CRUD in src/models.
  4. Put authorization, idempotency, and side effects in src/services.
  5. Add the route or Server Component in src/app; use src/api for client calls.
  6. Test the service rule, the route's auth gate, and replay behavior for money or credits.

Use capability checks instead of comparing tier names, and update the repository runbook when the contract changes. This guide reflects starter commit 2a1a04a.

How Sushi SaaS Is Structured · Sushi SaaS