tenant-service — DOMAIN_MODEL
Companion: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · SECURITY_MODEL · Platform: 06 Data Models · Naming
This document defines the pure-TypeScript domain model that lives in @melmastoon/tenant-domain. It contains no framework imports. Persistence, transport, and Pub/Sub are not visible here.
1. Ubiquitous Language
| Term | Meaning |
|---|---|
| Tenant | A hotel operator. May own one property (single guesthouse) or many (chain). |
| Property | A physical hotel; identity owned by property-service, but referenced as an OrganizationUnit of kind property here. |
| Organization Unit (OrgUnit) | A node in the chain → region → property tree. |
| Membership | The relationship of a User (from iam-service) to a Tenant, with state and optional property scope. |
| Role | Tenant-scoped permission bundle. System roles (e.g. tenant.owner) are immutable; tenants may also define custom roles drawn from the canonical (resource, action) registry. |
| Role Assignment | Membership ↔ Role link, optionally narrowed to a subset of OrganizationUnit IDs (propertyScope[]). |
| Invitation | A signed, single-use email token used to onboard a new staff member. |
| Tenant Config | Per-tenant defaults: currencies, locales, tax model, time zone, cancellation policy, check-in/out times, breakfast / smoking / child policy. |
| Billing Contact | Tenant-side person + tax IDs used by billing-service. |
| Feature Flag Override | Per-tenant override on a platform-wide flag. |
2. Branded IDs and Value Objects
import type { Branded } from '@melmastoon/domain-primitives';
export type TenantId = Branded<string, 'TenantId'>; // tnt_<ULID>
export type TenantConfigId = Branded<string, 'TenantConfigId'>; // tcg_<ULID>
export type OrganizationUnitId = Branded<string, 'OrganizationUnitId'>; // org_<ULID>
export type MembershipId = Branded<string, 'MembershipId'>; // mbr_<ULID>
export type RoleId = Branded<string, 'RoleId'>; // rol_<ULID>
export type RoleAssignmentId = Branded<string, 'RoleAssignmentId'>; // rla_<ULID>
export type InvitationId = Branded<string, 'InvitationId'>; // inv_<ULID>
export type BillingContactId = Branded<string, 'BillingContactId'>; // bcn_<ULID>
export type FeatureFlagOverrideId = Branded<string, 'FeatureFlagOverrideId'>; // flg_<ULID>
export type UserId = Branded<string, 'UserId'>; // usr_ (from iam)
export type PropertyId = Branded<string, 'PropertyId'>; // ppt_ (from property-service)
2.1 Enums
export type TenantStatus =
| 'pending' // provisioned but no plan attached
| 'active' // plan attached, accepts writes
| 'suspended' // billing-driven or manual
| 'closed'; // soft-deleted, scheduled for cascade
export type MembershipStatus =
| 'pending' // invitation accepted, awaiting iam confirmation
| 'active'
| 'suspended' // tenant.owner action, not billing
| 'removed'; // soft-deleted
export type OrganizationUnitKind =
| 'chain'
| 'region'
| 'property';
export type InvitationStatus =
| 'pending'
| 'accepted'
| 'expired'
| 'revoked';
2.2 Composite VOs
export class CountryCode {
constructor(readonly value: string) { // ISO 3166-1 alpha-2
if (!/^[A-Z]{2}$/.test(value)) throw new Error('CountryCode must be ISO 3166-1 alpha-2');
}
}
export class CurrencyCode { // ISO 4217
constructor(readonly value: string) {
if (!/^[A-Z]{3}$/.test(value)) throw new Error('CurrencyCode must be ISO 4217');
}
}
export class Locale { // BCP-47, e.g. 'fa-AF', 'ps-AF', 'en-US'
constructor(readonly value: string, readonly isRtl: boolean) {}
}
export class TimeOfDay { // 'HH:mm', tenant local
constructor(readonly value: string) {
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new Error('TimeOfDay must be HH:mm');
}
}
export class CancellationPolicyDefault {
constructor(
readonly windowHours: number, // free cancel window
readonly chargeOnLateCancelMicro: bigint, // money in micro-units
readonly noShowChargeMicro: bigint,
) {
if (windowHours < 0 || windowHours > 720) throw new Error('windowHours out of range');
}
}
export class TaxModel {
constructor(
readonly inclusive: boolean, // prices include tax?
readonly defaultRateBasisPoints: number, // e.g. 1500 = 15.00%
readonly registrationNumber: string | null,
) {}
}
export class Permission { // canonical (resource, action) pair
constructor(readonly resource: string, readonly action: string) {}
toString() { return `${this.resource}:${this.action}`; }
}
The (resource, action) registry is seeded at deploy time (see DATA_MODEL §6). Examples: reservation:create, reservation:check_in, folio:adjust, key_credential:issue, tenant.config:update.
3. Aggregate: Tenant
export class Tenant {
private constructor(
readonly id: TenantId,
private status: TenantStatus,
readonly slug: string, // url-safe identifier, immutable after create
private legalName: string,
readonly country: CountryCode,
readonly residencyRegion: 'me-central1' | 'asia-south1' | 'europe-west1',
private planRef: string | null, // billing-side opaque
private suspensionReason: string | null,
readonly createdAt: Date,
private updatedAt: Date,
private version: number,
) {}
static provision(input: ProvisionTenantInput): Tenant { /* … */ }
attachPlan(planRef: string): TenantEvent[] { /* status pending → active */ }
suspend(reason: string, by: 'platform' | 'billing'): TenantEvent[] { /* … */ }
reactivate(by: 'platform' | 'billing'): TenantEvent[] { /* … */ }
close(reason: string): TenantEvent[] { /* status → closed */ }
/* invariants */
assertActive(): void {
if (this.status !== 'active') {
throw new TenantNotActiveError(this.id, this.status);
}
}
}
Invariants:
| # | Invariant | Enforcement |
|---|---|---|
| T-1 | slug immutable, globally unique, kebab-case ^[a-z][a-z0-9-]{2,30}[a-z0-9]$ | Constructor + DB unique index |
| T-2 | status transitions: pending → active → suspended ⇄ active, any → closed (terminal) | Method whitelist; bad transition throws IllegalTenantStateTransition |
| T-3 | A tenant cannot enter active without a planRef | attachPlan is the only way to flip pending → active |
| T-4 | closed tenants accept zero writes downstream; gateway enforces | Status published in tenant.deleted event |
| T-5 | suspensionReason non-null iff status === 'suspended' | Setter contract |
4. Aggregate: TenantConfig
export class TenantConfig {
private constructor(
readonly id: TenantConfigId,
readonly tenantId: TenantId,
private currencies: CurrencyCode[], // first = default
private locales: Locale[], // first = default; isRtl honored on UI
private timeZone: string, // IANA, e.g. 'Asia/Kabul'
private taxModel: TaxModel,
private defaultCheckIn: TimeOfDay,
private defaultCheckOut: TimeOfDay,
private breakfastIncludedDefault: boolean,
private smokingPolicy: 'allowed' | 'designated' | 'forbidden',
private childPolicy: { minAge: number; cribsAvailable: boolean; },
private cancellationDefault: CancellationPolicyDefault,
private businessHours: { open: TimeOfDay; close: TimeOfDay; } | null,
private version: number,
) {}
update(patch: Partial<TenantConfigPatch>, by: UserId, expectVersion: number): TenantEvent[] {
if (expectVersion !== this.version) throw new OptimisticConcurrencyError();
/* mutate, bump version, emit tenant.config_updated.v1 */
}
}
Invariants:
| # | Invariant | Enforcement |
|---|---|---|
| TC-1 | At least one currency, at least one locale | Setter validation |
| TC-2 | Time zone is a valid IANA name | Validated against Intl.supportedValuesOf('timeZone') |
| TC-3 | defaultCheckOut <= defaultCheckIn + 28h (defensive) | Comparison in setter |
| TC-4 | Tax inclusive flag changes only when zero open folios across tenant | Cross-aggregate guard via async check (pricing-service advisory) |
| TC-5 | Optimistic concurrency via version and HTTP If-Match | Setter contract; controller maps to 412 |
5. Aggregate: OrganizationUnit
export class OrganizationUnit {
private constructor(
readonly id: OrganizationUnitId,
readonly tenantId: TenantId,
readonly kind: OrganizationUnitKind,
private parentId: OrganizationUnitId | null,
private path: string, // ltree path, derived
private name: string,
readonly propertyId: PropertyId | null, // populated only when kind === 'property'
private archived: boolean,
private version: number,
) {}
rename(newName: string): TenantEvent[] { /* … */ }
move(newParent: OrganizationUnit | null): TenantEvent[] { /* path recompute saga */ }
archive(): TenantEvent[] { /* not allowed if children active */ }
}
Invariants:
| # | Invariant | Enforcement |
|---|---|---|
| O-1 | Single root per tenant of kind chain; single-property tenants have chain collapsed (root is property) | DB unique partial index |
| O-2 | kind hierarchy: chain may parent only region or property; region may parent only property | Domain check in move |
| O-3 | Max depth 5 | nlevel(path) <= 5 invariant + DB CHECK |
| O-4 | propertyId non-null iff kind === 'property' | Constructor + setter contract |
| O-5 | A property unit cannot be archived while it has open reservations | Cross-aggregate; reservation-service returns advisory before archive (controller orchestrates) |
| O-6 | Chain restructuring (move) is a saga; emits organization_unit.moved.v1 upon completion | Application layer; see APPLICATION_LOGIC §3.5 |
6. Aggregate: Membership
export class Membership {
private constructor(
readonly id: MembershipId,
readonly tenantId: TenantId,
readonly userId: UserId,
private status: MembershipStatus,
private displayName: string,
private propertyScope: OrganizationUnitId[], // empty = whole tenant
private invitedBy: UserId | null,
private invitedAt: Date | null,
private joinedAt: Date | null,
private version: number,
) {}
activateFromInvite(now: Date): TenantEvent[] { /* … */ }
suspend(by: UserId, reason: string): TenantEvent[] { /* … */ }
remove(by: UserId): TenantEvent[] { /* see invariant M-3 */ }
changePropertyScope(scope: OrganizationUnitId[]): TenantEvent[] { /* … */ }
}
Invariants:
| # | Invariant | Enforcement |
|---|---|---|
| M-1 | One membership per (tenantId, userId) | DB unique constraint + domain factory check |
| M-2 | propertyScope[] may only reference OrganizationUnits of kind property belonging to this tenant | Cross-aggregate guard (loaded via repo) |
| M-3 | Cannot remove the last membership holding tenant.owner | Domain service OwnerProtectionService.assertNotLastOwner() |
| M-4 | removed is terminal; reactivation requires a fresh invite | State-machine method whitelist |
| M-5 | Suspending a membership cancels the user's active sessions in iam-service | Async via tenant.membership.suspended consumed by iam |
7. Aggregate: Role and RoleAssignment
export class Role {
private constructor(
readonly id: RoleId,
readonly tenantId: TenantId | null, // null for platform-defined system role templates
readonly code: string, // 'tenant.owner', 'tenant.gm', …; immutable
private displayName: string,
private permissions: Permission[], // pulled from canonical registry
readonly system: boolean, // true ⇒ immutable per tenant
private version: number,
) {}
grantPermission(p: Permission): TenantEvent[] { /* forbidden if system */ }
revokePermission(p: Permission): TenantEvent[] { /* forbidden if system */ }
}
export class RoleAssignment {
private constructor(
readonly id: RoleAssignmentId,
readonly tenantId: TenantId,
readonly membershipId: MembershipId,
readonly roleId: RoleId,
private propertyScope: OrganizationUnitId[], // empty = same as membership scope
private grantedBy: UserId,
private grantedAt: Date,
private version: number,
) {}
}
Invariants:
| # | Invariant | Enforcement |
|---|---|---|
| R-1 | Role.code unique per tenant; system codes reserved | DB partial unique index |
| R-2 | System roles are read-only; permissions mutation throws RoleImmutableError | Method guard |
| R-3 | A role assignment may only narrow scope, never widen | Comparison against membership scope |
| R-4 | A member cannot grant a role they do not themselves possess (escalation) | Application-service RoleEscalationGuard (uses PolicyEngine) |
| R-5 | Removing the last assignment of tenant.owner rejected (see M-3) | Domain service |
8. Aggregate: Invitation
export class Invitation {
private constructor(
readonly id: InvitationId,
readonly tenantId: TenantId,
readonly email: string, // RFC 5322 lowercased
readonly tokenHash: string, // sha256(token); raw token never stored
private status: InvitationStatus,
private rolesProposed: RoleId[], // applied on accept
private propertyScope: OrganizationUnitId[],
readonly invitedBy: UserId,
readonly invitedAt: Date,
readonly expiresAt: Date, // invitedAt + 14d
private acceptedAt: Date | null,
private acceptedBy: UserId | null,
) {}
accept(rawToken: string, by: UserId, now: Date): TenantEvent[] { /* constant-time compare */ }
revoke(by: UserId): TenantEvent[] { /* … */ }
expireIfDue(now: Date): TenantEvent[] { /* … */ }
}
Invariants:
| # | Invariant | Enforcement |
|---|---|---|
| I-1 | TTL = 14 days; expiry transitions to expired, never re-armable | expireIfDue + scheduled job |
| I-2 | Token compared in constant time; replay attacks rejected | crypto.timingSafeEqual on hashes |
| I-3 | accept is single-use; subsequent calls throw InvitationAlreadyAccepted | Status guard |
| I-4 | Only one pending invitation per (tenantId, email); new invite revokes the prior pending one | Repository check |
| I-5 | Accepting an invite for an email that does not yet exist in iam-service queues a pending membership; activation happens on iam.user.registered.v1 | Saga in APPLICATION_LOGIC §3.7 |
9. Aggregate: BillingContact and FeatureFlagOverride
export class BillingContact {
private constructor(
readonly id: BillingContactId,
readonly tenantId: TenantId,
private fullName: string,
private email: string,
private phone: string | null,
private address: PostalAddress,
private taxId: string | null,
private version: number,
) {}
}
export class FeatureFlagOverride {
private constructor(
readonly id: FeatureFlagOverrideId,
readonly tenantId: TenantId,
readonly flagKey: string, // 'aiEnabled', 'mfaRequiredForStaff', …
private enabled: boolean,
private rolloutBasisPoints: number, // 0..10000; per-tenant override
private updatedBy: UserId,
private updatedAt: Date,
private version: number,
) {}
}
Invariants:
- B-1 Only one
BillingContactper tenant (creating a new one supersedes via copy-on-write). - F-1
flagKeymust exist in the platform flag registry; unknown keys rejected. - F-2
rolloutBasisPoints ∈ [0, 10000].
10. Domain Services (cross-aggregate)
| Service | Responsibility |
|---|---|
OwnerProtectionService | Counts active RoleAssignments of tenant.owner before a remove; rejects last-owner removal |
RoleEscalationGuard | Resolves the assigner's permission set; rejects if any proposed permission is not held |
OrgTreeIntegrityService | Path recompute on move; cycle detection; depth check |
InvitationTokenService | Generates raw token, hashes for storage, performs constant-time compare |
TenantConfigCacheInvalidator | Pure event emitter; invoked by aggregate to flag downstream cache for refresh |
11. Domain Events (emitted from aggregates, dispatched by app layer)
| Event | Aggregate | Trigger |
|---|---|---|
TenantCreated | Tenant | Tenant.provision |
TenantPlanAttached | Tenant | attachPlan |
TenantSuspended | Tenant | suspend |
TenantReactivated | Tenant | reactivate |
TenantClosed | Tenant | close |
TenantConfigUpdated | TenantConfig | update |
OrganizationUnitCreated | OrganizationUnit | factory |
OrganizationUnitMoved | OrganizationUnit | move |
OrganizationUnitArchived | OrganizationUnit | archive |
MembershipCreated | Membership | factory + invite accept |
MembershipRoleChanged | Membership | (via RoleAssignment) |
MembershipPropertyScopeChanged | Membership | changePropertyScope |
MembershipSuspended / MembershipRemoved | Membership | state methods |
InvitationSent / InvitationAccepted / InvitationExpired / InvitationRevoked | Invitation | state methods |
FeatureFlagToggled | FeatureFlagOverride | mutation |
GuestErasureRequested | Tenant (operator-issued) | DSAR command — fan-out only, no local data change |
Each domain event is converted in the application layer to the corresponding melmastoon.tenant.*.v1 integration event before publication; see EVENT_SCHEMAS.
12. State Machines
Tenant
[pending] --attachPlan--> [active] --suspend(billing|platform)--> [suspended]
<--reactivate(billing|platform)--
[active|suspended] --close--> [closed] (terminal)
Membership
[pending] --activateFromInvite--> [active] --suspend--> [suspended]
<--reinstate--
[active|suspended|pending] --remove--> [removed] (terminal)
Invitation
[pending] --accept--> [accepted] (terminal)
[pending] --revoke--> [revoked] (terminal)
[pending] --expireIfDue--> [expired] (terminal)
13. Errors (domain layer)
| Class | Maps to error code |
|---|---|
TenantSlugTakenError | MELMASTOON.TENANT.SLUG_TAKEN |
IllegalTenantStateTransition | MELMASTOON.TENANT.ILLEGAL_STATE_TRANSITION |
TenantNotActiveError | MELMASTOON.TENANT.NOT_ACTIVE |
OptimisticConcurrencyError | MELMASTOON.COMMON.STALE_VERSION |
LastOwnerRemovalError | MELMASTOON.TENANT.LAST_OWNER_REMOVAL |
RoleEscalationError | MELMASTOON.TENANT.ROLE_ESCALATION |
RoleImmutableError | MELMASTOON.TENANT.ROLE_IMMUTABLE |
InvitationAlreadyAccepted | MELMASTOON.TENANT.INVITATION_REUSED |
InvitationExpired | MELMASTOON.TENANT.INVITATION_EXPIRED |
OrgUnitDepthExceeded | MELMASTOON.TENANT.ORG_DEPTH_EXCEEDED |
OrgUnitArchiveBlocked | MELMASTOON.TENANT.ORG_ARCHIVE_BLOCKED |
CrossTenantReferenceError | MELMASTOON.COMMON.CROSS_TENANT_REFERENCE |
See full list in ERROR_CODES.md.