Overview
TeamFlow is a multi-tenant SaaS platform that allows organisations to manage tasks, members, and roles in isolated workspaces. Each organisation operates independently — users, tasks, and data are scoped to their organisation and never leak across tenants.
This write-up covers the core architectural decisions, the data model, authentication flow, and deployment strategy.
Architecture Overview
TeamFlow follows Clean Architecture — a layered design where dependencies point inward, keeping the domain model free from infrastructure concerns.
┌─────────────────────────────────────┐
│ API Layer │ Controllers, Middleware, Program.cs
├─────────────────────────────────────┤
│ Application Layer │ Interfaces, DTOs, Validators, Services
├─────────────────────────────────────┤
│ Infrastructure Layer │ Repositories, DbContext, External Services
├─────────────────────────────────────┤
│ Domain Layer │ Entities, Enums, Exceptions (no dependencies)
└─────────────────────────────────────┘
Why Clean Architecture?
The main benefit is testability and replaceability. The domain layer has zero dependencies on databases or frameworks. If we swap SQL Server for PostgreSQL tomorrow, only the Infrastructure layer changes — nothing else breaks.
Multi-Tenancy Model
TeamFlow uses database-per-schema tenancy via row-level isolation — all tenants share one Azure SQL database, but every query is automatically scoped to an OrganizationId.
Why shared database?
For a startup-phase SaaS on Azure Student credits, provisioning a separate database per tenant is cost-prohibitive. Row-level isolation gives us:
- Lower infrastructure cost
- Simpler deployment
- Easier migrations (one schema to update)
The trade-off is that a bug could theoretically leak data between tenants — which we mitigate by enforcing OrganizationId filtering at the repository layer, not the controller layer.
How isolation is enforced
Every repository filters by OrganizationId by default:
public async Task<List<TaskItem>> GetByOrganizationAsync(Guid orgId)
{
return await _context.TaskItems
.Where(t => t.OrganizationId == orgId && !t.IsDeleted)
.AsNoTracking()
.ToListAsync();
}
The OrganizationId is extracted from the JWT token on every request — users cannot pass an arbitrary OrganizationId to access other tenants' data.
Data Model
Core Entities
Organizations
└── OrgUsers (many-to-many: Users ↔ Organizations, with Role)
└── TaskItems
└── InviteTokens
Users
└── OrgUsers
└── TaskItems (created by, assigned to)
Key design decisions
Soft deletes everywhere — no record is ever hard deleted. Every entity has an IsDeleted flag. This gives us an audit trail and makes data recovery possible.
GUIDs as primary keys — avoids sequential ID enumeration attacks (a user cannot guess organizationId=2 to access another tenant).
OrgUser join table with Role — a user can belong to multiple organisations with different roles in each. This is more flexible than storing role on the User entity directly.
Authentication Flow
TeamFlow uses JWT Bearer tokens with custom claims — no external identity provider.
1. User POSTs email + password to /api/auth/login
2. Server verifies password hash (BCrypt)
3. Server generates JWT with claims: userId, email, organizationId, role
4. Client stores JWT in memory / localStorage
5. Every subsequent request attaches JWT as Authorization: Bearer <token>
6. Middleware extracts and validates claims on every request
JWT Claims
{
"userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "admin@acme.com",
"organizationId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"role": "Admin",
"iss": "TeamFlow",
"aud": "TeamFlow",
"exp": 1712345678
}
Role-Based Access Control
Three roles exist: Admin, Member, and (future) Viewer.
Authorization is enforced via custom IAuthorizationHandler classes:
public class AdminRoleHandler : AuthorizationHandler<AdminRoleRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
AdminRoleRequirement requirement)
{
var role = context.User.FindFirst("role")?.Value;
if (role == "Admin") context.Succeed(requirement);
return Task.CompletedTask;
}
}
API Design
All endpoints return a consistent ApiResponse<T> wrapper:
{
"success": true,
"message": "Organization registered successfully.",
"data": {
"userId": "...",
"email": "admin@acme.com",
"organizationId": "...",
"token": "eyJ..."
},
"errors": null
}
This makes frontend error handling predictable — every response has the same shape regardless of success or failure.
Key Endpoints
| Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | POST | /api/auth/register-organization | Public | Create org + admin user | | POST | /api/auth/login | Public | Login, returns JWT | | GET | /api/tasks | Member/Admin | Get org tasks | | POST | /api/tasks | Member/Admin | Create task | | PUT | /api/tasks/ | Member/Admin | Update task | | POST | /api/invite | Admin | Invite member | | GET | /api/health | Public | Health check |
Caching Strategy
TeamFlow uses in-memory distributed caching (with Redis as a drop-in upgrade path).
Cached resources:
- Organisation details (30 min TTL)
- User profile (15 min TTL)
- Task lists (10 min TTL)
Cache is invalidated on write operations. The ICacheService abstraction means swapping from in-memory to Redis requires only a config change — no code changes.
Infrastructure & Deployment
┌─────────────────┐ HTTPS ┌──────────────────────┐
│ Next.js │ ─────────────► │ .NET 8 API │
│ (Vercel) │ │ (Azure App Service) │
└─────────────────┘ └──────────┬───────────┘
│
┌──────────▼───────────┐
│ Azure SQL Database │
│ (Serverless, Free) │
└──────────────────────┘
Azure App Service — chosen for its built-in scaling, managed TLS, and easy GitHub Actions integration.
Azure SQL Serverless — auto-pauses when idle (cost-saving on student credits), auto-resumes on first request.
Vercel — zero-config Next.js deployment with automatic preview deployments per PR.
CI/CD
GitHub Actions deploys the .NET backend to Azure on every push to main:
- name: Build and Deploy to Azure
uses: azure/webapps-deploy@v2
with:
app-name: mutisaasapp
publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }}
package: ./publish
Lessons Learned
1. Environment variables are everything in cloud deployments. The biggest deployment issue was missing Azure App Service config — the app ran silently with no database connection until env vars were set correctly.
2. Serverless SQL auto-pause can cause slow cold starts. Azure SQL Serverless pauses after inactivity. The first request after a pause can take 20–30 seconds. For production, a minimum capacity setting or a warm-up ping is recommended.
3. CORS must be configured precisely. Hardcoding allowed origins works fine for known frontends but requires a code change for new domains. Moving allowed origins to config would be a better long-term approach.
4. Clean Architecture pays off immediately. When requirements changed mid-build (e.g. adding caching), only the Infrastructure layer needed updates. Controllers and services were untouched.
What's Next
- [ ] Add SignalR for real-time task updates
- [ ] Upgrade to Redis for distributed caching
- [ ] Add file attachment support (Azure Blob Storage)
- [ ] Implement refresh tokens
- [ ] Add organisation-level audit logs