CI

Real-time SMS routing engine simulating a Mobile Virtual Network Operator (MVNO) core network component. Built with Java 21, Spring Boot 3.2, and Apache Kafka.

Getting Started

Prerequisites

  • Docker & Docker Compose

  • Java 21 (for local development)

Quick start

git clone https://github.com/guykopa/smsrouter.git
cd smsrouter
cp .env.example .env
docker compose -f docker/docker-compose.yml --env-file .env up zookeeper kafka kafka-ui -d
./mvnw spring-boot:run

The application starts on http://localhost:8080. Kafka UI is available on http://localhost:8090.

Send your first SMS

curl -s -X POST http://localhost:8080/api/sms/send \
  -H "Content-Type: application/json" \
  -d '{"from":"+33612345678","to":"+447911123456","text":"Hello"}' | jq .
Response
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "DELIVERED",
  "operator": "EE",
  "latencyMs": 32
}

Available endpoints

Method Path Description

GET

/

List available endpoints

POST

/api/sms/send

Submit an SMS for routing

GET

/actuator/health

Application health

GET

/actuator/metrics

Available metrics

E.164 routing table

Prefix Primary operator Fallback

+44

EE

Vodafone UK

+33

Orange FR

SFR

+49

Deutsche Telekom

Vodafone DE

+1

AT&T

T-Mobile US

+39

TIM

Vodafone IT

Architecture

Pattern: Event-Driven Hexagonal Architecture

smsrouter separates business routing logic from infrastructure concerns. The domain never knows about Kafka, Spring, or HTTP. Every state change produces a domain event published asynchronously to Kafka.

Dependency rule

REST Controller  (inbound adapter)
       ↓
SendSmsUseCase   (application)
       ↓
SmsRoutingService + PrefixResolver  (domain)
       ↓ depends on interfaces only
Ports: SmsPublisherPort, OperatorRegistryPort, SmsDeliveryPort
       ↑ implemented by
KafkaSmsPublisher, InMemoryOperatorRegistry, SimulatedSmsDelivery  (outbound adapters)

SOLID mapping

Principle Application

S

PrefixResolver only resolves operators. SmsRoutingService only routes. KafkaSmsPublisher only publishes.

O

Add a new operator registry by implementing OperatorRegistryPort — zero existing files modified.

L

InMemoryOperatorRegistry and any future DatabaseOperatorRegistry are substitutable wherever OperatorRegistryPort is expected.

I

SmsPublisherPort, OperatorRegistryPort, SmsDeliveryPort are three separate interfaces — no class depends on methods it does not use.

D

SmsRoutingService depends on OperatorRegistryPort and SmsDeliveryPort, never on concrete adapters.

Domain models

All domain models are Java records — immutable, no setters.

record SmsMessage(UUID id, String from, String to, String text, Instant timestamp) {}
record Operator(String name, String prefix, String country, int priority) {}
record RoutingResult(Operator operator, SmsStatus status, long latencyMs) {}
record SmsEvent(SmsEventType eventType, UUID smsId, Instant timestamp, Map<String,Object> payload) {}

SMS lifecycle

POST /api/sms/send
        │
        ▼  publish SMS_RECEIVED → sms.inbound
        │
        ▼  SmsRoutingService.resolveOperator()
        │  publish SMS_ROUTED → sms.events
        │
        ▼  SimulatedSmsDelivery.deliver()
        │
        ├── SUCCESS → publish SMS_DELIVERED → sms.events
        │
        └── FAILURE
            ├── publish SMS_FAILED → sms.dlq
            └── reason: DELIVERY_FAILED | UNROUTABLE

Extension points

To add a new delivery protocol (e.g. SMPP):

  1. Create port/SmppGatewayPort.java

  2. Write unit/SmppDeliveryTest.java — RED

  3. Create adapter/smpp/SmppDeliveryAdapter.java — GREEN

  4. Zero existing files modified.

REST API Reference

GET /

Returns the list of available endpoints.

Response 200 OK
{
  "application": "smsrouter",
  "endpoints": [
    { "method": "POST", "path": "/api/sms/send",     "description": "Submit an SMS for routing" },
    { "method": "GET",  "path": "/actuator/health",  "description": "Application health" },
    { "method": "GET",  "path": "/actuator/metrics", "description": "Application metrics" }
  ]
}

POST /api/sms/send

Submit an SMS message for routing and delivery.

Request body

Field Type Description

from

String

E.164 sender phone number (e.g. +33612345678)

to

String

E.164 recipient phone number (e.g. +447911123456)

text

String

SMS body

curl -s -X POST http://localhost:8080/api/sms/send \
  -H "Content-Type: application/json" \
  -d '{"from":"+33612345678","to":"+447911123456","text":"Hello"}'

Response 202 Accepted

Field Type Description

id

UUID

Unique message identifier

status

String

DELIVERED or FAILED

operator

String

Operator that handled the SMS

latencyMs

long

Delivery latency in milliseconds

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "DELIVERED",
  "operator": "EE",
  "latencyMs": 32
}

Response 422 Unprocessable Entity

Returned when no operator matches the destination prefix.

{
  "error": "Unroutable",
  "phoneNumber": "+99999999999"
}
An SMS_FAILED event with reason: UNROUTABLE is also published to sms.dlq.

GET /actuator/health

{ "status": "UP" }

Kafka Topics & Events

Topics

Topic Content

sms.inbound

SMS_RECEIVED — one message per incoming SMS

sms.events

SMS_ROUTED, SMS_DELIVERED, SMS_RETRY

sms.dlq

SMS_FAILED — delivery failures and unroutable numbers

Event types

Event Topic Published when

SMS_RECEIVED

sms.inbound

SMS enters the system

SMS_ROUTED

sms.events

Operator resolved

SMS_DELIVERED

sms.events

Delivery succeeded

SMS_FAILED

sms.dlq

Delivery failed or number unroutable

Event payload

All events share the same SmsEvent structure:

{
  "eventType": "SMS_RECEIVED",
  "smsId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "timestamp": 1777497354.941798487,
  "payload": { }
}

SMS_RECEIVED

{ "from": "+33612345678", "to": "+447911123456" }

SMS_ROUTED

{ "operator": "EE", "prefix": "+44" }

SMS_DELIVERED

{ "operator": "EE", "status": "DELIVERED", "latencyMs": 32 }

SMS_FAILED (delivery)

{ "operator": "EE", "status": "FAILED", "latencyMs": 28 }

SMS_FAILED (unroutable)

{ "reason": "UNROUTABLE", "phoneNumber": "+99999999999" }

Kafka UI

Visualise all topics and messages in real time at http://localhost:8090.

Cluster configured: smsrouter-localkafka:29092.

Development Guide

TDD cycle — mandatory for every class

  1. RED: write failing tests before the class exists

  2. GREEN: write the minimum code to make tests pass

  3. REFACTOR: clean without breaking any test

  4. Run ./mvnw test after every GREEN and REFACTOR phase

Run tests

# Unit tests only (fast, no Kafka)
./mvnw test

# Full build including integration tests (embedded Kafka)
./mvnw clean verify

Test structure

Package Content

unit/

Pure unit tests — fake adapters, zero Spring, zero Kafka

integration/

@SpringBootTest + @EmbeddedKafka — full context

fixture/

Shared fakes: FakeOperatorRegistry, FakeSmsPublisher, FakeSmsDelivery

Project structure

src/main/java/com/smsrouter/
├── domain/
│   ├── model/       ← Java records, no Spring
│   ├── service/     ← PrefixResolver, SmsRoutingService
│   └── exception/   ← UnroutableSmsException, OperatorUnavailableException
├── port/            ← SmsPublisherPort, OperatorRegistryPort, SmsDeliveryPort
├── adapter/
│   ├── kafka/       ← KafkaSmsPublisher
│   ├── registry/    ← InMemoryOperatorRegistry
│   └── delivery/    ← SimulatedSmsDelivery
├── application/     ← SendSmsUseCase, SmsRouterConfig
└── controller/      ← SmsController, HomeController

Build documentation

./mvnw asciidoctor:process-asciidoc
# Output: target/generated-docs/index.html

Docker

# Start infrastructure only
docker compose -f docker/docker-compose.yml --env-file .env up zookeeper kafka kafka-ui -d

# Start full stack (builds app image)
docker compose -f docker/docker-compose.yml --env-file .env up