Clean Architecture for Modern SaaS Applications: A Practical Guide
Learn how to apply clean architecture patterns to build scalable, maintainable SaaS applications with practical code examples and real-world insights.

Is your company ready for AI? Download our free checklist →
Download checklistIntroduction
Building a modern SaaS application is a complex endeavor. As your user base grows and features multiply, maintaining code quality and ensuring system scalability becomes critical. Clean architecture, popularized by Robert C. Martin, offers a set of principles that keep your business logic independent of frameworks, databases, and UI technologies. In this post, we'll explore how to apply clean architecture patterns specifically to SaaS applications, with practical code examples in TypeScript.
Why Clean Architecture for SaaS?
SaaS applications face unique challenges:
- Multi-tenancy: Data isolation between customers
- Continuous deployment: Frequent releases without downtime
- Third-party integrations: Payment gateways, email services, etc.
- Scalability: Handling sudden spikes in traffic
Clean architecture addresses these by enforcing strict dependency inversion: high-level modules (business rules) should not depend on low-level modules (infrastructure). Instead, both depend on abstractions.
The Onion Layers
Clean architecture is often visualized as concentric circles:
- Entities: Enterprise-wide business rules
- Use Cases: Application-specific business rules
- Interface Adapters: Controllers, presenters, gateways
- Frameworks & Drivers: External tools (DB, UI, APIs)
Dependencies point inward: the inner circles know nothing about the outer ones.
Practical Example: User Subscription Use Case
Let's build a simple use case for upgrading a user's subscription plan.
Want a personalized diagnostic? Complete our free checklist →
Download checklistEntities
// domain/entities/SubscriptionPlan.ts
export enum PlanType {
FREE = 'free',
PRO = 'pro',
ENTERPRISE = 'enterprise'
}
export class SubscriptionPlan {
constructor(
public readonly type: PlanType,
public readonly pricePerMonth: number
) {}
canDowngradeTo(target: PlanType): boolean {
// Business rule: enterprise can't downgrade to free directly
return this.type !== target;
}
}
Use Case (Interactor)
// application/use-cases/UpgradeSubscription.ts
import { SubscriptionPlan, PlanType } from '../domain/entities/SubscriptionPlan';
// This is an interface that defines what the outer layer must provide
export interface IUserRepository {
getUserPlan(userId: string): Promise<SubscriptionPlan>;
updateUserPlan(userId: string, newPlan: SubscriptionPlan): Promise<void>;
}
export interface IPaymentGateway {
chargeCustomer(customerId: string, amount: number): Promise<boolean>;
}
export interface INotificationService {
sendUpgradeConfirmation(email: string, plan: PlanType): Promise<void>;
}
export class UpgradeSubscription {
constructor(
private userRepo: IUserRepository,
private paymentGateway: IPaymentGateway,
private notificationService: INotificationService
) {}
async execute(userId: string, targetPlan: PlanType): Promise<void> {
const currentPlan = await this.userRepo.getUserPlan(userId);
const newPlan = new SubscriptionPlan(targetPlan, targetPlan === PlanType.FREE ? 0 : 29.99);
if (!currentPlan.canDowngradeTo(targetPlan)) {
throw new Error('Invalid plan transition');
}
const paymentSuccess = await this.paymentGateway.chargeCustomer(userId, newPlan.pricePerMonth);
if (!paymentSuccess) {
throw new Error('Payment failed');
}
await this.userRepo.updateUserPlan(userId, newPlan);
await this.notificationService.sendUpgradeConfirmation('user@example.com', targetPlan);
}
}
Interface Adapters (Controllers & Repositories)
// infrastructure/persistence/UserRepository.ts
import { IUserRepository } from '../../application/use-cases/UpgradeSubscription';
import { SubscriptionPlan, PlanType } from '../../domain/entities/SubscriptionPlan';
export class MongoUserRepository implements IUserRepository {
async getUserPlan(userId: string): Promise<SubscriptionPlan> {
// Real implementation: query MongoDB
const dbPlan = await db.collection('users').findOne({ id: userId }).plan;
return new SubscriptionPlan(dbPlan.type, dbPlan.price);
}
async updateUserPlan(userId: string, newPlan: SubscriptionPlan): Promise<void> {
await db.collection('users').updateOne(
{ id: userId },
{ $set: { plan: newPlan } }
);
}
}
// infrastructure/web/controllers/SubscriptionController.ts
import { UpgradeSubscription } from '../../../application/use-cases/UpgradeSubscription';
export class SubscriptionController {
constructor(private upgradeSubscription: UpgradeSubscription) {}
async handleUpgrade(req: Request, res: Response): Promise<void> {
try {
const { userId, targetPlan } = req.body;
await this.upgradeSubscription.execute(userId, targetPlan);
res.status(200).json({ success: true });
} catch (error) {
res.status(400).json({ error: error.message });
}
}
}
Dependency Injection: Wiring It All Together
To keep the layers decoupled, we use a dependency injection container (e.g., Inversify or simple manual wiring):
// infrastructure/ioc/container.ts
import { UpgradeSubscription } from '../../application/use-cases/UpgradeSubscription';
import { MongoUserRepository } from '../persistence/MongoUserRepository';
import { StripePaymentGateway } from '../payment/StripePaymentGateway';
import { SendGridNotificationService } from '../notifications/SendGridNotificationService';
export class Container {
// ...
private initUseCases() {
const userRepo = new MongoUserRepository();
const paymentGateway = new StripePaymentGateway();
const notificationService = new SendGridNotificationService();
this.upgradeSubscription = new UpgradeSubscription(userRepo, paymentGateway, notificationService);
}
}
Handling Multi-Tenancy
Clean architecture makes multi-tenancy natural: add a tenantId to entities and pass it through use cases. Repository implementations filter by tenant.
export interface ITenantAwareRepository {
setTenantId(tenantId: string): void;
}
export class TenantAwareUserRepository implements IUserRepository, ITenantAwareRepository {
private tenantId: string;
setTenantId(tenantId: string) {
this.tenantId = tenantId;
}
async getUserPlan(userId: string): Promise<SubscriptionPlan> {
// Add tenant filter
return db.collection('users').findOne({ id: userId, tenantId: this.tenantId });
}
}
Testing Advantages
Clean architecture promotes testability:
- Unit Test Use Cases: Mock repositories and gateways
- Integration Test Repositories: Use real test databases
- End-to-End Tests: Only test controllers with real wiring
Example unit test for UpgradeSubscription:
describe('UpgradeSubscription', () => {
it('should upgrade plan successfully', async () => {
const mockRepo = jest.fn<IUserRepository>();
mockRepo.getUserPlan = jest.fn().mockResolvedValue(new SubscriptionPlan(PlanType.FREE, 0));
mockRepo.updateUserPlan = jest.fn();
const mockPayment = jest.fn<IPaymentGateway>();
mockPayment.chargeCustomer = jest.fn().mockResolvedValue(true);
const mockNotification = jest.fn<INotificationService>();
mockNotification.sendUpgradeConfirmation = jest.fn();
const useCase = new UpgradeSubscription(mockRepo, mockPayment, mockNotification);
await useCase.execute('user1', PlanType.PRO);
expect(mockRepo.updateUserPlan).toHaveBeenCalled();
expect(mockNotification.sendUpgradeConfirmation).toHaveBeenCalled();
});
});
Real-World Considerations
While clean architecture is powerful, avoid over-engineering. For small SaaS apps, simpler patterns like MVC may suffice. As complexity grows, gradually introduce clean architecture layers.
Resources
- Clean Architecture: A Craftsman's Guide by Robert C. Martin - The foundational book
- Hexagonal Architecture in TypeScript - Practical implementation patterns
Conclusion
Clean architecture patterns give your SaaS application a solid foundation that can evolve with business needs. By decoupling business logic from infrastructure, you gain the ability to swap out databases, payment providers, or UI frameworks without rewriting core code. Start by identifying your use cases, define clear interfaces, and let the architecture scale with your product.
Ready for the next step? Evaluate your company with our free checklist →
Download checklistRelated posts
- Backend▣
Ada Lovelace: The Victorian Visionary Who Wrote the First Algorithm in 1843
Ada Lovelace: The Victorian Visionary Who Wrote the First Algorithm in 1843
Sep 29, 2026
- AI & ML◈
Apple Unveils 2026 AI Developer Tools: A New Era for On-Device Intelligence
Apple Unveils 2026 AI Developer Tools: A New Era for On-Device Intelligence
Sep 28, 2026
- AI & ML◈
The 7% Problem: Why Companies Are Bleeding Money on AI While Ignoring Their People
The 7% Problem: Why Companies Are Bleeding Money on AI While Ignoring Their People
Sep 27, 2026