Skip to main content

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

JobSchedulePurpose
DynamicGroupReEvaluationOn membership change + every 1hRe-evaluate groups whose member set may have changed
StaleInviteCleanupDaily 02:00 UTCExpire invitations older than 30 days
TenantTrialExpiryDaily 03:00 UTCSuspend tenants whose trial period ended
FeatureFlagSyncOn billing event + every 15 minReconcile feature flags with plan entitlements
OutboxRelayContinuous (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.