DEV Community

Cover image for How I Built a .NET 8 Clean Architecture SaaS Boilerplate (And What I Learned)
Hitesh Prajapati
Hitesh Prajapati

Posted on

How I Built a .NET 8 Clean Architecture SaaS Boilerplate (And What I Learned)

Every .NET developer eventually faces the same painful moment: you're starting a new SaaS project and you spend the first 3–4 weeks doing absolutely nothing unique.

Authentication. Multi-tenancy. CQRS setup. Repository patterns. JWT. Stripe. Role-based permissions. Logging. Error handling middleware.

It's the same scaffolding, over and over again.

After going through this cycle one too many times, I decided to build a reusable .NET 8 Clean Architecture SaaS Boilerplate — and in this article, I want to walk you through exactly how it's structured, the key decisions I made, and what you can learn from it even if you don't use the boilerplate itself.


Why Clean Architecture for SaaS?

Clean Architecture (popularised by Robert C. Martin) enforces a strict separation of concerns by organising code into concentric layers — each layer only depending on the layer inside it.

For a SaaS product, this matters enormously:

  • Business logic stays pure — your core domain doesn't care if you're using SQL Server or PostgreSQL, REST or gRPC
  • Testability is built-in — you can unit test use cases without touching infrastructure
  • Maintainability at scale — adding a new feature doesn't mean untangling spaghetti

Here's the layer structure I used:

📁 src/
  📁 Domain/          ← Entities, value objects, domain events
  📁 Application/     ← Use cases, CQRS commands/queries, interfaces
  📁 Infrastructure/  ← EF Core, JWT, email, external services
  📁 WebAPI/          ← Controllers, middleware, DI setup
Enter fullscreen mode Exit fullscreen mode

The golden rule: dependencies always point inward. Infrastructure depends on Application. Application depends on Domain. Domain depends on nothing.


CQRS with MediatR

One of the most impactful patterns in the boilerplate is CQRS (Command Query Responsibility Segregation) implemented via MediatR.

Instead of fat service classes, every operation is a self-contained handler:

// Command
public record CreateTenantCommand(string Name, string AdminEmail) : IRequest<Guid>;

// Handler
public class CreateTenantCommandHandler : IRequestHandler<CreateTenantCommand, Guid>
{
    private readonly IApplicationDbContext _context;

    public CreateTenantCommandHandler(IApplicationDbContext context)
        => _context = context;

    public async Task<Guid> Handle(CreateTenantCommand request, CancellationToken ct)
    {
        var tenant = Tenant.Create(request.Name, request.AdminEmail);
        _context.Tenants.Add(tenant);
        await _context.SaveChangesAsync(ct);
        return tenant.Id;
    }
}
Enter fullscreen mode Exit fullscreen mode

This gives you:

✅ Single Responsibility — each handler does exactly one thing

✅ Easy to test — just inject a mock IApplicationDbContext

✅ Pipeline behaviours for cross-cutting concerns (logging, validation, caching)


Multi-Tenancy Architecture

Multi-tenancy is the heart of any SaaS product and usually the most complex part to get right. The boilerplate supports a shared database with tenant isolation using a TenantId column on every tenant-scoped entity.

The tenant is resolved from the JWT token on every request:

public class TenantService : ITenantService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public TenantService(IHttpContextAccessor accessor)
        => _httpContextAccessor = accessor;

    public Guid GetCurrentTenantId()
    {
        var claim = _httpContextAccessor.HttpContext?
            .User.FindFirst("tenant_id");

        return Guid.Parse(claim?.Value 
            ?? throw new UnauthorizedAccessException());
    }
}
Enter fullscreen mode Exit fullscreen mode

And the ApplicationDbContext automatically filters by tenant using EF Core Global Query Filters:

protected override void OnModelCreating(ModelBuilder builder)
{
    foreach (var entityType in builder.Model.GetEntityTypes())
    {
        if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
        {
            builder.Entity(entityType.ClrType)
                .HasQueryFilter(BuildTenantFilter(entityType.ClrType));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Every DbSet query is now automatically scoped to the current tenant. No risk of data leaking between customers.


Authentication & JWT

The boilerplate uses ASP.NET Core Identity + JWT Bearer tokens for stateless authentication — perfect for API-first SaaS apps.

A few things I did differently from the typical tutorial approach:

1. Refresh tokens stored in the database

Access tokens are short-lived (15 minutes). Refresh tokens are stored in the UserTokens table and rotated on every use, protecting against token theft.

2. Claims-based permissions (not just roles)

Instead of checking [Authorize(Roles = "Admin")], permissions are granular claims:

[Authorize(Policy = "CanManageUsers")]
[HttpDelete("users/{id}")]
public async Task<IActionResult> DeleteUser(Guid id) { ... }
Enter fullscreen mode Exit fullscreen mode

This lets you give a user specific permissions without promoting them to a full admin role — critical in multi-tenant SaaS where tenant admins manage their own users.

3. Tenant claims in the token

{
  "sub": "user-guid",
  "tenant_id": "tenant-guid",
  "permissions": ["manage_users", "view_billing"],
  "exp": 1735689600
}
Enter fullscreen mode Exit fullscreen mode

FluentValidation Pipeline Behaviour

One of my favourite parts of the boilerplate is the automatic validation pipeline. Any command or query with a corresponding AbstractValidator<T> is validated before it reaches the handler:

public class CreateTenantCommandValidator : AbstractValidator<CreateTenantCommand>
{
    public CreateTenantCommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(100);

        RuleFor(x => x.AdminEmail)
            .NotEmpty()
            .EmailAddress();
    }
}
Enter fullscreen mode Exit fullscreen mode

The MediatR pipeline behaviour catches validation failures and returns a structured 400 Bad Request automatically — no manual validation code in controllers.


Global Exception Handling

Nothing breaks SaaS apps faster than unhandled exceptions leaking stack traces to users (or worse, to the browser console).

The boilerplate includes a middleware that maps domain exceptions to HTTP responses:

app.UseExceptionHandler(exceptionHandlerApp =>
{
    exceptionHandlerApp.Run(async context =>
    {
        var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;

        var (statusCode, message) = exception switch
        {
            NotFoundException => (404, exception.Message),
            UnauthorizedAccessException => (401, "Unauthorized"),
            ValidationException ve => (400, ve.Message),
            _ => (500, "An unexpected error occurred")
        };

        context.Response.StatusCode = statusCode;
        await context.Response.WriteAsJsonAsync(new { error = message });
    });
});
Enter fullscreen mode Exit fullscreen mode

What's Included in the Boilerplate

To summarise everything that's pre-built and ready to go:

Feature Detail
Clean Architecture layers Domain, Application, Infrastructure, WebAPI
CQRS + MediatR Commands, queries, pipeline behaviours
Multi-tenancy Shared DB with EF Core global query filters
Authentication ASP.NET Identity + JWT + refresh tokens
Authorization Claims-based permission policies
Validation FluentValidation via MediatR pipeline
Error handling Global exception middleware
Logging Structured logging with Serilog
API documentation Swagger/OpenAPI with JWT support
Unit test project xUnit + Moq setup included

How Much Time Does This Save?

Based on my own experience (and feedback from developers who've used it), setting all of this up from scratch typically takes 3–6 weeks for a solo developer:

  • Clean Architecture setup & conventions: ~3 days
  • Multi-tenancy implementation: ~5 days
  • Auth with JWT + refresh tokens + permissions: ~5 days
  • CQRS + validation pipeline: ~2 days
  • Error handling, logging, Swagger: ~2 days
  • Testing setup: ~2 days

The boilerplate brings that down to a single afternoon of configuration — updating connection strings, setting your JWT secret, and deploying.


Who Is This For?

This boilerplate is best suited for:

  • .NET / C# developers building their first or second SaaS product
  • Indie hackers who want to ship fast without compromising architecture
  • Small teams who want an opinionated, well-structured starting point
  • Developers learning Clean Architecture through a real, complete codebase

It is not a tutorial codebase — it's a production-ready starting point. Every pattern used is one you'd find in enterprise .NET projects.


Get the Boilerplate

The full .NET 8 Clean Architecture SaaS Boilerplate is available here:

👉 Download on Gumroad

It includes the complete source code, a detailed README with setup instructions, and all the patterns described in this article.


Questions?

Drop a comment below — happy to discuss any of the architectural decisions, why I chose one approach over another, or how to adapt the boilerplate for specific use cases.

If you found this useful, a ❤️ or 🦄 means a lot. And if you know another .NET developer who's about to spend 4 weeks reinventing the same wheel — share this with them!


Built with .NET 8 · ASP.NET Core · Entity Framework Core · MediatR · FluentValidation · Serilog

Top comments (0)