Back to Journal
SaaS Engineering

Complete Guide to SaaS API Design with Typescript

A comprehensive guide to implementing SaaS API Design using Typescript, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 13 min read

TypeScript has emerged as a dominant choice for SaaS API development, offering the productivity of JavaScript with the safety net of static types. This guide covers the complete architecture of a production-grade SaaS API in TypeScript, from project setup through authentication, multi-tenancy, and deployment patterns.

Every pattern here reflects production-tested approaches used in TypeScript-first SaaS platforms. The focus is on modern TypeScript with strict mode, leveraging type inference where possible and explicit annotations where they improve clarity.

Project Structure

A clean TypeScript API project separates concerns by domain:

1├── src/
2│ ├── index.ts # Entry point
3│ ├── app.ts # Express/Fastify setup
4│ ├── config/
5│ │ └── index.ts # Environment config with Zod
6│ ├── domains/
7│ │ ├── orders/
8│ │ │ ├── order.model.ts # Prisma types + domain logic
9│ │ │ ├── order.router.ts # Route definitions
10│ │ │ ├── order.service.ts # Business logic
11│ │ │ ├── order.schema.ts # Zod validation schemas
12│ │ │ └── order.repository.ts # Database queries
13│ │ └── users/
14│ │ ├── user.model.ts
15│ │ ├── user.router.ts
16│ │ └── user.service.ts
17│ ├── middleware/
18│ │ ├── auth.ts
19│ │ ├── error-handler.ts
20│ │ ├── rate-limit.ts
21│ │ ├── tenant.ts
22│ │ └── validate.ts
23│ ├── lib/
24│ │ ├── database.ts # Prisma client
25│ │ ├── cache.ts # Redis client
26│ │ ├── logger.ts # Pino logger
27│ │ └── jwt.ts # Token utilities
28│ └── types/
29│ ├── express.d.ts # Express type augmentation
30│ └── common.ts # Shared types
31├── prisma/
32│ └── schema.prisma
33├── tsconfig.json
34└── package.json
35 

Configuration with Type Safety

Use Zod to validate environment variables at startup:

typescript
1import { z } from 'zod';
2 
3const envSchema = z.object({
4 NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
5 PORT: z.coerce.number().default(3001),
6 DATABASE_URL: z.string().url(),
7 REDIS_URL: z.string().url(),
8 JWT_SECRET: z.string().min(32),
9 JWT_EXPIRES_IN: z.string().default('15m'),
10 CORS_ORIGINS: z.string().transform(s => s.split(',')),
11 LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
12});
13 
14export type Config = z.infer<typeof envSchema>;
15 
16export const config: Config = envSchema.parse(process.env);
17 

Database Layer with Prisma

Define your schema and generate type-safe queries:

prisma
1// prisma/schema.prisma
2generator client {
3 provider = "prisma-client-js"
4}
5 
6datasource db {
7 provider = "postgresql"
8 url = env("DATABASE_URL")
9}
10 
11model Order {
12 id String @id @default(uuid())
13 tenantId String @map("tenant_id")
14 customerId String @map("customer_id")
15 status OrderStatus @default(PENDING)
16 totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
17 currency String @db.VarChar(3)
18 items OrderItem[]
19 createdAt DateTime @default(now()) @map("created_at")
20 updatedAt DateTime @updatedAt @map("updated_at")
21 
22 @@index([tenantId, createdAt(sort: Desc)])
23 @@map("orders")
24}
25 
26model OrderItem {
27 id String @id @default(uuid())
28 orderId String @map("order_id")
29 productId String @map("product_id")
30 quantity Int
31 unitPrice Decimal @map("unit_price") @db.Decimal(10, 2)
32 order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
33 
34 @@map("order_items")
35}
36 
37enum OrderStatus {
38 PENDING
39 CONFIRMED
40 PROCESSING
41 COMPLETED
42 CANCELLED
43}
44 

Repository Pattern

Wrap Prisma calls in a repository for testability:

typescript
1import { PrismaClient, Order, Prisma } from '@prisma/client';
2 
3interface PaginatedResult<T> {
4 data: T[];
5 nextCursor: string | null;
6 hasMore: boolean;
7}
8 
9export class OrderRepository {
10 constructor(private readonly prisma: PrismaClient) {}
11 
12 async findById(tenantId: string, orderId: string): Promise<Order | null> {
13 return this.prisma.order.findFirst({
14 where: { id: orderId, tenantId },
15 include: { items: true },
16 });
17 }
18 
19 async list(
20 tenantId: string,
21 options: {
22 cursor?: string;
23 limit?: number;
24 status?: string;
25 }
26 ): Promise<PaginatedResult<Order>> {
27 const limit = Math.min(options.limit ?? 20, 100);
28 
29 const where: Prisma.OrderWhereInput = { tenantId };
30 if (options.status) {
31 where.status = options.status as any;
32 }
33 
34 const orders = await this.prisma.order.findMany({
35 where,
36 include: { items: true },
37 orderBy: { createdAt: 'desc' },
38 take: limit + 1,
39 ...(options.cursor && {
40 cursor: { id: options.cursor },
41 skip: 1,
42 }),
43 });
44 
45 const hasMore = orders.length > limit;
46 const data = hasMore ? orders.slice(0, limit) : orders;
47 const nextCursor = hasMore ? data[data.length - 1].id : null;
48 
49 return { data, nextCursor, hasMore };
50 }
51 
52 async create(data: Prisma.OrderCreateInput): Promise<Order> {
53 return this.prisma.order.create({
54 data,
55 include: { items: true },
56 });
57 }
58 
59 async updateStatus(
60 tenantId: string,
61 orderId: string,
62 status: string
63 ): Promise<Order> {
64 return this.prisma.order.update({
65 where: { id: orderId, tenantId },
66 data: { status: status as any },
67 include: { items: true },
68 });
69 }
70}
71 

Validation Schemas

Define request/response schemas with Zod:

typescript
1import { z } from 'zod';
2 
3export const createOrderSchema = z.object({
4 customerId: z.string().uuid(),
5 items: z
6 .array(
7 z.object({
8 productId: z.string().min(1),
9 quantity: z.number().int().positive(),
10 unitPrice: z.number().positive(),
11 })
12 )
13 .min(1),
14 currency: z.string().length(3).toUpperCase(),
15 metadata: z.record(z.unknown()).optional(),
16});
17 
18export const listOrdersSchema = z.object({
19 cursor: z.string().uuid().optional(),
20 limit: z.coerce.number().int().min(1).max(100).default(20),
21 status: z.enum(['PENDING', 'CONFIRMED', 'PROCESSING', 'COMPLETED', 'CANCELLED']).optional(),
22});
23 
24export const updateOrderStatusSchema = z.object({
25 status: z.enum(['CONFIRMED', 'PROCESSING', 'COMPLETED', 'CANCELLED']),
26});
27 
28export type CreateOrderInput = z.infer<typeof createOrderSchema>;
29export type ListOrdersInput = z.infer<typeof listOrdersSchema>;
30 

Service Layer

Services contain business logic and are framework-agnostic:

typescript
1import { Order } from '@prisma/client';
2 
3interface CreateOrderInput {
4 customerId: string;
5 items: Array<{ productId: string; quantity: number; unitPrice: number }>;
6 currency: string;
7}
8 
9export class OrderService {
10 constructor(
11 private readonly orderRepo: OrderRepository,
12 private readonly customerRepo: CustomerRepository
13 ) {}
14 
15 async createOrder(tenantId: string, input: CreateOrderInput): Promise<Order> {
16 const customer = await this.customerRepo.findById(tenantId, input.customerId);
17 if (!customer) {
18 throw new NotFoundError('Customer', input.customerId);
19 }
20 
21 if (customer.status !== 'ACTIVE') {
22 throw new BusinessError('Cannot create order for inactive customer');
23 }
24 
25 const totalAmount = input.items.reduce(
26 (sum, item) => sum + item.unitPrice * item.quantity,
27 0
28 );
29 
30 return this.orderRepo.create({
31 tenantId,
32 customerId: input.customerId,
33 totalAmount,
34 currency: input.currency,
35 items: {
36 create: input.items.map(item => ({
37 productId: item.productId,
38 quantity: item.quantity,
39 unitPrice: item.unitPrice,
40 })),
41 },
42 });
43 }
44 
45 async getOrder(tenantId: string, orderId: string): Promise<Order> {
46 const order = await this.orderRepo.findById(tenantId, orderId);
47 if (!order) {
48 throw new NotFoundError('Order', orderId);
49 }
50 return order;
51 }
52 
53 async listOrders(tenantId: string, options: ListOrdersInput) {
54 return this.orderRepo.list(tenantId, options);
55 }
56 
57 async updateStatus(
58 tenantId: string,
59 orderId: string,
60 newStatus: string
61 ): Promise<Order> {
62 const order = await this.getOrder(tenantId, orderId);
63 
64 const validTransitions: Record<string, string[]> = {
65 PENDING: ['CONFIRMED', 'CANCELLED'],
66 CONFIRMED: ['PROCESSING', 'CANCELLED'],
67 PROCESSING: ['COMPLETED', 'CANCELLED'],
68 };
69 
70 const allowed = validTransitions[order.status] ?? [];
71 if (!allowed.includes(newStatus)) {
72 throw new BusinessError(
73 `Cannot transition from ${order.status} to ${newStatus}`
74 );
75 }
76 
77 return this.orderRepo.updateStatus(tenantId, orderId, newStatus);
78 }
79}
80 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Router with Express

Define routes with type-safe middleware:

typescript
1import { Router, Request, Response } from 'express';
2import { validate } from '../middleware/validate';
3import { createOrderSchema, listOrdersSchema, updateOrderStatusSchema } from './order.schema';
4 
5export function createOrderRouter(orderService: OrderService): Router {
6 const router = Router();
7 
8 router.post(
9 '/',
10 validate('body', createOrderSchema),
11 async (req: Request, res: Response) => {
12 const order = await orderService.createOrder(req.tenantId, req.validated.body);
13 res.status(201).json({ data: order });
14 }
15 );
16 
17 router.get(
18 '/',
19 validate('query', listOrdersSchema),
20 async (req: Request, res: Response) => {
21 const result = await orderService.listOrders(req.tenantId, req.validated.query);
22 res.json(result);
23 }
24 );
25 
26 router.get('/:id', async (req: Request, res: Response) => {
27 const order = await orderService.getOrder(req.tenantId, req.params.id);
28 res.json({ data: order });
29 });
30 
31 router.patch(
32 '/:id/status',
33 validate('body', updateOrderStatusSchema),
34 async (req: Request, res: Response) => {
35 const order = await orderService.updateStatus(
36 req.tenantId,
37 req.params.id,
38 req.validated.body.status
39 );
40 res.json({ data: order });
41 }
42 );
43 
44 return router;
45}
46 

Authentication Middleware

JWT-based authentication with tenant extraction:

typescript
1import jwt from 'jsonwebtoken';
2import { Request, Response, NextFunction } from 'express';
3import { config } from '../config';
4 
5interface JWTPayload {
6 sub: string;
7 tenantId: string;
8 roles: string[];
9}
10 
11declare global {
12 namespace Express {
13 interface Request {
14 userId: string;
15 tenantId: string;
16 roles: string[];
17 }
18 }
19}
20 
21export function authenticate(req: Request, res: Response, next: NextFunction) {
22 const authHeader = req.headers.authorization;
23 if (!authHeader?.startsWith('Bearer ')) {
24 return res.status(401).json({
25 type: 'unauthorized',
26 title: 'Unauthorized',
27 status: 401,
28 detail: 'Missing or invalid authorization header',
29 });
30 }
31 
32 const token = authHeader.slice(7);
33 
34 try {
35 const payload = jwt.verify(token, config.JWT_SECRET) as JWTPayload;
36 req.userId = payload.sub;
37 req.tenantId = payload.tenantId;
38 req.roles = payload.roles;
39 next();
40 } catch {
41 return res.status(401).json({
42 type: 'unauthorized',
43 title: 'Unauthorized',
44 status: 401,
45 detail: 'Invalid or expired token',
46 });
47 }
48}
49 

Validation Middleware

Generic validation middleware using Zod:

typescript
1import { z, ZodSchema } from 'zod';
2import { Request, Response, NextFunction } from 'express';
3 
4type RequestPart = 'body' | 'query' | 'params';
5 
6declare global {
7 namespace Express {
8 interface Request {
9 validated: Record<string, any>;
10 }
11 }
12}
13 
14export function validate(part: RequestPart, schema: ZodSchema) {
15 return (req: Request, res: Response, next: NextFunction) => {
16 const result = schema.safeParse(req[part]);
17 
18 if (!result.success) {
19 return res.status(422).json({
20 type: 'validation_error',
21 title: 'Validation Failed',
22 status: 422,
23 errors: result.error.issues.map(issue => ({
24 field: issue.path.join('.'),
25 message: issue.message,
26 code: issue.code,
27 })),
28 });
29 }
30 
31 req.validated = req.validated || {};
32 req.validated[part] = result.data;
33 next();
34 };
35}
36 

Error Handling

Centralized error handler with RFC 7807 Problem Details:

typescript
1export class AppError extends Error {
2 constructor(
3 public readonly statusCode: number,
4 public readonly title: string,
5 message: string,
6 public readonly errors?: Array<{ field: string; message: string }>
7 ) {
8 super(message);
9 this.name = 'AppError';
10 }
11}
12 
13export class NotFoundError extends AppError {
14 constructor(resource: string, id: string) {
15 super(404, 'Not Found', `${resource} '${id}' was not found`);
16 }
17}
18 
19export class BusinessError extends AppError {
20 constructor(message: string) {
21 super(422, 'Business Rule Violation', message);
22 }
23}
24 
25export function errorHandler(
26 err: Error,
27 req: Request,
28 res: Response,
29 _next: NextFunction
30) {
31 if (err instanceof AppError) {
32 return res.status(err.statusCode).json({
33 type: err.title.toLowerCase().replace(/\s+/g, '_'),
34 title: err.title,
35 status: err.statusCode,
36 detail: err.message,
37 ...(err.errors && { errors: err.errors }),
38 });
39 }
40 
41 logger.error({ err, path: req.path, method: req.method }, 'Unhandled error');
42 
43 return res.status(500).json({
44 type: 'internal_error',
45 title: 'Internal Server Error',
46 status: 500,
47 detail: 'An unexpected error occurred',
48 });
49}
50 

Application Setup

Wire everything together with dependency injection:

typescript
1import express from 'express';
2import cors from 'cors';
3import helmet from 'helmet';
4import pino from 'pino-http';
5import { PrismaClient } from '@prisma/client';
6import { config } from './config';
7import { authenticate } from './middleware/auth';
8import { errorHandler } from './middleware/error-handler';
9import { OrderRepository } from './domains/orders/order.repository';
10import { OrderService } from './domains/orders/order.service';
11import { createOrderRouter } from './domains/orders/order.router';
12 
13const prisma = new PrismaClient();
14 
15// Repositories
16const orderRepo = new OrderRepository(prisma);
17const customerRepo = new CustomerRepository(prisma);
18 
19// Services
20const orderService = new OrderService(orderRepo, customerRepo);
21 
22// App
23const app = express();
24 
25app.use(helmet());
26app.use(cors({ origin: config.CORS_ORIGINS, credentials: true }));
27app.use(express.json({ limit: '1mb' }));
28app.use(pino({ level: config.LOG_LEVEL }));
29 
30app.get('/health', (_req, res) => res.json({ status: 'ok' }));
31 
32app.use('/api/v1', authenticate);
33app.use('/api/v1/orders', createOrderRouter(orderService));
34 
35app.use(errorHandler);
36 
37const server = app.listen(config.PORT, () => {
38 console.log(`API running on port ${config.PORT}`);
39});
40 
41process.on('SIGTERM', async () => {
42 server.close();
43 await prisma.$disconnect();
44 process.exit(0);
45});
46 

Conclusion

TypeScript provides a unique advantage for SaaS API development: the safety of static types without the verbosity of Java or the compilation overhead of Go. Combined with Prisma's type-safe database queries and Zod's runtime validation, you get end-to-end type safety from database to API response.

The architecture outlined here—domain-driven structure, repository pattern, service layer, and centralized error handling—creates a codebase that's easy to navigate, test, and extend. Each domain is self-contained with its own routes, schemas, services, and repository, making it straightforward to add new features without modifying existing code.

TypeScript's ecosystem advantage is particularly strong for teams building full-stack SaaS applications. Sharing types between your API and frontend eliminates an entire category of integration bugs, and the npm ecosystem provides battle-tested solutions for every infrastructure concern from caching to queue processing.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026