Every SaaS product needs the same boring infrastructure — authentication, billing, multi-tenancy, user management. I've built these from scratch multiple times and finally decided to package it all into a reusable starter kit.
Here's how I architected it and the decisions I made along the way.
The Problem
You have a great SaaS idea. You start coding. Then you spend weeks on:
- JWT authentication with refresh token rotation
- Multi-tenant data isolation
- Stripe subscription billing
- Role-based authorization
- Rate limiting
- Email service
By the time you're done with infrastructure, you've lost momentum on your actual product.
The Architecture
I went with Clean Architecture — four layers with strict dependency rules:
API → Infrastructure → Application → Domain
Domain — Entities, enums, exceptions. Zero external dependencies.
Application — CQRS commands/queries, validators, interfaces. Defines WHAT the app does.
Infrastructure — EF Core, Stripe, email, JWT. Implements HOW it's done.
API — Controllers, middleware, configuration. The entry point.
Why Clean Architecture? Because the buyer of this boilerplate needs to swap things out. Don't like PostgreSQL? Switch to SQL Server with one config change. Don't want Stripe? Implement IPaymentService with your preferred provider. The infrastructure is pluggable.
Multi-Tenancy: Three Resolution Strategies
This was the most interesting part to design. Tenants can be resolved through:
1. JWT Claim — After login, the tenant_id is embedded in the access token. Every request automatically knows which tenant it belongs to.
2. HTTP Header — API clients send X-Tenant-Id header. Useful for server-to-server communication.
3. Subdomain — acme.yourapp.com resolves to the "acme" tenant. Great for customer-facing apps.
The resolution happens in middleware, before any business logic runs:
public async Task InvokeAsync(HttpContext context,
ICurrentTenantService tenantService,
ApplicationDbContext dbContext)
{
// Try JWT claim first
var tenantIdClaim = context.User.FindFirstValue("tenant_id");
if (Guid.TryParse(tenantIdClaim, out var tenantId))
{
var tenant = await dbContext.Tenants
.FirstOrDefaultAsync(t => t.Id == tenantId);
if (tenant != null)
{
tenantService.SetTenant(tenant.Id, tenant.Slug);
await _next(context);
return;
}
}
// Fall back to header, then subdomain...
}
The ICurrentTenantService is a scoped service — it lives for the duration of the request. Once the tenant is set, every downstream service automatically knows which tenant is active.
Authentication: JWT + Refresh Token Rotation
Standard JWT with a twist — refresh token rotation. When a client uses a refresh token to get a new access token, the old refresh token is revoked and a new one is issued.
Why? If someone steals a refresh token and the legitimate user also tries to refresh, the system detects the reuse and can invalidate all tokens for that user.
Access Token: 15 minutes (short-lived)
Refresh Token: 7 days (stored in DB, rotated on use)
The auth flow:
-
POST /api/auth/register— Creates user + tenant + free trial subscription -
POST /api/auth/login— Returns access + refresh tokens -
POST /api/auth/refresh— Rotates tokens -
POST /api/auth/forgot-password— Sends reset email -
GET /api/auth/me— Returns current user profile
CQRS with MediatR
Every action is a small, focused class:
// Command
public record LoginCommand(string Email, string Password)
: IRequest<Result<TokenResponse>>;
// Handler
public class LoginCommandHandler
: IRequestHandler<LoginCommand, Result<TokenResponse>>
{
public async Task<Result<TokenResponse>> Handle(
LoginCommand request, CancellationToken cancellationToken)
{
// validate credentials, generate tokens, return result
}
}
// Validator (runs automatically via pipeline)
public class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Password).NotEmpty();
}
}
The ValidationBehaviour pipeline intercepts every command/query and runs the matching validator before the handler executes. No manual validation code in handlers.
Stripe Integration
Three subscription plans are seeded by default:
| Plan | Price | Max Users | API Calls |
|---|---|---|---|
| Free | $0 | 2 | 1,000/mo |
| Pro | $29/mo | 25 | 50,000/mo |
| Enterprise | $99/mo | Unlimited | Unlimited |
The billing flow:
- Tenant admin hits
POST /api/subscriptions/checkout - Backend creates a Stripe Checkout Session
- User completes payment on Stripe's hosted page
- Stripe sends webhook → backend updates subscription status
Webhook handling covers all the important events:
-
checkout.session.completed— Activate subscription -
invoice.paid— Renew subscription -
invoice.payment_failed— Mark as past due -
customer.subscription.deleted— Expire subscription
The Audit Trail
Every entity that extends BaseEntity automatically gets:
public abstract class BaseEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ModifiedAt { get; set; }
public string? CreatedBy { get; set; }
public string? ModifiedBy { get; set; }
}
An EF Core interceptor automatically sets these fields on save — no manual code needed in handlers.
Entities implementing ISoftDeletable are never actually deleted. DELETE operations are converted to updates with IsDeleted = true, and a global query filter excludes them from all reads.
What I'd Do Differently
1. Event-driven architecture — Right now, the welcome email is sent directly from the registration handler. In a production system, I'd publish a UserRegisteredEvent and handle the email in a separate consumer.
2. Integration tests — The current test suite covers domain logic and validation. I'd add integration tests with WebApplicationFactory and a test database.
3. API versioning — Not included yet, but important for any SaaS that will evolve over time.
Tech Stack
- .NET 10 / ASP.NET Core
- Entity Framework Core (PostgreSQL + SQL Server)
- MediatR (CQRS)
- FluentValidation
- Stripe.net
- MailKit (SMTP)
- Serilog
- Docker + docker-compose
- Swagger/OpenAPI
Try It Out
I packaged this into a starter kit that you can use to build your own SaaS product. Clone it, edit appsettings.json, run dotnet run, and start building your actual features instead of infrastructure.
$49 one-time purchase, unlimited projects, full source code.
What's your approach to multi-tenancy? I'd love to hear how others handle tenant isolation, especially at scale. Drop a comment below.
Top comments (0)