Skip to content

Architecture

This document explains how FastSvelte is structured and the reasoning behind key architectural decisions.

1. The Monorepo Structure

FastSvelte has four parts:

fastsvelte/
├── backend/          # FastAPI + Python (your API)
├── frontend/         # SvelteKit + TypeScript (admin dashboard)
├── landing/          # SvelteKit (marketing site)
└── backend/db/       # PostgreSQL + Sqitch (database migrations)

Why a monorepo?

FastSvelte uses a monorepo because it's a tightly coupled fullstack application where the backend and frontend are designed to work together. When the backend API changes, the frontend needs to change with it - keeping them in separate repositories would mean managing dependencies, versioning, and synchronization across repos. A monorepo allows atomic commits that update the database schema, backend logic, and frontend UI simultaneously, ensuring the entire system stays in sync. This simplifies development with a single clone and unified tooling.

Design Philosophy: Minimal Dependencies, Maximum Flexibility

FastSvelte intentionally avoids many popular libraries and frameworks that other starter kits include. Instead of bundling heavy abstractions, it sticks to proven, essential tools with minimal dependencies.

Why stay lean? Adding a library later is straightforward - removing one that's baked into the starter kit is painful. As a starter kit, FastSvelte's job is to provide a solid foundation, not to make architectural decisions for you. This approach makes the codebase highly customizable. Build exactly what you need, add libraries as your requirements become clear, and maintain full control over your architecture.


2. End-to-End Request Flow

Let's trace what happens when a user creates a project in your SaaS app. (This example follows the Adding a Feature tutorial, where you build a project management feature.)

High-Level Flow

sequenceDiagram
    participant User
    participant Frontend as Frontend<br/>(SvelteKit)
    participant Backend as Backend<br/>(FastAPI)

    User->>Frontend: Fill form & click "Create Project"
    Frontend->>Backend: POST /api/projects
    Backend->>Backend: Validate, auth, save to DB
    Backend-->>Frontend: ProjectResponse (JSON)
    Frontend->>Frontend: Update UI
    Frontend-->>User: Show success

The flow:

  1. User fills out a form and clicks "Create Project"
  2. Frontend sends API request with project data
  3. Backend validates, authenticates, and saves to database
  4. Response flows back: backend → frontend → user sees success

That's the high-level flow. Now let's see how each piece is built.


3. Backend: Layered Architecture

The backend is where all the heavy lifting happens. External services like Stripe, SendGrid, and Google OAuth integrate here. The frontend stays thin - it's purely a presentation layer that talks to the backend API.

The backend separates concerns into layers. Here's how a request flows through them:

Backend Request Flow

sequenceDiagram
    participant Route as Route Layer<br/>(HTTP)
    participant Service as Service Layer<br/>(Business Logic)
    participant Repo as Repository<br/>(Data Access)
    participant DB as PostgreSQL

    Route->>Route: Validate request & auth
    Route->>Service: create_project(org_id, user_id, data)
    Service->>Service: Check quotas & validate
    Service->>Repo: create_project(org_id, user_id, data)
    Repo->>DB: INSERT INTO project...
    DB-->>Repo: Return project ID
    Repo->>DB: SELECT * FROM project WHERE id = ?
    DB-->>Repo: Return project data
    Repo-->>Service: Project entity
    Service-->>Route: Project entity
    Route-->>Route: Convert to JSON response

Each layer has a specific job:

  • Route - Handles HTTP (validation, auth, response formatting)
  • Service - Business logic (quotas, permissions, workflows)
  • Repository - Database access (SQL queries)
  • Database - Data storage

Directory Structure

app/
├── main.py              # Starts everything
├── config/              # Settings & dependency injection
├── api/route/           # HTTP endpoints
├── service/             # Business logic
├── data/repo/           # Database queries
├── model/               # Request/response shapes
└── util/                # Auth, email, etc.

Keep Layers Separated

Don't leak concerns between layers. Services shouldn't know about HTTP status codes or request objects. Repositories shouldn't contain business logic.

Bad: the service returns an HTTP exception:

# Wrong: the service layer shouldn't know about HTTP
async def create_user(self, email: str):
    if self.user_repo.exists(email):
        raise HTTPException(status_code=409, detail="User exists")

Good: the service throws a domain exception, the route handles HTTP:

# Right: the service throws a domain exception
async def create_user(self, email: str):
    if self.user_repo.exists(email):
        raise UserAlreadyExistsException(email)

# Right: the route converts it to HTTP
@router.post("/users")
async def create_user_route(data: UserCreate):
    try:
        return await user_service.create_user(data.email)
    except UserAlreadyExistsException as e:
        raise HTTPException(status_code=409, detail=str(e))

Why raw SQL instead of an ORM?

ORMs add complexity. You learn the ORM's query language, debug what SQL it generates, then eventually write raw SQL anyway for performance. With raw SQL in repositories, you see exactly what runs and optimize directly.

Raw SQL is also easier for LLMs to generate and reason about, making AI-assisted development smoother.

Beyond technical considerations, this choice aligns with FastSvelte's design philosophy of staying lean. Adding an ORM later is straightforward when your project needs it - removing one that's baked into the starter kit is painful. You maintain full control over data access patterns and can choose SQLAlchemy, Prisma, or any other tool based on your actual requirements.

Why dependency injection?

Dependency injection eliminates repetitive boilerplate and centralizes configuration. Instead of manually constructing dependencies in every route, they're wired up once and injected automatically.

In FastSvelte, all objects are wired up in one place (app/config/container.py):

# Define everything once
project_repo = providers.Singleton(ProjectRepo, db_config=db_config)
project_service = providers.Singleton(ProjectService, project_repo=project_repo)

Then use them anywhere:

async def create_project(
    project_service: ProjectService = Depends()  # Injected automatically
):
    ...

This centralization provides several benefits. Object lifecycles (singleton vs factory) are explicit and visible in one file - no hunting through the codebase to determine if a service creates new instances or reuses one. Configuration changes propagate automatically without touching route code. Testing becomes straightforward by swapping implementations in the container rather than modifying dozens of files.


4. Database: Multi-Tenant PostgreSQL

All data is scoped to an organization (the tenant boundary), so the schema serves both individual users and teams without changing. Migrations are plain SQL managed with Sqitch, no ORM. The mode (b2c / b2b) is set by FS_MODE and changes only application logic, not the schema.

See Database for the schema, db_config, and the Sqitch workflow, and Multi-Tenancy for the organization, role, and invitation model.


5. Frontend: Type-Safe SvelteKit

The frontend is a SvelteKit SPA (Single Page Application) that stays thin by delegating all business logic to the backend. It uses Svelte 5 runes for reactivity and maintains type safety through auto-generated API clients.

Directory structure:

src/
├── routes/
│   ├── (auth)/          # Login, signup (public pages)
│   ├── (protected)/     # Dashboard, settings (requires authentication)
│   └── +layout.svelte   # Global layout wrapper
├── lib/
│   ├── api/gen/         # Auto-generated TypeScript API client (Orval)
│   ├── auth/            # Session management with Svelte stores
│   ├── components/      # Reusable UI components
│   ├── context/         # Application-wide context providers
│   ├── config/          # Configuration and constants
│   └── util/            # Helper functions and utilities

Key features:

  • Route-based authentication: Routes in (protected)/ automatically check for valid sessions
  • Auto-generated API client: TypeScript types generated from OpenAPI spec ensure compile-time safety
  • Svelte 5 runes: Modern reactivity with $state, $derived, and $effect for local component state
  • TailwindCSS + DaisyUI: Utility-first styling with pre-built component themes

Rendering model: app and landing

FastSvelte ships two SvelteKit projects that render differently on purpose:

App (frontend/) Landing (landing/)
Mode SPA (ssr: false) SSG (ssr: true + prerender = true)
Build output Static files with an index.html fallback Static HTML per route, with real content
Why Lives behind auth, so there is nothing for crawlers to index. Client-side rendering keeps session handling simple. Marketing pages live or die by SEO. Crawlers get complete HTML without running JavaScript.

Both projects build to plain static files, so the only server in any deployment is the FastAPI backend. There is no Node tier to run, scale, or pay for. Deep links like /settings still work on a static host because the deploy configs shipped with the kit (vercel.json, staticwebapp.config.json) rewrite unknown paths to the SPA fallback.

Prerendering freezes the landing's content at build time. Editing marketing copy means rebuild and redeploy, and PUBLIC_* variables are baked in during the build (set them in CI, not in your host's runtime settings).

If your landing outgrows static. Prerendering is ordinary server-side rendering that runs once at build, so the landing's code stays fully server-renderable. If you later need per-request rendering (personalization, instant-publish content), swap adapter-static for adapter-node or adapter-vercel in landing/svelte.config.js and remove the prerender flag from src/routes/+layout.ts. That is a two-line config change, not a rewrite. Forms like the newsletter signup never need it: point them at a FastAPI endpoint from the client.

Auto-generated API client

The frontend's TypeScript API client is generated from the backend's OpenAPI spec, so a backend change surfaces as a compile-time error in the frontend. See Type-Safe API Client (Orval).

Data loading: +page.ts vs onMount

Pages fetch their data in a +page.ts load function, not in onMount. The load starts as soon as you navigate, so the page appears immediately instead of mounting empty and then filling itself in.

There are two flavours, and one exception.

Page type Pattern Example to copy
Lists, tables, dashboards, detail views +page.ts, streamed (return the promise, do not await it) routes/(protected)/notes/+page.ts
Editable forms +page.ts, awaited (return the finished data) routes/(protected)/settings/+page.ts
Polling, WebSockets, upload progress onMount in the component, with cleanup routes/(protected)/admin/health/+page.svelte

Streamed means the load returns a promise it never awaited:

export const load: PageLoad = async ({ depends }) => {
    depends(KEYS.notes);
    return { notes: listNotes() }; // note: no await
};

The page unwraps it with {#await}, which puts the loading, loaded and failed states in one place. You do not write a loading flag:

{#await data.notes}
    <NotesSkeleton />
{:then notes}
    <NotesGrid {notes} />
{:catch}
    <p>Failed to load notes.</p>
{/await}

Awaited is for forms, and the reason is dirty-detection. When the load hands over finished data, data is the saved state, so "does this form have unsaved changes?" is just a comparison against it:

let theme = $state(untrack(() => data.theme)); // seeded once, then follows the user
const hasChanges = $derived(theme !== data.theme);

Do it the other way and you end up maintaining a second originalTheme copy by hand, and resyncing it after every save.

Two rules that keep this working:

  1. Never copy load data into $state on a read-only page. The copy goes stale the moment the load re-runs. Read data directly. Forms are the exception, and only for the fields being edited, as above.
  2. Refresh by re-running the load, not by refetching. After a mutation, call invalidate() with the scope the load claimed via depends():
await deleteNote(id);
await invalidate(KEYS.notes); // re-runs the load, which refetches the list

Every scope in the app is named in lib/invalidation-keys.ts. They live in one file because a mistyped scope fails silently: invalidate('app:note') matches nothing, refreshes nothing, and leaves stale data on screen without an error.

Why depends() is needed at all

SvelteKit can track fetches for you, but only when they go through the fetch it passes into the load function. Our Orval client uses its own, so each load names its scope explicitly with depends() and mutations invalidate it by that name.

Why not onMount? It only runs after the component mounts, so navigation completes, the page renders empty, and then the request starts. You also hand-roll loading and error flags, re-fetching when a route parameter changes, and cancelling requests that a newer one has superseded. Load functions do all of that for you.

Keep onMount for what is genuinely tied to the component's lifetime: a timer, a socket, an event listener you have to remove. admin/health polls on an interval, so it is the one page that owns its data in local state, and it is commented to say so.

Background reading: when to use load functions and onMount and load functions vs onMount.


6. Authentication & Security

Session-based authentication

We use session cookies (not JWT tokens):

  1. User logs in → Backend creates session in database
  2. Backend sends HTTP-only cookie with session ID
  3. Every API call includes this cookie automatically
  4. Backend checks: "Is this session valid?" before responding

Why session cookies instead of JWT?

  • HTTP-only cookies can't be stolen by JavaScript (XSS protection)
  • Server controls sessions = instant logout
  • Simpler frontend code = no token refresh logic
  • Built-in CSRF protection with SameSite cookies

Sessions expire after 24 hours (configurable). The backend stores hashed session tokens and compares them on each request.

Role-based access control

Four roles, ordered by precedence (readonly < member < org_admin < sys_admin):

  • readonly - View-only access
  • member - Basic user (can use the app)
  • org_admin - Manage organization (invite users, change settings)
  • sys_admin - Full system access (manage all orgs, see analytics)

See Authentication and Security for the full model. Protect routes with role checks:

@router.get("/admin/users")
async def list_users(
    current_user: CurrentUser = Depends(min_role_required(Role.SYSTEM_ADMIN))
):
    # Only sys_admins can reach this

Routes in (protected)/ automatically check authentication on the frontend:

<!-- (protected)/+layout.svelte -->
<script>
  import { onMount } from "svelte";
  import { ensureAuthenticated } from "$lib/auth/session";

  onMount(async () => {
    await ensureAuthenticated(); // Redirects to login if not authenticated
  });
</script>

7. Design Decisions

Why file name suffixes like user_service.py?

Files are named service/user_service.py instead of service/user.py. The suffix appears redundant since the folder already indicates the layer, but it solves a practical problem.

Without suffixes:

user.py | user.py | user.py | user.py

With suffixes:

user_route.py | user_service.py | user_model.py | user_repo.py

When multiple files are open, IDE tabs show filenames, not full paths. Without suffixes, every tab displays user.py - making navigation difficult. The suffix also improves search: typing "user_service" immediately finds the right file instead of filtering through four different user.py files across different folders.

Where should imports go?

Put imports at the top of the file by default. Python caches imported modules, so top-level imports cost nothing at runtime, and keeping them together makes a file's dependencies obvious at a glance.

Move an import inside a function only for a specific reason:

  • Optional dependencies: a feature relying on a package not every install includes. An inline import keeps the module importable when the package is absent, failing only if the feature is actually used. The email provider factory does this: it imports the Azure, SendGrid, or Resend client only when that provider is selected, so you don't need all three SDKs installed.
  • Breaking a circular import: when two modules need each other at import time, a deferred import inside the function that needs it sidesteps the cycle.
  • Smoke test isolation: keeping a heavy or environment-dependent import out of module load so a smoke test can exercise the rest of the module without it.

Why Factory vs Singleton in dependency injection?

Singleton = one instance for the whole app:

# Same UserService instance every time
user_service = providers.Singleton(UserService, user_repo=user_repo)

Factory = a fresh instance every time it's injected:

# Fresh instance per injection
report_builder = providers.Factory(ReportBuilder, plan_repo=plan_repo)

FastSvelte's container registers everything as a Singleton, deliberately. Repos and services are stateless: they hold only references to other singletons and read-only config, so there is no per-request state to isolate. The one stateful component, the database connection pool, lives inside the db_config singleton precisely so that every repo shares one pool instead of each opening its own connections. The OpenAI client is similar: it keeps persistent HTTP connections open, and sharing one instance reuses them instead of reconnecting on every request.

Use Factory when you add a component that holds per-request state (a unit-of-work object, a mutable builder, anything unsafe to share across concurrent requests). When you add an ordinary repo or service, register it as a Singleton like everything else in the container.

How does error handling work?

FastSvelte uses domain exceptions that inherit from BaseAppException. Each exception knows its own HTTP status code, error code, and message format. A global error handler middleware automatically converts these to JSON responses.

Services throw domain exceptions:

from app.exception.common_exception import ResourceNotFound

async def get_user(self, user_id: int):
    user = await self.user_repo.get_by_id(user_id)
    if not user:
        raise ResourceNotFound(resource="user", resource_id=user_id)
    return user

Routes don't need try/catch - exceptions bubble up to middleware:

@router.get("/{user_id}")
async def get_user_route(user_id: int, user_service: UserService = Depends()):
    # No try/catch needed - middleware handles it
    return await user_service.get_user(user_id)

Middleware automatically converts to HTTP response:

The global error handler in app/api/middleware/error_handler.py catches all BaseAppException instances and returns structured JSON responses with the appropriate status code, error code, message, and details.

See Section 3 for why services shouldn't know about HTTP - they might be called from routes, background jobs, CLI scripts, or tests.



Next Steps: