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.

Clean Architecture for Modern SaaS Applications: A Practical Guide

Is your company ready for AI? Download our free checklist →

Download checklist

Introduction

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:

  1. Entities: Enterprise-wide business rules
  2. Use Cases: Application-specific business rules
  3. Interface Adapters: Controllers, presenters, gateways
  4. 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 checklist

Entities

// 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

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 checklist

Related posts