SaaS Architecture: Monolith, Microservices & API Design
Start With a Monolith
Controversial opinion that isn't: Start with a monolith.
A monolith is a single deployable application containing all your code. It's simpler to:
- Develop (one codebase)
- Test (one test suite)
- Deploy (one deployment)
- Debug (one process to trace)
- Refactor (change anything easily)
Companies that started as monoliths and succeeded: Shopify, Stack Overflow, Basecamp, GitHub (initially).
When to extract services:
- Team is large enough that coordination is painful (15+ engineers)
- Specific component has completely different scaling needs
- Clear domain boundaries have emerged
- Deploy frequency is blocked by coupling
The Modular Monolith
Best of both worlds: monolith deployment with service-like organization.
Structure:
/src
/modules
/users
routes.ts
service.ts
repository.ts
/billing
routes.ts
service.ts
repository.ts
/projects
...Rules:
- Modules communicate through defined interfaces
- No direct database access across modules
- Each module owns its tables
- Modules can be extracted to services later
This gives you monolith simplicity with a path to services.
API Design
REST (Most Common)
Resource-based API design. Works for most SaaS.
Conventions:
- GET /users — List users
- POST /users — Create user
- GET /users/:id — Get user
- PUT /users/:id — Update user
- DELETE /users/:id — Delete user
Best practices:
- Use plural nouns (/users not /user)
- Nest for relationships (/users/:id/projects)
- Filter with query params (/users?role=admin)
- Return appropriate status codes
- Version your API (/v1/users)
GraphQL (When It Fits)
Query language for flexible data fetching.
Good for: Mobile apps (bandwidth savings), complex UIs with varying data needs
Overkill for: Simple CRUD apps, small teams
tRPC (TypeScript End-to-End)
Type-safe API without schema definition.
Good for: Full-stack TypeScript apps, rapid development
Consider: Lock-in to TypeScript ecosystem
Event-Driven Patterns
Decouple operations that don't need to be synchronous.
When to Use Events
- Operations that can happen later (email, analytics)
- Operations that shouldn't block the response
- Operations triggered by multiple sources
Simple Pattern: Background Jobs
Use a job queue (BullMQ, SQS, Inngest) for:
- Sending emails
- Processing uploads
- Generating reports
- Syncing external services
Advanced: Event Sourcing
Store events as the source of truth, derive state from events.
Use when: Audit trails matter, complex domain logic, undo required
Avoid when: Simple CRUD, small team, unclear requirements
Data Layer Patterns
Repository Pattern
Abstract database access behind interfaces. Makes testing easier and switching databases possible.
// Repository interface
interface UserRepository {
findById(id: string): Promise;
create(data: CreateUserData): Promise;
update(id: string, data: UpdateUserData): Promise;
}
// Implementation with Prisma, Drizzle, etc.
class PostgresUserRepository implements UserRepository {
// ...
} Service Layer
Business logic separate from HTTP handling. Routes call services, services call repositories.
// Route handler
app.post('/users', async (req, res) => {
const user = await userService.createUser(req.body);
res.json(user);
});
// Service (business logic)
class UserService {
async createUser(data) {
// Validation, business rules
return this.userRepository.create(data);
}
}
Architecture Anti-Patterns
- Distributed monolith — Microservices that are tightly coupled (worst of both worlds)
- Shared database — Multiple services accessing same tables (no isolation)
- Circular dependencies — Services that call each other in loops
- Over-abstraction — Layers of indirection that add no value
- Framework lock-in — Business logic tightly coupled to framework