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:
- Frontend - User fills out a form and clicks "Create Project"
- API Client - Auto-generated TypeScript client sends POST request with typed data
- Backend Route - Validates the request, checks authentication
- Backend Service - Runs business logic (quota checks, validation)
- Repository - Executes SQL to insert the project into PostgreSQL
- Response - Data flows back up: Repository → Service → Route → Frontend
- 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 areorganization- Your team or personal workspacerole- What you can do (member, org_admin, sys_admin)session- Your active loginsplan- Subscription tiersorganization_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:
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:
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:
-
You write a Pydantic model in the backend:
-
FastAPI auto-generates OpenAPI spec
- Orval reads the spec and generates TypeScript types
- 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):
- User logs in → Backend creates session in database
- Backend sends HTTP-only cookie with session ID
- Every API call includes this cookie automatically
- 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:
- User clicks subscribe → Frontend creates Stripe checkout session
- User completes payment on Stripe's hosted page
- Stripe sends webhook to
/webhooks/stripe - Backend verifies signature (prevents fake requests)
- Backend updates
organization_plantable - User immediately sees new features
SendGrid (email)
Transactional emails for password reset, verification, etc.:
The service handles template selection, API calls, retry logic, and error handling.
Google OAuth
Social login flow:
- User clicks "Login with Google"
- Frontend redirects to Google
- User approves
- Google redirects back with authorization code
- Backend exchanges code for user info
- Backend creates or finds user, creates session
- 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:
With suffixes:
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:
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:
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