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 .
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "DELIVERED",
"operator": "EE",
"latencyMs": 32
}
Available endpoints
| Method | Path | Description |
|---|---|---|
|
|
List available endpoints |
|
|
Submit an SMS for routing |
|
|
Application health |
|
|
Available metrics |
E.164 routing table
| Prefix | Primary operator | Fallback |
|---|---|---|
|
EE |
Vodafone UK |
|
Orange FR |
SFR |
|
Deutsche Telekom |
Vodafone DE |
|
AT&T |
T-Mobile US |
|
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 |
|
O |
Add a new operator registry by implementing |
L |
|
I |
|
D |
|
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):
-
Create
port/SmppGatewayPort.java -
Write
unit/SmppDeliveryTest.java— RED -
Create
adapter/smpp/SmppDeliveryAdapter.java— GREEN -
Zero existing files modified.
REST API Reference
Base URL: http://localhost:8080
GET /
Returns the list of available endpoints.
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 |
|---|---|---|
|
String |
E.164 sender phone number (e.g. |
|
String |
E.164 recipient phone number (e.g. |
|
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 |
|---|---|---|
|
UUID |
Unique message identifier |
|
String |
|
|
String |
Operator that handled the SMS |
|
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 |
|---|---|
|
|
|
|
|
|
Event types
| Event | Topic | Published when |
|---|---|---|
|
|
SMS enters the system |
|
|
Operator resolved |
|
|
Delivery succeeded |
|
|
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-local → kafka:29092.
Development Guide
TDD cycle — mandatory for every class
-
RED: write failing tests before the class exists
-
GREEN: write the minimum code to make tests pass
-
REFACTOR: clean without breaking any test
-
Run
./mvnw testafter 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 |
|---|---|
|
Pure unit tests — fake adapters, zero Spring, zero Kafka |
|
|
|
Shared fakes: |
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