Application Logic
:::info Source
Sourced from services/tenant-service/APPLICATION_LOGIC.md in the documentation repo.
:::
Blueprint doc 3 of 17. Companion: DOMAIN_MODEL | API_CONTRACTS | EVENT_SCHEMAS
1. Architecture Layers
┌────────────────────────── Presentation ──────────────────────────────┐
│ NestJS Controllers (REST) · NATS Reply Handlers · SSE (future) │
└──────────────────────────┬───────────────────────────────────────────┘
│ DTOs + Zod validation
┌──────────────────────────▼───────────────────────────────────────────┐
│ Application (Use Cases) │
│ Command Handlers · Query Handlers · Saga Orchestrators · Mappers │
└──────────────────────────┬───────────────────────────────────────────┘
│ Ports (interfaces)
┌──────────────────────────▼───────────────────────────────────────────┐
│ Domain (pure TypeScript) │
│ Aggregates · Entities · Value Objects · Domain Events · Services │
└──────────────────────────┬───────────────────────────────────────────┘
│ Port implementations
┌──────────────────────────▼───────────────────────────────────────────┐
│ Infrastructure │
│ PostgresRepos · NatsPublisher · RedisCache · IdentityClient │
│ BillingClient · PolicyEngine (in-memory) · Outbox · InboxHandler │
└──────────────────────────────────────────────────────────────────────┘
Dependency rule: outer layers depend inward only. Domain has zero framework imports.
2. Ports (Interfaces)
2.1 Outbound Ports
// persistence
interface TenantRepository {
findById(id: TenantId): Promise<Tenant | null>;
findBySlug(slug: string): Promise<Tenant | null>;
save(tenant: Tenant): Promise<void>;
existsBySlug(slug: string): Promise<boolean>;
}
interface OrgUnitRepository {
findById(id: OrgUnitId, tenantId: TenantId): Promise<OrgUnit | null>;
findByTenant(tenantId: TenantId): Promise<OrgUnit[]>;
findSubtree(parentPath: string, tenantId: TenantId): Promise<OrgUnit[]>;
save(orgUnit: OrgUnit): Promise<void>;
delete(id: OrgUnitId, tenantId: TenantId): Promise<void>;
}
interface MembershipRepository {
findById(id: ULID, tenantId: TenantId): Promise<Membership | null>;
findByUser(userId: UserId, tenantId: TenantId): Promise<Membership | null>;
findByTenant(tenantId: TenantId, page: CursorPage): Promise<Paginated<Membership>>;
findByOrgUnit(orgUnitId: OrgUnitId, tenantId: TenantId): Promise<Membership[]>;
save(membership: Membership): Promise<void>;
countByRole(roleId: RoleId, tenantId: TenantId): Promise<number>;
}
interface RoleRepository {
findById(id: RoleId): Promise<Role | null>;
findByTenant(tenantId: TenantId): Promise<Role[]>;
findSystemRoles(): Promise<Role[]>;
save(role: Role): Promise<void>;
delete(id: RoleId): Promise<void>;
}
interface DynamicGroupRepository {
findById(id: ULID, tenantId: TenantId): Promise<DynamicGroup | null>;
findByTenant(tenantId: TenantId): Promise<DynamicGroup[]>;
save(group: DynamicGroup): Promise<void>;
delete(id: ULID, tenantId: TenantId): Promise<void>;
}
interface FeatureFlagRepository {
findByTenant(tenantId: TenantId): Promise<FeatureFlagOverride[]>;
findByFlag(tenantId: TenantId, flag: string): Promise<FeatureFlagOverride | null>;
save(override: FeatureFlagOverride): Promise<void>;
delete(tenantId: TenantId, flag: string): Promise<void>;
}
// messaging
interface EventPublisher {
publish(event: DomainEvent): Promise<void>;
publishBatch(events: DomainEvent[]): Promise<void>;
}
// external services
interface IdentityClient {
resolveUserByEmail(email: Email): Promise<{ userId: UserId; status: string } | null>;
}
interface BillingClient {
getPlanEntitlements(planId: string): Promise<PlanEntitlements>;
}
// authorization
interface PolicyEngine {
evaluate(request: AuthzRequest): AuthzDecision;
}
// caching
interface TenantCache {
getTenant(id: TenantId): Promise<CachedTenant | null>;
setTenant(tenant: CachedTenant, ttl: number): Promise<void>;
invalidate(id: TenantId): Promise<void>;
getAuthzDecision(key: string): Promise<AuthzDecision | null>;
setAuthzDecision(key: string, decision: AuthzDecision, ttl: number): Promise<void>;
}
2.2 Inbound Ports
// command ports (use cases)
interface ProvisionTenantPort {
execute(cmd: ProvisionTenantCommand): Promise<TenantId>;
}
interface InviteUserPort {
execute(cmd: InviteUserCommand): Promise<ULID>;
}
interface CheckAuthorizationPort {
execute(cmd: AuthzCheckCommand): Promise<AuthzDecision>;
}
// ... (one per use case)
3. Use Cases (Command Handlers)
3.1 ProvisionTenant
Input: { name, slug, type, homeRegion, plan, ownerEmail, settings? }
Steps:
1. Validate slug uniqueness via TenantRepository.existsBySlug()
2. Create Tenant aggregate (status = 'trial' if no plan, 'active' if plan)
3. Create root OrgUnit (name = tenant name, ltreePath = slug)
4. Create owner Membership (status = 'invited', roleIds = [org_owner])
5. Persist Tenant + OrgUnit + Membership in single transaction (outbox)
6. Emit TenantProvisioned + OrgUnitCreated + UserInvited
Output: TenantId
Errors: SLUG_TAKEN, INVALID_REGION, INVALID_PLAN
3.2 UpdateTenantSettings
Input: { tenantId, patch: Partial<TenantSettings> }
Steps:
1. Load Tenant
2. Validate patch against TenantSettings schema
3. Apply immutable merge (new settings object)
4. Persist + emit TenantSettingsUpdated
Output: void
Errors: TENANT_NOT_FOUND, VALIDATION_ERROR
Auth: org_owner, org_admin
3.3 SuspendTenant
Input: { tenantId, reason }
Steps:
1. Load Tenant
2. Validate status allows transition (active → suspended)
3. Apply status change
4. Persist + emit TenantSuspended
Output: void
Errors: TENANT_NOT_FOUND, INVALID_STATUS_TRANSITION
Auth: platform_admin
3.4 InviteUser
Input: { tenantId, email, roleIds, orgUnitIds }
Steps:
1. Validate roleIds exist and are assignable
2. Validate orgUnitIds exist within tenant
3. Check no active membership exists for this email in this tenant
4. Resolve userId via IdentityClient (may be null if user not yet registered)
5. Create Membership (status = 'invited')
6. Persist + emit UserInvited
7. (notification-service picks up event and sends invite email)
Output: MembershipId
Errors: ROLE_NOT_FOUND, ORG_UNIT_NOT_FOUND, ALREADY_MEMBER
Auth: org_owner, org_admin
3.5 AcceptInvite
Input: { tenantId, userId }
Steps:
1. Load Membership by (tenantId, userId, status='invited')
2. Transition status to 'active', set joinedAt
3. Persist + emit MembershipActivated
Output: void
Errors: INVITE_NOT_FOUND, ALREADY_ACTIVE
3.6 AssignRoles
Input: { tenantId, userId, roleIds }
Steps:
1. Load Membership
2. Validate all roleIds exist
3. Validate system-role constraints (e.g., cannot assign platform_admin at tenant level)
4. Update roleIds (immutable replacement)
5. Persist + emit RoleAssigned
Output: void
Auth: org_owner, org_admin
3.7 CreateOrgUnit
Input: { tenantId, parentId?, name: I18nString }
Steps:
1. If parentId, load parent and validate depth < maxOrgDepth
2. Compute ltreePath from parent chain
3. Create OrgUnit aggregate
4. Persist + emit OrgUnitCreated
Output: OrgUnitId
Errors: PARENT_NOT_FOUND, MAX_DEPTH_EXCEEDED
Auth: org_owner, org_admin
3.8 MoveOrgUnit
Input: { tenantId, orgUnitId, newParentId }
Steps:
1. Load OrgUnit and new parent
2. Validate no cycles (new parent is not a descendant of orgUnit)
3. Validate new depth within limits
4. Update parentId and recompute ltreePath for entire subtree
5. Persist + emit OrgUnitMoved
Output: void
Errors: CYCLE_DETECTED, MAX_DEPTH_EXCEEDED
Auth: org_owner, org_admin
3.9 DefineRole
Input: { tenantId, name, permissions: Permission[] }
Steps:
1. Validate permissions against resource/action registry
2. Validate ABAC conditions against DSL schema
3. Create Role aggregate (isSystem = false)
4. Persist + emit RoleCreated
Output: RoleId
Auth: org_owner
3.10 CheckAuthorization (Policy Decision Point)
Input: { tenantId, userId, resource, action, resourceAttributes? }
Steps:
1. Check authz cache → return if hit
2. Load Membership (with roles and permissions)
3. Build evaluation context (user attributes, resource attributes, time)
4. PolicyEngine.evaluate() against all role permissions
5. Cache decision (TTL 60s for allow, 30s for deny)
6. Return { allowed: boolean, decisionId, matchedRoles, matchedPermissions }
Output: AuthzDecision
Latency target: ≤ 5ms cached, ≤ 20ms uncached
3.11 DefineDynamicGroup
Input: { tenantId, name, query: ABACQuery }
Steps:
1. Validate query against ABAC DSL schema
2. Create DynamicGroup aggregate
3. Persist
Output: GroupId
Auth: org_owner, org_admin
3.12 EvaluateDynamicGroup
Input: { tenantId, groupId }
Steps:
1. Load DynamicGroup
2. Load all active memberships for tenant (paginated internally)
3. Evaluate query against each membership's attributes
4. Update lastEvaluatedAt
5. Persist + emit DynamicGroupEvaluated (with memberIds)
Output: { memberCount, memberIds }
Perf: Paginated evaluation; results cached 5 min; invalidated on membership changes
Auth: org_owner, org_admin
3.13 ChangeDataResidency (Saga — M5)
Input: { tenantId, targetRegion }
Steps: (orchestrated saga, see MIGRATION_PLAN.md)
1. Validate tenant status is active
2. Suspend new writes for tenant (maintenance mode)
3. Snapshot tenant data in source region
4. Copy data to target region
5. Validate data integrity (row counts, checksums)
6. Update homeRegion on Tenant aggregate
7. Update DNS / routing for tenant
8. Resume writes
9. Emit DataResidencyChanged
Compensation: rollback at any step returns to source region
Auth: platform_admin
4. Use Cases (Query Handlers)
4.1 GetTenant
Input: { tenantId }
Output: TenantDTO (id, name, slug, type, status, homeRegion, plan, settings)
Cache: Redis, TTL 5 min, invalidated on any tenant mutation
Auth: Any authenticated member of the tenant
4.2 ListMyTenants
Input: { userId }
Output: TenantSummaryDTO[] (tenants where user has active membership)
Route: GET /api/v1/me/tenants
Auth: Authenticated user (no tenant context required)
4.3 ListMemberships
Input: { tenantId, cursor, filters: { status?, orgUnitId?, roleId? } }
Output: Paginated<MembershipDTO>
Auth: org_owner, org_admin, org_manager (scoped to own org-unit subtree)
4.4 ListOrgUnits
Input: { tenantId }
Output: OrgUnitTreeDTO (hierarchical)
Cache: Redis, TTL 5 min
Auth: Any member of the tenant
5. Event Consumers
5.1 identity.user.registered.v1
Trigger: New user registers in identity-service
Steps:
1. Query Memberships with status='invited' matching userId or email
2. For each match, transition to 'active' status
3. Emit MembershipActivated for each
Idempotency: Inbox table dedup by eventId
5.2 billing.subscription.changed.v1
Trigger: Billing plan changes (upgrade, downgrade, cancel)
Steps:
1. Load Tenant by tenantId from event
2. Update plan reference (planId, addons)
3. If plan canceled and no grace: suspend tenant
4. Invalidate feature-flag cache (entitlements may change)
5. Emit TenantSettingsUpdated
Idempotency: Inbox table dedup by eventId
5.3 gdpr.subject_request.received.v1
Trigger: GDPR erasure or export request
Steps:
1. Identify all memberships for the subject userId
2. For erasure: anonymize membership data, remove PII
3. For export: compile tenant-service data into export payload
4. Emit gdpr.subject_request.completed.v1 (partial, this service's slice)
Idempotency: Inbox table dedup by eventId
6. NATS Request/Reply Handlers
6.1 tenant.resolve_for_request
Input: { tenantId }
Output: { id, slug, status, homeRegion, plan, settings.offlineEnabled, settings.aiEnabled }
SLA: ≤ 5ms (served from Redis cache; fallback to DB)
Used by: API gateway on every request
6.2 tenant.membership.resolve
Input: { tenantId, userId }
Output: { membershipId, roleIds, orgUnitIds, status }
SLA: ≤ 10ms (Redis cache; fallback to DB)
Used by: Other services for local authz enrichment
7. Background Jobs
| Job | Schedule | Purpose |
|---|---|---|
DynamicGroupReEvaluation | On membership change + every 1h | Re-evaluate groups whose member set may have changed |
StaleInviteCleanup | Daily 02:00 UTC | Expire invitations older than 30 days |
TenantTrialExpiry | Daily 03:00 UTC | Suspend tenants whose trial period ended |
FeatureFlagSync | On billing event + every 15 min | Reconcile feature flags with plan entitlements |
OutboxRelay | Continuous (polling 100ms) | Publish unpublished outbox events to NATS |
8. Cross-Cutting Concerns
8.1 Idempotency
Every write endpoint requires Idempotency-Key header. Keys are stored in Redis with TTL 24h, scoped to (tenant, user, route, key).
8.2 Optimistic Concurrency
PATCH endpoints require If-Match header with entity version. Version mismatch returns 409 Conflict.
8.3 Audit Trail
Every mutation emits a domain event that doubles as an audit entry. Events are immutable in NATS JetStream with operational retention (30 days hot, 13 months cold).
8.4 Tenant Context Propagation
The RequestContext middleware extracts tenantId from JWT tid claim, validates it against X-Tenant-Id header, sets app.tenant_id on the Postgres connection, and propagates via OTel baggage.