Skip to main content

Domain Model

:::info Source Sourced from services/tenant-service/DOMAIN_MODEL.md in the documentation repo. :::

Blueprint doc 2 of 17. Companion: 02 DDD | 12 Data Models | SERVICE_OVERVIEW


1. Ubiquitous Language

TermDefinition
TenantA billable entity that owns data on the platform — an organization, a content provider, an individual learner, or a hybrid org+provider.
Org UnitA node in a tenant's hierarchical organizational structure (department, campus, team). Stored as an ltree path for efficient subtree queries.
MembershipThe relationship between a User (from identity context) and a Tenant. Carries role assignments and org-unit scoping.
RoleA named collection of permissions. System roles are platform-defined and immutable. Tenant roles are custom.
PermissionA (resource, action, condition?) triple. The optional condition is an ABAC predicate evaluated at decision time.
ABAC PredicateAn expression tree (ABACQuery) that evaluates contextual attributes (user org-units, resource ownership, time, etc.) to a boolean.
Dynamic GroupA tenant-scoped, query-defined cohort of memberships. Evaluated on demand; consumed by assignment-service for bulk targeting.
Feature Flag OverrideA per-tenant override of a platform-wide feature flag value.
PlanA reference to the tenant's billing plan (owned by billing-service). Tenant-service stores the planId + addons[] for routing entitlements.
Home RegionThe data residency region for the tenant (us, eu, me, ap). Immutable after provisioning except via the migration saga.

2. Aggregate Diagram

┌─────────────────────────────────────────────────────────────────────┐
│ Tenant (Aggregate Root) │
│ │
│ id: TenantId slug: string (unique) │
│ type: TenantType name: string │
│ homeRegion: Region plan: PlanRef │
│ status: TenantStatus settings: TenantSettings │
│ ssoProviders: SSOProviderRef[] │
│ createdAt: ISODate │
│ │
│ invariants: │
│ - slug globally unique (enforced at DB + domain) │
│ - status transitions: trial → active → suspended → closed │
│ - homeRegion immutable (changed only via migration saga) │
│ - type immutable after provisioning │
└─────────────────────────┬───────────────────────────────────────────┘
│ 1..*
┌───────────┴───────────────────┐
│ │
┌─────────────▼──────┐ ┌─────────────▼──────────┐
│ OrgUnit (AR) │ │ Membership (AR) │
│ │ │ │
│ id: OrgUnitId │ │ id: ULID │
│ tenantId │ │ tenantId │
│ parentId? │ │ userId: UserId │
│ name: I18nString │ │ roleIds: RoleId[] │
│ ltreePath: string │ │ orgUnitIds: OrgUnitId[]│
│ │ │ status: MemberStatus │
│ invariants: │ │ invitedAt?, joinedAt? │
│ - no cycles │ │ │
│ - max depth 10 │ │ invariants: │
│ - ltree = path │ │ - 1 active per │
│ from root │ │ (tenant, user) │
└────────────────────┘ │ - invited→active→ │
│ suspended │
┌─────────────────┴────────────────────────┘

┌─────────────▼──────┐ ┌────────────────────────┐
│ Role (AR) │ │ DynamicGroup (AR) │
│ │ │ │
│ id: RoleId │ │ id: ULID │
│ tenantId | null │ │ tenantId │
│ name: string │ │ name: string │
│ permissions[] │ │ query: ABACQuery │
│ isSystem: boolean │ │ lastEvaluatedAt? │
│ │ │ │
│ invariants: │ │ invariants: │
│ - system roles │ │ - query validates │
│ immutable │ │ against DSL │
│ - permissions │ │ - evaluation is │
│ validated │ │ idempotent │
│ against │ └────────────────────────┘
│ resource/ │
│ action reg │
└────────────────────┘

3. Value Objects

3.1 Branded IDs (Frozen at F03, M0 end)

type TenantId = Branded<string, 'TenantId'>; // ULID
type OrgUnitId = Branded<string, 'OrgUnitId'>; // ULID
type RoleId = Branded<string, 'RoleId'>; // ULID

3.2 Enumerations

type TenantType = 'org' | 'provider' | 'individual' | 'org+provider';
type TenantStatus = 'active' | 'trial' | 'suspended' | 'closed';
type Region = 'us' | 'eu' | 'me' | 'ap';
type MemberStatus = 'invited' | 'active' | 'suspended';

3.3 Composite Value Objects

interface PlanRef {
id: string; // billing-service plan ID
addons: string[]; // addon slugs
}

interface TenantSettings {
defaultLocale: Locale;
allowedLocales: Locale[];
brandingTheme: BrandingTheme;
passwordPolicy: PasswordPolicy;
sessionTimeout: ISODuration;
mfaRequired: boolean;
offlineEnabled: boolean;
aiEnabled: boolean;
aiTutorEnabled: boolean;
maxOrgDepth: number; // default 10, max 10
}

interface BrandingTheme {
logoUrl?: string;
primaryColor?: string; // hex
accentColor?: string;
faviconUrl?: string;
customCss?: string; // scoped, sanitized
}

interface PasswordPolicy {
minLength: number; // 8–128
requireUppercase: boolean;
requireNumber: boolean;
requireSymbol: boolean;
maxAge?: ISODuration;
}

interface Permission {
resource: string; // e.g., 'course', 'enrollment', 'tenant'
action: string; // e.g., 'read', 'write', 'delete', 'assign'
condition?: ABACQuery; // optional ABAC predicate
}

interface SSOProviderRef {
id: ULID;
protocol: 'saml' | 'oidc';
name: string;
entityId: string;
metadataUrl?: string;
enabled: boolean;
}

3.4 ABAC Query DSL (Frozen at F29, M3 start)

type ABACQuery =
| { op: 'eq'; field: string; value: JSONValue }
| { op: 'neq'; field: string; value: JSONValue }
| { op: 'in'; field: string; values: JSONValue[] }
| { op: 'contains'; field: string; value: JSONValue }
| { op: 'gt' | 'gte' | 'lt' | 'lte'; field: string; value: number | string }
| { op: 'exists'; field: string }
| { op: 'and'; conditions: ABACQuery[] }
| { op: 'or'; conditions: ABACQuery[] }
| { op: 'not'; condition: ABACQuery };

Supported context fields:

Field pathTypeDescription
ctx.tenant_idTenantIdCurrent tenant
ctx.user.idUserIdAuthenticated user
ctx.user.role_idsRoleId[]Assigned roles
ctx.user.org_unit_idsOrgUnitId[]Assigned org-units
ctx.user.org_unit_pathsstring[]ltree paths (supports subtree queries)
ctx.timeISODateCurrent server time
resource.tenant_idTenantIdResource owner tenant
resource.created_byUserIdResource creator
resource.org_unit_idOrgUnitIdResource org-unit scope
resource.visibilitystringResource visibility level
resource.statusstringResource status

4. Domain Events

EventEmitted byKey payload fields
TenantProvisionedTenant.provision()tenantId, type, slug, homeRegion, plan
TenantSettingsUpdatedTenant.updateSettings()tenantId, changedFields
TenantSuspendedTenant.suspend()tenantId, reason
TenantClosedTenant.close()tenantId
UserInvitedMembership.invite()tenantId, userId, email, roleIds
MembershipActivatedMembership.activate()tenantId, userId, roleIds
MembershipSuspendedMembership.suspend()tenantId, userId, reason
RoleCreatedRole.create()roleId, tenantId, name, permissions
RoleUpdatedRole.update()roleId, changedPermissions
RoleDeletedRole.delete()roleId
OrgUnitCreatedOrgUnit.create()orgUnitId, tenantId, parentId, ltreePath
OrgUnitMovedOrgUnit.move()orgUnitId, oldParentId, newParentId
OrgUnitDeletedOrgUnit.delete()orgUnitId
DynamicGroupEvaluatedDynamicGroup.evaluate()groupId, tenantId, memberCount, memberIds
DataResidencyChangedmigration sagatenantId, oldRegion, newRegion

5. Invariants (Codified)

5.1 Tenant Invariants

#InvariantEnforcement
T1Slug is globally uniqueDB unique index + domain check
T2Status transitions are forward-only: trial → active → suspended → closedDomain state machine
T3homeRegion is immutable post-provisioning (except via migration saga)Domain guard
T4type is immutable post-provisioningDomain guard
T5Settings pass schema validation (e.g., maxOrgDepth ≤ 10)Zod schema at boundary

5.2 OrgUnit Invariants

#InvariantEnforcement
O1No cycles in parent chainDomain check on move
O2Max depth 10 (configurable per tenant, ceiling 10)Domain check on create/move
O3ltreePath always consistent with parent chainComputed on write; verified on read
O4Deleting an org unit with children is forbidden (must re-parent first)Domain guard

5.3 Membership Invariants

#InvariantEnforcement
M1At most one active membership per (tenantId, userId)DB unique partial index on status='active'
M2Status transitions: invited → active → suspendedDomain state machine
M3At least one org_owner per tenant (cannot remove last)Domain check on role unassignment

5.4 Role Invariants

#InvariantEnforcement
R1System roles are immutable by tenantsDomain guard on isSystem flag
R2Permissions reference known (resource, action) pairsValidated against resource/action registry
R3ABAC conditions parse against DSL schemaValidated at write time

5.5 DynamicGroup Invariants

#InvariantEnforcement
D1Query validates against ABAC DSL schemaValidated at create/update
D2Evaluation is idempotentSame query + same data = same result

6. State Machines

6.1 Tenant Status

provision()
┌─────────────────┐
│ ▼
[NEW] ────────► [trial] ──── activate() ────► [active]

suspend()│

[suspended]

close()│

[closed]

Note: active → suspended → active (reinstate) is allowed
for billing grace periods.

6.2 Membership Status

invite() activate() suspend()
[NEW] ────► [invited] ────► [active] ────► [suspended]

7. System Roles (Seeded)

Role nameScopeKey permissions
platform_adminSystem (tenantId: null)All resources, all actions
compliance_officerSystemAudit read, GDPR actions
org_ownerTenantTenant settings, all org resources
org_adminTenantMembers, roles, org-units, assignments
org_managerTenantRead members in own org-unit subtree, view progress
provider_adminTenantCourses, listings, payouts
authorTenantDraft CRUD within assigned courses
reviewerTenantReview drafts, approve/reject
publisherTenantPublish approved courses
learnerTenantEnroll, play, view own progress
individualTenantBuy, enroll, play, view own progress