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.
| Field | Type | Notes |
|---|---|---|
messageId | MessageId (UUIDv4 branded) | Aggregate identity |
tenantId | TenantId (UUIDv4 branded) | Isolation boundary — RLS-enforced |
accountId | AccountId | Billing + rate-limit scope |
to | PhoneNumber (E.164 VO) | Destination |
from | SenderId | Source (E.164 or alphanumeric sender) |
body | SmsBody (VO) | Trimmed, max 1600 chars, encoded |
segmentCount | number | Computed at accept (GSM-7 / UCS-2 detection) |
messageType | MessageType enum | SMS, FLASH_SMS |
status | MessageStatus enum | State machine (see §3) |
attemptCount | number | 0..3 |
operatorId | OperatorId | null | Set after routing |
routeId | RouteId | null | Set after routing |
idempotencyKey | string | Nullable; set when client supplied Idempotency-Key |
lastError | ProblemJson | null | Most recent failure |
enqueuedAt | Instant | HTTP accept timestamp |
processedAt | Instant | null | Set 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
| VO | Invariant |
|---|---|
PhoneNumber | Matches ^\+[1-9]\d{1,14}$; normalized at construction |
SmsBody | Non-empty; ≤1600 chars; segments computed from encoding |
SenderId | Non-empty; ≤20 chars; alphanumeric allowed where operator permits |
MessageId | UUIDv4 |
TenantId, AccountId, OperatorId, RouteId | Branded UUIDs; no cross-type assignment |
3. State Machine
Invariants:
- State transitions only via aggregate methods; direct DB update prohibited.
attemptCountmonotonically increases.- Terminal states:
SENT,DEAD_LETTER. No further transitions.
4. Domain Events
| Event | Trigger | Payload (summary) |
|---|---|---|
sms.events.status.v1 | Every transition | messageId, tenantId, previousStatus, newStatus, at |
sms.outbound.retry.v1 | Retry scheduled | messageId, attempt, reason |
sms.outbound.deadletter.v1 | DLQ routing | Full payload + failureReason, attemptCount, failedAt |
See EVENT_SCHEMAS for full schemas.
5. Domain Services
| Service | Purpose |
|---|---|
MessageValidationService | Zod + domain invariants; produces SmsBody, PhoneNumber VOs |
SegmentCalculator | GSM-7 vs UCS-2 detection, segment count |
IdempotencyResolver | Resolves Idempotency-Key against Redis + replay response |
RetryPolicy | Computes next backoff and decides DLQ vs retry |
6. Domain Errors
| Error | Cause | Terminal? |
|---|---|---|
InvalidPhoneNumberError | Destination fails E.164 | Yes (FAILED) |
SmsBodyTooLongError | body > 1600 chars | Yes |
NoRouteFoundError | routing-engine NO_ROUTE_FOUND | Yes |
RoutingTransientError | routing-engine 5xx/timeout | No (retry) |
OperatorPublishError | NATS publish to smpp.operator.* failed | No (retry) |
IdempotencyReplay | Key already in Redis | No — replay stored response |