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_idclaim 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
Hostheader.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_idclaim 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, IClockAuth/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 → AuthEndpoints → AuthService.LoginAsync → IUserRepository (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.
| Decision | We chose | We rejected | Why |
|---|---|---|---|
| Tenancy model | Shared DB + TenantId + RLS | Schema/DB-per-tenant | One migration set, scales to thousands of tenants, no per-tenant provisioning. |
| Mediator | No MediatR — thin services | MediatR pipeline | MediatR is now commercially licensed; direct calls keep the AOT path clean. |
| Identity | Identity primitives behind ports | Full SignInManager / UI | Full Identity's global email-uniqueness fights multi-tenancy; borrow the proven bits. |
| Primary keys | UUIDv7 | UUIDv4 / auto-increment | Time-ordered → index locality; non-guessable; safe to expose. |
| Auth surfaces | Cookie + JWT, one identity | One or the other | httpOnly cookie = no token in JS for the SPA; JWT stays for mobile/server. |
| Tenant scope post-auth | The claim | The host/URL | A session for tenant A is inert against tenant B's domain even if replayed. |
6 · Hands-on exercise
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.
git checkout v0-walking-skeleton.- Look at
MeResponseinAuthContracts.csandGET /api/meinMeEndpoints.cs. - Predict first: which layers must change to expose
LastLoginAt, and which must not? Write it down before coding. - Add a
LastLoginAtfield toMeResponse(Application) and populate it in the/api/mehandler (Api). - Build and run; hit
/api/meafter 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
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.
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.
The host picks the branding. The moment a request is authenticated, scope comes from the tenant_id claim.
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: