Skip to main content

SMS Orchestrator — Domain Model

Status: populated Owner: Platform Engineering Last updated: 2026-04-18 Companion: SERVICE_OVERVIEW · EVENT_SCHEMAS · DATA_MODEL

1. Aggregates

SmsMessage (root)

Represents one outbound SMS from HTTP accept through terminal state. All state transitions invariant-checked in-aggregate.

FieldTypeNotes
messageIdMessageId (UUIDv4 branded)Aggregate identity
tenantIdTenantId (UUIDv4 branded)Isolation boundary — RLS-enforced
accountIdAccountIdBilling + rate-limit scope
toPhoneNumber (E.164 VO)Destination
fromSenderIdSource (E.164 or alphanumeric sender)
bodySmsBody (VO)Trimmed, max 1600 chars, encoded
segmentCountnumberComputed at accept (GSM-7 / UCS-2 detection)
messageTypeMessageType enumSMS, FLASH_SMS
statusMessageStatus enumState machine (see §3)
attemptCountnumber0..3
operatorIdOperatorId | nullSet after routing
routeIdRouteId | nullSet after routing
idempotencyKeystringNullable; set when client supplied Idempotency-Key
lastErrorProblemJson | nullMost recent failure
enqueuedAtInstantHTTP accept timestamp
processedAtInstant | nullSet when terminal

IdempotencyKey (root)

Owns the mapping from a client-supplied key (hashed + account-scoped) to the originally-accepted messageId and canonical response body. Enforces 48h replay window.

2. Value Objects

VOInvariant
PhoneNumberMatches ^\+[1-9]\d{1,14}$; normalized at construction
SmsBodyNon-empty; ≤1600 chars; segments computed from encoding
SenderIdNon-empty; ≤20 chars; alphanumeric allowed where operator permits
MessageIdUUIDv4
TenantId, AccountId, OperatorId, RouteIdBranded UUIDs; no cross-type assignment

3. State Machine

Invariants:

  • State transitions only via aggregate methods; direct DB update prohibited.
  • attemptCount monotonically increases.
  • Terminal states: SENT, DEAD_LETTER. No further transitions.

4. Domain Events

EventTriggerPayload (summary)
sms.events.status.v1Every transitionmessageId, tenantId, previousStatus, newStatus, at
sms.outbound.retry.v1Retry scheduledmessageId, attempt, reason
sms.outbound.deadletter.v1DLQ routingFull payload + failureReason, attemptCount, failedAt

See EVENT_SCHEMAS for full schemas.

5. Domain Services

ServicePurpose
MessageValidationServiceZod + domain invariants; produces SmsBody, PhoneNumber VOs
SegmentCalculatorGSM-7 vs UCS-2 detection, segment count
IdempotencyResolverResolves Idempotency-Key against Redis + replay response
RetryPolicyComputes next backoff and decides DLQ vs retry

6. Domain Errors

ErrorCauseTerminal?
InvalidPhoneNumberErrorDestination fails E.164Yes (FAILED)
SmsBodyTooLongErrorbody > 1600 charsYes
NoRouteFoundErrorrouting-engine NO_ROUTE_FOUNDYes
RoutingTransientErrorrouting-engine 5xx/timeoutNo (retry)
OperatorPublishErrorNATS publish to smpp.operator.* failedNo (retry)
IdempotencyReplayKey already in RedisNo — replay stored response