Skip to main content

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

TermMeaning
TenantA hotel operator. May own one property (single guesthouse) or many (chain).
PropertyA 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.
MembershipThe relationship of a User (from iam-service) to a Tenant, with state and optional property scope.
RoleTenant-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 AssignmentMembership ↔ Role link, optionally narrowed to a subset of OrganizationUnit IDs (propertyScope[]).
InvitationA signed, single-use email token used to onboard a new staff member.
Tenant ConfigPer-tenant defaults: currencies, locales, tax model, time zone, cancellation policy, check-in/out times, breakfast / smoking / child policy.
Billing ContactTenant-side person + tax IDs used by billing-service.
Feature Flag OverridePer-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:

#InvariantEnforcement
T-1slug immutable, globally unique, kebab-case ^[a-z][a-z0-9-]{2,30}[a-z0-9]$Constructor + DB unique index
T-2status transitions: pending → active → suspended ⇄ active, any → closed (terminal)Method whitelist; bad transition throws IllegalTenantStateTransition
T-3A tenant cannot enter active without a planRefattachPlan is the only way to flip pending → active
T-4closed tenants accept zero writes downstream; gateway enforcesStatus published in tenant.deleted event
T-5suspensionReason 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:

#InvariantEnforcement
TC-1At least one currency, at least one localeSetter validation
TC-2Time zone is a valid IANA nameValidated against Intl.supportedValuesOf('timeZone')
TC-3defaultCheckOut <= defaultCheckIn + 28h (defensive)Comparison in setter
TC-4Tax inclusive flag changes only when zero open folios across tenantCross-aggregate guard via async check (pricing-service advisory)
TC-5Optimistic concurrency via version and HTTP If-MatchSetter 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:

#InvariantEnforcement
O-1Single root per tenant of kind chain; single-property tenants have chain collapsed (root is property)DB unique partial index
O-2kind hierarchy: chain may parent only region or property; region may parent only propertyDomain check in move
O-3Max depth 5nlevel(path) <= 5 invariant + DB CHECK
O-4propertyId non-null iff kind === 'property'Constructor + setter contract
O-5A property unit cannot be archived while it has open reservationsCross-aggregate; reservation-service returns advisory before archive (controller orchestrates)
O-6Chain restructuring (move) is a saga; emits organization_unit.moved.v1 upon completionApplication 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:

#InvariantEnforcement
M-1One membership per (tenantId, userId)DB unique constraint + domain factory check
M-2propertyScope[] may only reference OrganizationUnits of kind property belonging to this tenantCross-aggregate guard (loaded via repo)
M-3Cannot remove the last membership holding tenant.ownerDomain service OwnerProtectionService.assertNotLastOwner()
M-4removed is terminal; reactivation requires a fresh inviteState-machine method whitelist
M-5Suspending a membership cancels the user's active sessions in iam-serviceAsync 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:

#InvariantEnforcement
R-1Role.code unique per tenant; system codes reservedDB partial unique index
R-2System roles are read-only; permissions mutation throws RoleImmutableErrorMethod guard
R-3A role assignment may only narrow scope, never widenComparison against membership scope
R-4A member cannot grant a role they do not themselves possess (escalation)Application-service RoleEscalationGuard (uses PolicyEngine)
R-5Removing 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:

#InvariantEnforcement
I-1TTL = 14 days; expiry transitions to expired, never re-armableexpireIfDue + scheduled job
I-2Token compared in constant time; replay attacks rejectedcrypto.timingSafeEqual on hashes
I-3accept is single-use; subsequent calls throw InvitationAlreadyAcceptedStatus guard
I-4Only one pending invitation per (tenantId, email); new invite revokes the prior pending oneRepository check
I-5Accepting an invite for an email that does not yet exist in iam-service queues a pending membership; activation happens on iam.user.registered.v1Saga 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 BillingContact per tenant (creating a new one supersedes via copy-on-write).
  • F-1 flagKey must exist in the platform flag registry; unknown keys rejected.
  • F-2 rolloutBasisPoints ∈ [0, 10000].

10. Domain Services (cross-aggregate)

ServiceResponsibility
OwnerProtectionServiceCounts active RoleAssignments of tenant.owner before a remove; rejects last-owner removal
RoleEscalationGuardResolves the assigner's permission set; rejects if any proposed permission is not held
OrgTreeIntegrityServicePath recompute on move; cycle detection; depth check
InvitationTokenServiceGenerates raw token, hashes for storage, performs constant-time compare
TenantConfigCacheInvalidatorPure event emitter; invoked by aggregate to flag downstream cache for refresh

11. Domain Events (emitted from aggregates, dispatched by app layer)

EventAggregateTrigger
TenantCreatedTenantTenant.provision
TenantPlanAttachedTenantattachPlan
TenantSuspendedTenantsuspend
TenantReactivatedTenantreactivate
TenantClosedTenantclose
TenantConfigUpdatedTenantConfigupdate
OrganizationUnitCreatedOrganizationUnitfactory
OrganizationUnitMovedOrganizationUnitmove
OrganizationUnitArchivedOrganizationUnitarchive
MembershipCreatedMembershipfactory + invite accept
MembershipRoleChangedMembership(via RoleAssignment)
MembershipPropertyScopeChangedMembershipchangePropertyScope
MembershipSuspended / MembershipRemovedMembershipstate methods
InvitationSent / InvitationAccepted / InvitationExpired / InvitationRevokedInvitationstate methods
FeatureFlagToggledFeatureFlagOverridemutation
GuestErasureRequestedTenant (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)

ClassMaps to error code
TenantSlugTakenErrorMELMASTOON.TENANT.SLUG_TAKEN
IllegalTenantStateTransitionMELMASTOON.TENANT.ILLEGAL_STATE_TRANSITION
TenantNotActiveErrorMELMASTOON.TENANT.NOT_ACTIVE
OptimisticConcurrencyErrorMELMASTOON.COMMON.STALE_VERSION
LastOwnerRemovalErrorMELMASTOON.TENANT.LAST_OWNER_REMOVAL
RoleEscalationErrorMELMASTOON.TENANT.ROLE_ESCALATION
RoleImmutableErrorMELMASTOON.TENANT.ROLE_IMMUTABLE
InvitationAlreadyAcceptedMELMASTOON.TENANT.INVITATION_REUSED
InvitationExpiredMELMASTOON.TENANT.INVITATION_EXPIRED
OrgUnitDepthExceededMELMASTOON.TENANT.ORG_DEPTH_EXCEEDED
OrgUnitArchiveBlockedMELMASTOON.TENANT.ORG_ARCHIVE_BLOCKED
CrossTenantReferenceErrorMELMASTOON.COMMON.CROSS_TENANT_REFERENCE

See full list in ERROR_CODES.md.