Skip to content

Architecture

How FastSvelte is built and why it's organized this way.

How It Works: Following a Request

Let's trace what happens when a user creates a project in your SaaS app:

  1. Frontend - User fills out a form and clicks "Create Project"
  2. API Client - Auto-generated TypeScript client sends POST request with typed data
  3. Backend Route - Validates the request, checks authentication
  4. Backend Service - Runs business logic (quota checks, validation)
  5. Repository - Executes SQL to insert the project into PostgreSQL
  6. Response - Data flows back up: Repository → Service → Route → Frontend
  7. UI Updates - React component re-renders with the new project

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


The Monorepo Structure

FastSvelte has four parts:

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

External services like Stripe, SendGrid, and Google OAuth integrate via the backend.


Backend: Layered Architecture

The backend separates concerns into layers. Here's the 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.

Route layer handles HTTP:

@router.post("/api/project")
async def create_project(
    data: ProjectCreate,
    current_user: CurrentUser = Depends(get_current_user),
    project_service: ProjectService = Depends()
):
    return await project_service.create_project(current_user.organization_id, data)

Service layer contains business logic:

async def create_project(self, org_id: int, data: ProjectCreate) -> Project:
    # Business logic: check quotas, validate data, etc.
    project_id = await self.project_repo.create(org_id, data)
    return await self.project_repo.get_by_id(project_id)

Repository layer talks to the database:

async def create(self, org_id: int, data: ProjectCreate) -> int:
    query = f"""
        INSERT INTO {self.schema}.project (organization_id, name, description)
        VALUES ($1, $2, $3)
        RETURNING id
    """
    return await self.db_config.fetch_val(query, org_id, data.name, data.description)

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.

Why dependency injection?

All objects are wired up in one place (app/config/container.py):

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

Then use them anywhere:

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

This centralization means you can swap implementations (great for testing) and change how things connect without touching business logic.


Database: Multi-Tenant PostgreSQL

Everything revolves around users and organizations:

  • user - Who you are
  • organization - Your team or personal workspace
  • role - What you can do (member, org_admin, sys_admin)
  • session - Your active logins
  • plan - Subscription tiers
  • organization_plan - Which plan you're on

B2C mode (one user per org): - User signs up → Gets their own organization automatically - Like Notion's personal workspace

B2B mode (teams): - One person creates an organization - They invite teammates - Everyone shares the same data

Switch with FS_MODE=b2b or FS_MODE=b2c.

Database migrations with Sqitch

Schema changes are version controlled:

cd db
sqitch add add_feature -n "Add feature table"

This creates three SQL files: - deploy/add_feature.sql - How to apply the change - revert/add_feature.sql - How to undo it - verify/add_feature.sql - How to verify it worked

Deploy with ./sqitch.sh dev deploy.


Frontend: Type-Safe SvelteKit

The frontend is organized by routes:

src/
├── routes/
│   ├── (auth)/          # Login, signup (public)
│   ├── (protected)/     # Dashboard, settings (requires login)
│   └── +layout.svelte   # Global layout
├── lib/
│   ├── api/gen/         # Auto-generated API client
│   ├── auth/            # Session management
│   ├── components/      # Reusable UI
│   └── utils/           # Helpers

Auto-generated API client

When you change backend models or routes:

cd frontend
npm run generate  # Reads OpenAPI spec, generates TypeScript client

Now you get compile-time type safety:

// TypeScript knows exactly what this returns
const response = await projectApi.createProject({ name: "My Project" });
// response.data is typed as Project

Here's how it works:

  1. You write a Pydantic model in the backend:

    class ProjectCreate(BaseModel):
        name: str
        description: str | None
    

  2. FastAPI auto-generates OpenAPI spec

  3. Orval reads the spec and generates TypeScript types
  4. Your frontend code is now type-safe

Change the backend model → Run npm run generate → TypeScript complains if the frontend breaks.


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

Three roles:

  • 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)

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>

Integrations

Stripe (payments)

When a user subscribes:

  1. User clicks subscribe → Frontend creates Stripe checkout session
  2. User completes payment on Stripe's hosted page
  3. Stripe sends webhook to /webhooks/stripe
  4. Backend verifies signature (prevents fake requests)
  5. Backend updates organization_plan table
  6. User immediately sees new features

SendGrid (email)

Transactional emails for password reset, verification, etc.:

await email_service.send_password_reset(user.email, reset_token)

The service handles template selection, API calls, retry logic, and error handling.

Google OAuth

Social login flow:

  1. User clicks "Login with Google"
  2. Frontend redirects to Google
  3. User approves
  4. Google redirects back with authorization code
  5. Backend exchanges code for user info
  6. Backend creates or finds user, creates session
  7. User is logged in

Frequently Asked Questions

Why file name suffixes like user_service.py?

Open 10 files in your IDE. Which tab is which?

Without suffixes:

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

With suffixes:

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

Clear, right?

Also helps with search: Type "user_service" instead of filtering through 4 different user.py files.

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 = new instance each time:

# Fresh UserRepo instance per request
user_repo = providers.Factory(UserRepo, db_config=db_config)

Use Factory when the component might hold state (like database connections).

Use Singleton when it's stateless (like services).

Why separate error handling in services vs routes?

Service layer throws business exceptions:

if user_exists:
    raise UserAlreadyExistsException(f"User {email} already exists")

Route layer converts to HTTP:

try:
    user = await user_service.create_user(email, password)
except UserAlreadyExistsException as e:
    raise HTTPException(status_code=409, detail=str(e))

Why? Services don't know about HTTP. They might be called from: - HTTP routes - Background jobs - CLI scripts - Tests

Keeping them separate makes services reusable.


Next Steps: - Development Guide - Start building - Troubleshooting - Fix issues