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
| Term | Definition |
|---|---|
| Tenant | A billable entity that owns data on the platform — an organization, a content provider, an individual learner, or a hybrid org+provider. |
| Org Unit | A node in a tenant's hierarchical organizational structure (department, campus, team). Stored as an ltree path for efficient subtree queries. |
| Membership | The relationship between a User (from identity context) and a Tenant. Carries role assignments and org-unit scoping. |
| Role | A named collection of permissions. System roles are platform-defined and immutable. Tenant roles are custom. |
| Permission | A (resource, action, condition?) triple. The optional condition is an ABAC predicate evaluated at decision time. |
| ABAC Predicate | An expression tree (ABACQuery) that evaluates contextual attributes (user org-units, resource ownership, time, etc.) to a boolean. |
| Dynamic Group | A tenant-scoped, query-defined cohort of memberships. Evaluated on demand; consumed by assignment-service for bulk targeting. |
| Feature Flag Override | A per-tenant override of a platform-wide feature flag value. |
| Plan | A reference to the tenant's billing plan (owned by billing-service). Tenant-service stores the planId + addons[] for routing entitlements. |
| Home Region | The 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 path | Type | Description |
|---|---|---|
ctx.tenant_id | TenantId | Current tenant |
ctx.user.id | UserId | Authenticated user |
ctx.user.role_ids | RoleId[] | Assigned roles |
ctx.user.org_unit_ids | OrgUnitId[] | Assigned org-units |
ctx.user.org_unit_paths | string[] | ltree paths (supports subtree queries) |
ctx.time | ISODate | Current server time |
resource.tenant_id | TenantId | Resource owner tenant |
resource.created_by | UserId | Resource creator |
resource.org_unit_id | OrgUnitId | Resource org-unit scope |
resource.visibility | string | Resource visibility level |
resource.status | string | Resource status |
4. Domain Events
| Event | Emitted by | Key payload fields |
|---|---|---|
TenantProvisioned | Tenant.provision() | tenantId, type, slug, homeRegion, plan |
TenantSettingsUpdated | Tenant.updateSettings() | tenantId, changedFields |
TenantSuspended | Tenant.suspend() | tenantId, reason |
TenantClosed | Tenant.close() | tenantId |
UserInvited | Membership.invite() | tenantId, userId, email, roleIds |
MembershipActivated | Membership.activate() | tenantId, userId, roleIds |
MembershipSuspended | Membership.suspend() | tenantId, userId, reason |
RoleCreated | Role.create() | roleId, tenantId, name, permissions |
RoleUpdated | Role.update() | roleId, changedPermissions |
RoleDeleted | Role.delete() | roleId |
OrgUnitCreated | OrgUnit.create() | orgUnitId, tenantId, parentId, ltreePath |
OrgUnitMoved | OrgUnit.move() | orgUnitId, oldParentId, newParentId |
OrgUnitDeleted | OrgUnit.delete() | orgUnitId |
DynamicGroupEvaluated | DynamicGroup.evaluate() | groupId, tenantId, memberCount, memberIds |
DataResidencyChanged | migration saga | tenantId, oldRegion, newRegion |
5. Invariants (Codified)
5.1 Tenant Invariants
| # | Invariant | Enforcement |
|---|---|---|
| T1 | Slug is globally unique | DB unique index + domain check |
| T2 | Status transitions are forward-only: trial → active → suspended → closed | Domain state machine |
| T3 | homeRegion is immutable post-provisioning (except via migration saga) | Domain guard |
| T4 | type is immutable post-provisioning | Domain guard |
| T5 | Settings pass schema validation (e.g., maxOrgDepth ≤ 10) | Zod schema at boundary |
5.2 OrgUnit Invariants
| # | Invariant | Enforcement |
|---|---|---|
| O1 | No cycles in parent chain | Domain check on move |
| O2 | Max depth 10 (configurable per tenant, ceiling 10) | Domain check on create/move |
| O3 | ltreePath always consistent with parent chain | Computed on write; verified on read |
| O4 | Deleting an org unit with children is forbidden (must re-parent first) | Domain guard |
5.3 Membership Invariants
| # | Invariant | Enforcement |
|---|---|---|
| M1 | At most one active membership per (tenantId, userId) | DB unique partial index on status='active' |
| M2 | Status transitions: invited → active → suspended | Domain state machine |
| M3 | At least one org_owner per tenant (cannot remove last) | Domain check on role unassignment |
5.4 Role Invariants
| # | Invariant | Enforcement |
|---|---|---|
| R1 | System roles are immutable by tenants | Domain guard on isSystem flag |
| R2 | Permissions reference known (resource, action) pairs | Validated against resource/action registry |
| R3 | ABAC conditions parse against DSL schema | Validated at write time |
5.5 DynamicGroup Invariants
| # | Invariant | Enforcement |
|---|---|---|
| D1 | Query validates against ABAC DSL schema | Validated at create/update |
| D2 | Evaluation is idempotent | Same 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 name | Scope | Key permissions |
|---|---|---|
platform_admin | System (tenantId: null) | All resources, all actions |
compliance_officer | System | Audit read, GDPR actions |
org_owner | Tenant | Tenant settings, all org resources |
org_admin | Tenant | Members, roles, org-units, assignments |
org_manager | Tenant | Read members in own org-unit subtree, view progress |
provider_admin | Tenant | Courses, listings, payouts |
author | Tenant | Draft CRUD within assigned courses |
reviewer | Tenant | Review drafts, approve/reject |
publisher | Tenant | Publish approved courses |
learner | Tenant | Enroll, play, view own progress |
individual | Tenant | Buy, enroll, play, view own progress |