Skip to content

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
Hold "Ctrl" to enable pan & zoom

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

  1. Login and refresh requests go to client-service without an existing access token.
  2. Protected /api/** requests send a JWT created by client-service.
  3. Gateway checks the JWT signature and expiry and confirms that sms:token:{jwt} exists in Redis.
  4. Gateway adds trusted client ID, user ID, username, and status headers.
  5. 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
Hold "Ctrl" to enable pan & zoom

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 LOCKED so 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 9099 is marked delivered and charged. NOT_AVAILABLE is final but unconfirmed. Neither receives an automatic refund.
  • A chunk in DISPATCHING has 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 9014 in dev and Compose, while its Dockerfile health metadata refers to 8081. Deployments must align or override these values.

Detailed architecture