Advance Tech / Training / SaaS Backbone / Module 00 (free)
★ Free sample · no signup

Module 00 — The Walking Skeleton

The foundation module of Building a Production Multi-Tenant SaaS Backbone. It stands alone — read it, run it, do the exercise, and decide for yourself.

◷ ~3 hoursCode: git checkout v0-walking-skeleton⤓ MIT codebase

This is Module 00 of the course. It stands alone and the codebase is MIT-licensed and public — follow along by checking out the tags. The full course (Modules 01+) and the worked exercise solutions are available to enrollees.

1 · Learning objectives

After this module you can:

  • Explain the dependency rule and decide which layer (Domain, Application, Infrastructure, Api) a given type belongs in.
  • Describe ports & adapters: how the inner layers declare interfaces and the outer layers implement them.
  • Read a Minimal API app and trace a request from endpoint → application service → port → adapter → database.
  • Explain two-phase tenant resolution — why the host decides branding before login and the tenant_id claim decides data scope after.
  • Walk the password + email-OTP 2FA → httpOnly cookie login flow end to end.
  • Recognise an EF Core global query filter and why it's the first (app-level) layer of tenant isolation.

2 · Prerequisites

  • Assumed knowledge: C#, async/await, dependency injection, the basics of ASP.NET Core and EF Core.
  • Environment (optional, to run it): .NET 10 SDK + PostgreSQL. The exercise in this module is pure C# and needs only the SDK.

3 · Concepts & theory

Clean Architecture in one rule

“Clean architecture” is a lot of diagrams that reduce to a single, enforceable rule:

Source-code dependencies point inward. Inner layers know nothing about outer layers.

The project has four layers, from inner to outer:

Domain  ←  Application  ←  Infrastructure  ←  Api
(entities)   (use-cases,     (EF, hashing,      (HTTP, DI,
             ports)          JWT, adapters)     middleware)

Domain depends on nothing. Application depends only on Domain. Infrastructure and Api depend inward. You can verify this mechanically — open the four .csproj files and look at the ProjectReferences; the arrows only ever point left. The payoff: your business rules don't know whether the database is Postgres or the web framework is ASP.NET, so those choices can change without touching the core.

Ports & adapters (a.k.a. hexagonal)

The dependency rule creates a puzzle: Application needs to send email and hash passwords, but those live in Infrastructure (outer). It can't reference outward. The solution is dependency inversion: the inner layer declares an interface (a port) describing what it needs; the outer layer provides the implementation (an adapter). At runtime, DI hands the adapter to the inner layer through the port.

In this codebase the ports live in Application/Abstractions/Ports.cs (IUserRepository, IPasswordService, IEmailSender, ITokenService, …) and the adapters live in Infrastructure.

Multi-tenancy: one app, many customers

A multi-tenant SaaS serves many customer organisations (“tenants”) from one deployment. The model here is shared database + a TenantId column on every tenant-owned row, isolated at two layers: an EF Core query filter (this module) and Postgres Row-Level Security (Module 01). Every tenant's rows live in the same tables; the system's job is to make sure tenant A can never see tenant B's rows.

Two-phase tenant resolution

“Which tenant is this request for?” has two different answers depending on whether the user is logged in:

  • Phase A — before login (host): to render a branded login page, you only have the request's Host header. dashboard.acme.com → tenant Acme. This drives branding/routing only — it grants no data access.
  • Phase B — after login (claim): once authenticated, the tenant that scopes every query comes from the user's tenant_id claim in the cookie/JWT — never the URL. The host is then verified against the claim, never trusted in its place.

Conflating these is a classic source of both security bugs and broken branding. Keeping them separate is why you get white-label custom domains and scoping that can't be spoofed by changing the URL.

4 · Code walkthrough

Read in dependency order: inner first.

Backbone.Domain — entities, zero dependencies

Common/Entity.cs is the base type:

public abstract class Entity
{
    public Guid Id { get; protected set; } = Guid.CreateVersion7();   // UUIDv7: time-ordered
    public DateTimeOffset CreatedAt { get; protected set; } = DateTimeOffset.UtcNow;
}

Entities/AppUser.cs and Entities/Tenant.cs are the core entities. AppUser has a private parameterless constructor (for EF) and a public one that enforces invariants, properties with private set, and behaviour like MarkLogin(). This is a rich domain model, not an anaemic bag of setters. Note AppUser.TenantId — every user belongs to exactly one tenant, and email is unique per tenant (UNIQUE(tenant_id, normalized_email)), so the same address can exist in different tenants.

The Domain references nothing — open Backbone.Domain.csproj and you'll find no project reference and no packages. That's the dependency rule at its purest.

Backbone.Application — use-cases and ports

Abstractions/Ports.cs declares the interfaces the application needs from the outside world:

public interface IPasswordService { string Hash(string password); bool Verify(string hash, string password); }
public interface IEmailSender { Task SendAsync(string to, string subject, string body, CancellationToken ct = default); }
public interface ITenantContext { bool IsResolved { get; } Guid TenantId { get; } string Host { get; } void Resolve(Guid tenantId, string host); }
// … IUserRepository, ITenantRepository, ITokenService, ITwoFactorService, IClock

Auth/AuthService.cs is the use-case orchestrator for login. Read LoginAsync:

var user = await users.FindByEmailAsync(tenant.TenantId, email, ct);
if (user is null || !passwords.Verify(user.PasswordHash, password))
    return new LoginOutcome(LoginStatus.Failed);          // same failure either way — no user enumeration
if (user.TwoFactorEnabled) { await twoFactor.IssueAsync(user, ct); return new LoginOutcome(LoginStatus.TwoFactorRequired, PendingUserId: user.Id); }
user.MarkLogin();
await users.SaveAsync(ct);
return new LoginOutcome(LoginStatus.Success, user, DefaultPermissions);

Three things to notice: (1) it depends only on ports — no EF, no ASP.NET; (2) it returns an outcome object, not an HTTP response — the API layer decides what 200/401 means; (3) the generic failure (missing user vs. wrong password look identical) is a deliberate security choice.

Backbone.Infrastructure — the adapters

This is where the ports get real implementations. The key line is the global query filter on users in BackboneDbContext.cs:

e.HasQueryFilter(u => u.TenantId == CurrentTenantId);   // CurrentTenantId comes from the injected ITenantContext

Every db.Users query EF generates silently gets WHERE tenant_id = @current appended. This is tenant isolation, layer 1 — application code physically can't query across tenants by accident. PasswordService.cs wraps ASP.NET Identity's PasswordHasher (PBKDF2) — we borrow the primitive, not the whole Identity pipeline. LoggingEmailSender just logs the OTP in dev (so you can read your 2FA code in the console). DependencyInjection.cs wires every port to its adapter, with ITenantContext and repositories Scoped, the stateless services Singleton.

Backbone.Api — the HTTP edge

Program.cs composes everything: AddInfrastructure, cookie and JWT authentication, then the middleware pipeline. TenantResolutionMiddleware.cs is the heart of two-phase resolution:

var claim = ctx.User.FindFirst("tenant_id")?.Value;
if (Guid.TryParse(claim, out var claimTenantId))
    tenant.Resolve(claimTenantId, host);                 // Phase B: authenticated → the CLAIM is the authority
else {
    var t = await tenants.FindByHostAsync(host, ct);     // Phase A: anonymous → resolve by HOST (branding only)
    if (t is not null) tenant.Resolve(t.Id, host);
}
// when resolved: open a transaction and SET LOCAL app.tenant_id so the DB layer can scope too (Module 01)

The full request story: browser POSTs credentials → AuthEndpointsAuthService.LoginAsyncIUserRepository (scoped by ITenantContext) → password verify → (optional 2FA) → SignInAsync sets the cookie with the tenant_id claim → later, GET /api/me arrives with the cookie → middleware reads the claim (Phase B) → returns the tenant-scoped profile.

5 · Decisions & trade-offs

Every module ends with what we chose, what we rejected, and why. Here is Module 00's, unedited.

DecisionWe choseWe rejectedWhy
Tenancy modelShared DB + TenantId + RLSSchema/DB-per-tenantOne migration set, scales to thousands of tenants, no per-tenant provisioning.
MediatorNo MediatR — thin servicesMediatR pipelineMediatR is now commercially licensed; direct calls keep the AOT path clean.
IdentityIdentity primitives behind portsFull SignInManager / UIFull Identity's global email-uniqueness fights multi-tenancy; borrow the proven bits.
Primary keysUUIDv7UUIDv4 / auto-incrementTime-ordered → index locality; non-guessable; safe to expose.
Auth surfacesCookie + JWT, one identityOne or the otherhttpOnly cookie = no token in JS for the SPA; JWT stays for mobile/server.
Tenant scope post-authThe claimThe host/URLA session for tenant A is inert against tenant B's domain even if replayed.

6 · Hands-on exercise

⚑ Try it yourself

Goal: surface a new field through the layers without breaking the dependency rule. Signal: GET /api/me returns lastLoginAt, and you never touched the Domain project.

  1. git checkout v0-walking-skeleton.
  2. Look at MeResponse in AuthContracts.cs and GET /api/me in MeEndpoints.cs.
  3. Predict first: which layers must change to expose LastLoginAt, and which must not? Write it down before coding.
  4. Add a LastLoginAt field to MeResponse (Application) and populate it in the /api/me handler (Api).
  5. Build and run; hit /api/me after logging in and confirm the field appears.

The lesson is in step 3: Domain doesn't change — proof that the inner layer is independent of how the outer layers present it. The worked solution and a ports-&-adapters stretch goal ship with the course.

7 · Common pitfalls

Putting business rules in the endpoint.

Minimal API handlers are adapters — they translate HTTP to/from application calls. Login logic belongs in AuthService. If you're hashing a password in an endpoint, you've leaked a layer.

Referencing Infrastructure from Application.

The compiler won't stop you, but it inverts the dependency arrow and couples your use-cases to EF/ASP.NET. Need something from outside? Declare a port.

Trusting the host for data scope.

The host picks the branding. The moment a request is authenticated, scope comes from the tenant_id claim.

Anaemic entities.

Public setters everywhere turn your Domain into a DTO. Keep invariants inside the entity (private set, constructors, methods like MarkLogin()).

8 · Recap & self-check

You learned: Clean Architecture is the dependency rule made enforceable; ports/adapters let inner layers stay ignorant of frameworks; this app resolves tenants in two phases (host before login, claim after) and isolates them first with an EF query filter; and login orchestrates Identity primitives behind ports to issue an httpOnly cookie carrying the tenant_id claim.

Self-check — tap to reveal the answer:

1 · Which project may not reference any other, and how do you verify that?
The Domain project. Verify it mechanically: open Backbone.Domain.csproj — it has no <ProjectReference> and no package references. The dependency arrows only ever point inward.
2 · AuthService needs to send an email. Why does it not reference Infrastructure?
Because that would invert the dependency rule. Instead Application declares a port (IEmailSender); Infrastructure provides the adapter, and DI hands it in at runtime. Application stays ignorant of how email is actually sent.
3 · Why is data scope taken from the claim and not the host after login? What attack does that prevent?
The host is attacker-controllable and only a branding hint. Taking scope from the tenant_id claim means a session minted for tenant A is inert against tenant B even if replayed on B's domain — it prevents cross-tenant access by host/URL spoofing.
4 · The EF query filter already scopes users to the current tenant. Why is that not enough on its own?
It is one forgotten IgnoreQueryFilters() (or a raw SQL query) away from a cross-tenant leak. That is exactly why Module 01 adds Postgres Row-Level Security as a database-enforced backstop — proven with a cross-tenant isolation test.
That last self-check question is Module 01.

The query filter is one IgnoreQueryFilters() from a leak.

Module 01 adds the backstop the database itself enforces — PostgreSQL Row-Level Security — and proves it with a cross-tenant isolation test even a raw SQL query can't beat. You build the whole phase yourself, milestone by milestone.