A realistic, carrier-grade M-Pesa-inspired wallet system built with Spring Boot, Kafka, and MySQL. This system demonstrates core mobile money functionality including wallet management, money transfers, transaction processing, and notifications.
✅ Wallet Management - Create and manage digital wallets ✅ Money Transfers - Send money between wallets with validation ✅ Async Processing - Transaction processing via Kafka message queues ✅ Double-Spending Prevention - Pessimistic locking and optimistic locking (versioning) ✅ Idempotency - Duplicate transaction prevention using idempotency keys ✅ Retry Support - Built-in Kafka retry mechanisms ✅ SMS Notifications - Simulated async notifications for transaction events ✅ Ledger Accounting - Double-entry bookkeeping for audit trails
Client
↓
API Gateway (REST Controllers)
↓
Transaction Service ──▶ Kafka (txn.created)
↓
Ledger Service
(Process Transaction)
↓
Kafka (txn.completed)
↓
Notification Service
(Send SMS)
- Transaction Service: Initiates transfers and publishes to Kafka
- Ledger Service: Consumes transactions, updates balances with pessimistic locks
- Notification Service: Sends async SMS notifications
- Wallet Service: Manages wallet operations
- Spring Boot 4.0.1 (Java 17)
- Apache Kafka - Async messaging
- MySQL 8.0 - Persistent storage
- Flyway - Database migrations
- Redis - Session management
- Lombok - Boilerplate reduction
- Java 17+
- Maven 3.6+
- MySQL 8.0+ (running locally)
You have three options for running the required infrastructure:
- Install Docker Desktop for Mac: https://www.docker.com/products/docker-desktop/
- Install and start Docker Desktop
- Run from project root:
docker compose up -d
This starts:
- MySQL on port 3306
- Kafka on port 9092
- Zookeeper on port 2181
- Redis on port 6379
Install MySQL:
brew install mysql
brew services start mysqlInstall Kafka (includes Zookeeper):
brew install kafka
brew install zookeeper
# Start Zookeeper first
brew services start zookeeper
# Then start Kafka
brew services start kafkaInstall Redis (Optional):
brew install redis
brew services start redisIf you only have MySQL installed:
- The application will run in synchronous mode
- Kafka auto-configuration is disabled in
application.yml - Wallet creation and balance queries work
- Transaction initiation works, but async processing (ledger updates, notifications) won't run
- Good for development/testing wallet APIs
Note: The database telco_wallets will be created automatically via createDatabaseIfNotExist=true in the JDBC URL.
./mvnw clean install./mvnw spring-boot:runThe application will start on http://localhost:8080
Endpoint: POST /api/wallets
Request:
{
"phoneNumber": "254712345678"
}Validation:
- Phone number format:
^254[0-9]{9}$(Kenyan format) - Must be unique
Response (201 Created):
{
"id": 1,
"phoneNumber": "254712345678",
"balance": 0.00,
"currency": "KES",
"status": "ACTIVE"
}Example:
curl -X POST http://localhost:8080/api/wallets \
-H "Content-Type: application/json" \
-d '{"phoneNumber":"254712345678"}'Endpoint: POST /api/wallets/deposit
Request:
{
"phoneNumber": "254712345678",
"amount": 5000.00,
"description": "Initial deposit"
}Validation:
- Amount: min 1.0, max 300,000.0
- Phone number must exist
Response (200 OK):
{
"id": 1,
"phoneNumber": "254712345678",
"balance": 5000.00,
"currency": "KES",
"status": "ACTIVE"
}Example:
curl -X POST http://localhost:8080/api/wallets/deposit \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "254712345678",
"amount": 5000.00,
"description": "Initial deposit"
}'Key Features:
- Uses pessimistic locking (
FOR UPDATE) to prevent race conditions - Thread-safe for concurrent deposits
- Immediate balance update
Endpoint: GET /api/wallets/{phoneNumber}
Response (200 OK):
{
"id": 1,
"phoneNumber": "254712345678",
"balance": 5000.00,
"currency": "KES",
"status": "ACTIVE"
}Example:
curl http://localhost:8080/api/wallets/254712345678Endpoint: POST /api/transactions/transfer
Request:
{
"senderPhoneNumber": "254712345678",
"receiverPhoneNumber": "254787654321",
"amount": 1000.00,
"idempotencyKey": "unique-key-123",
"description": "Payment for services"
}Validation:
- Amount: min 1.0, max 300,000.0
- Both phone numbers must exist
- Sender must have sufficient balance
idempotencyKeyis required (prevents duplicate transactions)
Response (201 Created):
{
"transactionId": "TXN-abc123...",
"senderPhoneNumber": "254712345678",
"receiverPhoneNumber": "254787654321",
"amount": 1000.00,
"currency": "KES",
"status": "PENDING",
"description": "Payment for services",
"createdAt": "2026-01-16T10:30:00"
}Example:
curl -X POST http://localhost:8080/api/transactions/transfer \
-H "Content-Type: application/json" \
-d '{
"senderPhoneNumber": "254712345678",
"receiverPhoneNumber": "254787654321",
"amount": 1000.00,
"idempotencyKey": "unique-key-123",
"description": "Payment for services"
}'Note: If Kafka is enabled, the transaction will be processed asynchronously. Otherwise, it remains in PENDING status.
Endpoint: GET /api/transactions/{transactionId}
Response (200 OK):
{
"transactionId": "TXN-abc123...",
"senderPhoneNumber": "254712345678",
"receiverPhoneNumber": "254787654321",
"amount": 1000.00,
"currency": "KES",
"status": "COMPLETED",
"description": "Payment for services",
"createdAt": "2026-01-16T10:30:00"
}Example:
curl http://localhost:8080/api/transactions/TXN-abc123...Transaction Status Values:
PENDING- Transaction created, awaiting processingPROCESSING- Currently being processed by ledger serviceCOMPLETED- Successfully completedFAILED- Processing failed (checkfailureReason)
# Create first wallet
curl -X POST http://localhost:8080/api/wallets \
-H "Content-Type: application/json" \
-d '{"phoneNumber":"254712345678"}'
# Create second wallet
curl -X POST http://localhost:8080/api/wallets \
-H "Content-Type: application/json" \
-d '{"phoneNumber":"254787654321"}'# Deposit to first wallet
curl -X POST http://localhost:8080/api/wallets/deposit \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "254712345678",
"amount": 10000.00,
"description": "Initial deposit"
}'
# Deposit to second wallet
curl -X POST http://localhost:8080/api/wallets/deposit \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "254787654321",
"amount": 5000.00,
"description": "Initial deposit"
}'curl http://localhost:8080/api/wallets/254712345678
curl http://localhost:8080/api/wallets/254787654321Expected: Wallet 1 has KES 10,000, Wallet 2 has KES 5,000
curl -X POST http://localhost:8080/api/transactions/transfer \
-H "Content-Type: application/json" \
-d '{
"senderPhoneNumber": "254712345678",
"receiverPhoneNumber": "254787654321",
"amount": 1000,
"idempotencyKey": "test-txn-001",
"description": "Test transfer"
}'Response includes transaction ID
curl http://localhost:8080/api/transactions/{transactionId}Status should be COMPLETED
curl http://localhost:8080/api/wallets/254712345678 # Should show 9,000
curl http://localhost:8080/api/wallets/254787654321 # Should show 6,000# Send same request again with same idempotency key
curl -X POST http://localhost:8080/api/transactions/transfer \
-H "Content-Type: application/json" \
-d '{
"senderPhoneNumber": "254712345678",
"receiverPhoneNumber": "254787654321",
"amount": 1000,
"idempotencyKey": "test-txn-001",
"description": "Test transfer"
}'Expected: Returns existing transaction, no duplicate charge
curl -X POST http://localhost:8080/api/transactions/transfer \
-H "Content-Type: application/json" \
-d '{
"senderPhoneNumber": "254787654321",
"receiverPhoneNumber": "254712345678",
"amount": 100000,
"idempotencyKey": "test-txn-002",
"description": "Large transfer"
}'Expected: HTTP 400 - Insufficient balance error
Pessimistic Locking: Uses @Lock(LockModeType.PESSIMISTIC_WRITE) to lock wallet rows during transaction processing
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT w FROM Wallet w WHERE w.id = :id")
Optional<Wallet> findByIdForUpdate(@Param("id") Long id);Optimistic Locking: Uses @Version on Wallet entity for concurrent modification detection
Each transfer requires unique idempotencyKey. Duplicate keys return existing transaction:
if (transactionRepository.existsByIdempotencyKey(request.getIdempotencyKey())) {
return existingTransaction;
}- Client → Transaction Service creates PENDING transaction
- Transaction Service → Kafka publishes
txn.createdevent - Kafka → Ledger Service consumes event
- Ledger Service processes with locks, updates balances
- Ledger Service → Kafka publishes
txn.completedevent - Kafka → Notification Service sends SMS notifications
txn.created- New transactions to processtxn.completed- Successfully completed transactionstxn.failed- Failed transactions with reasonsnotification.request- SMS notification requests
- wallets - User wallet data with balances
- transactions - Transaction records with status
- ledger_entries - Double-entry bookkeeping audit trail
- notifications - SMS notification history
Check logs for transaction flow:
# Application logs
tail -f logs/spring.log
# Kafka topics
docker exec -it telco-kafka kafka-console-consumer \
--bootstrap-server localhost:9092 \
--topic txn.created --from-beginningThis is a realistic implementation demonstrating core concepts. For production:
- Add authentication/authorization (JWT, OAuth2)
- Implement rate limiting and throttling
- Add comprehensive monitoring (Prometheus, Grafana)
- Implement circuit breakers (Resilience4j)
- Add distributed tracing (Zipkin, Jaeger)
- Implement proper secret management
- Add comprehensive integration tests
- Configure Kafka for high availability (replication factor > 1)
- Implement transaction reversal workflows
- Add KYC validation
- Implement PIN/password verification
- Add fraud detection mechanisms
MIT License