System Architecture Overview¶
The SMS Gateway is a Spring Boot microservice system. It uses:
- HTTP for authentication and balance operations;
- PostgreSQL for business data;
- Redis for active sessions and alert counters;
- Kafka for SMS delivery and reporting events;
- Eureka for service discovery.
The system protects client data and balances, saves accepted work before processing it, and supports retries and recovery. A database update and an external provider call cannot be committed as one transaction. Unclear provider results must therefore be checked before retrying.
Service catalog¶
| Service | Port | Main responsibility | Data and dependencies |
|---|---|---|---|
| gateway-service | 8000 in Compose; 8081 in the dev profile unless overridden | Receives external requests, validates JWTs and sessions, adds trusted identity headers, and routes requests | Redis and Eureka; no application database |
| client-service | 9014 | Manages clients, users, tokens, balances, reports, and channel alerts | PostgreSQL client_service, Redis database 5, and Kafka |
| sms-router-service | 9015 | Validates SMS requests, calculates routes and prices, manages campaigns, coordinates charges, and publishes outbox events | PostgreSQL sms_router, Kafka, and client-service |
| aggregator-integration-service | 9016 | Consumes SMS chunks, calls Infozillion, checks delivery reports, and manages direct-SMS refunds | PostgreSQL sms_aggregator, limited access to sms_router, Kafka, and client-service |
| discovery-service | 8761 | Registers services and resolves service names through Eureka | In-memory service registry; no business database |
Deployment topology¶
flowchart LR
classDef svc fill:#e8f0fe,stroke:#1a73e8,stroke-width:1.5px
classDef data fill:#f1f3f4,stroke:#5f6368,stroke-width:1.5px
classDef ext fill:#e6f4ea,stroke:#137333,stroke-width:1.5px
Consumer[Client / operator]:::ext -->|HTTP :8000| GW[gateway-service]:::svc
GW -->|validate access session| Redis[(Redis)]:::data
GW -->|lb://client-service| CS[client-service]:::svc
GW -->|lb://sms-router-service| RT[sms-router-service]:::svc
RT -->|billing settings, deduct, reverse, refund| CS
RT --> PG[(PostgreSQL)]:::data
CS --> PG
RT -->|sms.outbound.* and sms.reporting| Kafka[(Kafka)]:::data
Kafka --> AG[aggregator-integration-service]:::svc
AG -->|send-sms / check-delivery-report| IZ[Infozillion / MNO / IPTSP]:::ext
AG -->|save SMS, audit, and refund data| PG
Kafka --> CS
CS -->|channel alert webhook| Hook[External alert receiver]:::ext
Eureka[discovery-service]:::svc -. registry .-> GW
Eureka -. registry .-> RT
Eureka -. registry .-> AG
Eureka -. registry .-> CS
The production Compose setup publishes only gateway port 8000. Other services and infrastructure stay inside the Docker network. Non-container deployments should use firewall or reverse-proxy rules to provide the same protection.
Trust and API boundaries¶
External client traffic¶
- Login and refresh requests go to client-service without an existing access token.
- Protected
/api/**requests send a JWT created by client-service. - Gateway checks the JWT signature and expiry and confirms that
sms:token:{jwt}exists in Redis. - Gateway adds trusted client ID, user ID, username, and status headers.
- Router and client-service endpoints use those headers as the caller's identity.
The downstream services do not validate the JWT again on these routes. Their direct ports must remain private so callers cannot create false identity headers.
Administrative and internal traffic¶
/admin/** and /internal/** use X-Admin-API-Key instead of a bearer JWT. The same key protects client setup, routing configuration, reporting, and internal balance calls. Keep it secret, restrict these routes by network, and give automation only the access it needs.
Operational traffic¶
Router leaves only liveness, readiness, and info endpoints open. Other diagnostics require the admin key. Client-service currently allows all available actuator endpoints on its direct port. Restrict that port and expose only required actuator endpoints in production.
Direct SMS acceptance and delivery¶
sequenceDiagram
autonumber
actor App as Client application
participant G as Gateway
participant R as SMS router
participant C as Client service
participant DB as PostgreSQL
participant K as Kafka
participant A as Aggregator integration
participant I as Infozillion
App->>G: POST /api/sms/messages + Bearer JWT
G->>G: Validate JWT and session
G->>R: Body + trusted identity headers
R->>R: Normalize recipients, choose route, and calculate cost
R->>C: Deduct client or user balance
alt insufficient or invalid account
C-->>R: 4xx deduction rejected
R-->>App: Submission error
else deduction accepted
C-->>R: Remaining balance
R->>DB: Save request, chunks, recipients, and outbox
R-->>App: 201 PENDING + messageId
R->>K: Publish sms.outbound.{mnoUserId}
K->>A: Outbound chunk
A->>DB: Claim DISPATCHING
A->>I: send-sms
opt Delivery tracking required
A->>I: check-delivery-report on schedule
end
A->>DB: Save final recipient and reporting event
opt eligible direct failure
A->>C: Idempotent refund
end
end
The API returns before the SMS is sent to Infozillion. The outbox keeps accepted work safe when Kafka is temporarily unavailable. However, the Infozillion call and the database update are separate operations. If a chunk remains in DISPATCHING, confirm the provider result before replaying it to avoid a duplicate SMS.
Campaign orchestration¶
Router manages campaign creation, CSV processing, preview, charging, scheduling, dispatch, pause, resume, reschedule, cancellation, retry, resend, delivery status, and refunds. It stores the rendered message, route, encoding, segment count, rate, and cost for each recipient.
Aggregator sends campaign chunks and saves delivery results, but it does not refund campaign messages. Router identifies campaign work through campaignRef, updates the campaign recipients, applies retry rules, and handles refunds. Having one refund owner prevents duplicate credits.
See SMS router process flows for the complete state machine.
Data ownership and cross-schema access¶
The base deployment uses one PostgreSQL database with three schemas:
| Schema | Owner | Main data |
|---|---|---|
client_service |
client-service | Clients, users, balances, deduction and refund logs, reports, export jobs, channel alerts, and client outbox events |
sms_router |
sms-router-service | Routes, SMS requests, chunks, recipients, campaigns, uploads, idempotency records, outbox events, and scheduler leases |
sms_aggregator |
aggregator-integration-service | Infozillion call audits and direct-SMS refund attempts |
Aggregator has limited access to the sms_router schema. It reads route and credential references and updates chunk, recipient, and outbox data. This lets it save a final recipient status and its MESSAGE_FINALIZED event in one database transaction. Grant only the required cross-schema permissions.
Each service runs Flyway for its own schema. Run router migrations before aggregator validation because aggregator maps some router tables.
Messaging contracts¶
| Topic pattern | Producer | Consumer | Purpose |
|---|---|---|---|
sms.outbound.{mnoUserId} |
Router outbox | Aggregator | An SMS chunk ready to send through one MNO user |
sms.reporting |
Router outbox | Client-service | Submitted, final, and campaign report updates |
client.channel-alerts |
Client outbox | Alert consumer | Channel-failure alerts when no webhook is configured |
*.dlt / sms.reporting.dlt |
Kafka error handlers | Operators and diagnostic consumers | Records that could not be processed |
Outbox and Kafka can deliver an event more than once. Consumers use stable IDs and saved status to handle normal duplicates safely. A DLT record is not replayed or refunded automatically.
Consistency and financial model¶
| Concern | How it is handled | Limitation |
|---|---|---|
| Concurrent spending | Lock the client balance and user allocations during updates | Requests for the same client may wait for the same balance lock |
| Repeated client requests | Keep a username-scoped idempotency record for 24 hours | Direct internal deductions are currently unkeyed; an unclear failure after deduction needs investigation |
| Campaign charge | Use a keyed deduction, reversal, and stale-campaign recovery | Client-service and router use separate database transactions |
| Delivery refund | Use stable refund keys and separate direct/campaign refund owners | 9099 and NOT_AVAILABLE are not automatically refunded |
| Duplicate provider sends | Save DISPATCHING and DISPATCHED chunk states |
A crash after the provider call has an unclear result and is not retried automatically |
| Reporting | Apply Kafka updates by recipient ID and campaign version | Reports are eventually consistent and may not update immediately |
Scheduling, recovery and scaling¶
- Router uses PostgreSQL-backed ShedLock for scheduled tasks that must run on only one instance.
- Outbox and row workers use
SKIP LOCKEDso different instances can process different rows. - Campaign recovery tasks handle stale draft, building, dispatching, and cancelling states.
- Router and client-service stop retrying outbox records after their configured recovery rounds. Operators must then investigate them.
- Aggregator consumers scale through Kafka topics and partitions. Provider speed remains a limit.
- Chunk status prevents normal duplicate dispatch.
- Eureka and load-balanced service names allow services to restart or scale without changing client URLs.
Known architecture constraints¶
- Infozillion is the only implemented provider integration. There is no public provider plug-in framework.
- Secondary sender routes are selected by configuration, not automatically after provider failure.
- Aggregator TPS settings are not used by the current send path and do not provide a cluster-wide limit.
- Code
9099is marked delivered and charged.NOT_AVAILABLEis final but unconfirmed. Neither receives an automatic refund. - A chunk in
DISPATCHINGhas no automatic stale recovery. - DLT records are generally not replayed or refunded automatically.
- Changing a user's password or status does not end existing access sessions immediately.
- Client-service uses port
9014in dev and Compose, while its Dockerfile health metadata refers to8081. Deployments must align or override these values.