An event-driven notification delivery system with dynamic, score-based channel selection, built on Spring Boot and Apache Kafka.
SignalLoop accepts a notification request, decides which channel (Email, SMS, or Push) is best suited to deliver it based on a configurable scoring algorithm, publishes the decision as an event to Kafka, and asynchronously delivers and tracks the outcome — recording both the delivery result and the reasoning behind the channel choice.
Most notification systems hardcode delivery logic: "if urgent, send SMS; otherwise, send email." SignalLoop replaces that with a weighted scoring model that combines three factors — the user's preferred channel, the notification's urgency, and each channel's live reliability (success/failure history) — into a single score per channel, and picks the highest-scoring one at request time.
The system is split into two phases:
- Synchronous phase — the API request is authenticated, validated, scored, and persisted. The scoring decision itself is recorded in an audit table for traceability.
- Asynchronous phase — a Kafka event is published for the created notification. A separate consumer picks up the event, resolves the appropriate channel sender via the Strategy pattern, attempts delivery, and updates the notification's status. Failures are retried through Kafka's retry topics before landing in a dead-letter topic.
Channel reliability itself is not static — every send outcome updates a per-channel success/failure/retry counter, which feeds back into the scoring algorithm on the next request. This creates a feedback loop where channels that are currently failing are automatically deprioritized.
flowchart TD
Client[Client] -->|HTTP + JWT| API[Spring Boot REST API]
API --> Security[Spring Security Filter Chain<br/>JWT Auth Filter]
Security --> Controller[Controllers]
Controller --> Service[Service Layer]
Service --> Scoring[Channel Scoring Strategy]
Scoring --> Redis[(Redis<br/>Cached Scoring Config)]
Service --> DB[(PostgreSQL)]
Service --> Producer[Kafka Producer]
Producer --> Kafka[[Kafka Topic:<br/>notification-events]]
Kafka --> Consumer[Kafka Consumer]
Consumer --> Processor[Notification Processor]
Processor --> Strategy[Channel Sender Strategy<br/>Email / SMS / Push]
Processor --> Metrics[Channel Metrics]
Metrics --> DB
Kafka -.retry exhausted.-> DLT[[Dead Letter Topic]]
- Stateless JWT authentication (HMAC-SHA256 signed tokens via
jjwt) - Role-based access control with
ROLE_USERandROLE_ADMIN - Method-level authorization via
@PreAuthorizeon admin endpoints - BCrypt password hashing
- Custom
UserDetailsServicebacked by the user repository
- Notification creation with dynamic channel selection at request time
- Channel decision persisted separately in an audit table (
notification_selection_audit) with the individual score for every channel - Notification status lifecycle:
PENDING → SENT/RETRYING/FAILED - Strategy-pattern channel senders (
EmailSender,SmsSender,PushSender) resolved at runtime through a factory
- Configurable weighted scoring across three factors: user channel preference, notification urgency, and channel reliability
- Factor weights and urgency-per-channel weights stored in the database and adjustable via an admin API
- Scoring configuration cached in Redis (
@Cacheable) and evicted on update (@CacheEvict)
- Per-channel success/failure/retry counters (
channel_metricstable) - Reliability score derived dynamically from historical success/failure counts and fed back into the scoring algorithm
- Notification events published to a dedicated topic (
notification-events) after creation - Consumer-side idempotency check (skips events for notifications already
SENT) - Automatic retry via
@RetryableTopicwith exponential backoff (4 attempts) - Dead-letter topic handling via
@DltHandler— marks the notificationFAILEDand records a reliability failure on exhaustion
- Redis-backed Spring Cache abstraction for the scoring configuration
- PostgreSQL with schema and seed data managed by Flyway migrations
- Six tables:
users,notifications,factor_percentage,urgency_weight,notification_selection_audit,channel_metrics
- MapStruct-based DTO ↔ entity mapping across all modules
- Bean validation (
jakarta.validation) on request payloads - Centralized exception handling via
@RestControllerAdvice, returning a consistent JSON error shape
- Spring Boot Actuator with
healthandinfoendpoints exposed
- Multi-container setup via Docker Compose: application, PostgreSQL, Redis, and Kafka (KRaft mode, no Zookeeper)
- Application Dockerfile built on
eclipse-temurin:21-jre
| Category | Technology |
|---|---|
| Language | Java 17 |
| Framework | Spring Boot 4.0.5 |
| Web | Spring Web (Spring MVC) |
| Security | Spring Security, JWT (io.jsonwebtoken / jjwt 0.13.0), BCrypt |
| Persistence | Spring Data JPA, PostgreSQL |
| Migrations | Flyway |
| Messaging | Apache Kafka, Spring for Apache Kafka |
| Caching | Spring Cache, Redis (Spring Data Redis) |
| Mapping | MapStruct |
| Boilerplate Reduction | Lombok |
| Resilience | Spring Retry, Spring AOP (spring-aspects) |
| Build Tool | Maven |
| Containerization | Docker, Docker Compose |
| Testing | JUnit 5, Mockito |
src/main/java/com/notification/system/
├── SystemApplication.java
│
├── auth/ # Authentication & authorization
│ ├── JwtAuthFilter.java # OncePerRequestFilter: extracts & validates JWT
│ ├── JwtAuthUtil.java # Token generation, parsing, validation
│ ├── SecurityConfig.java # Security filter chain, password encoder, auth provider
│ ├── controller/ # AuthController, AdminController
│ ├── dto/ # Login/Signup request & response DTOs
│ ├── enums/ # Role
│ └── service/ # JwtAuthService, CustomUserDetailsService
│
├── common/exception/ # GlobalExceptionHandler, ApiError
│
├── notification/
│ ├── audit/ # Channel-selection audit trail (entity, mapper, repository, service, controller)
│ ├── config/ # AsyncConfig (thread pool executor bean)
│ ├── controller/ # NotificationController
│ ├── dto/ # NotificationRequestDTO, NotificationResponseDTO
│ ├── entity/ # Notification
│ ├── enums/ # NotificationStatus
│ ├── exception/ # NotificationNotFoundException
│ ├── kafka/ # KafkaConfig, producer, consumer, event DTO/mapper, delivery exception
│ ├── mapper/ # NotificationMapper
│ ├── processor/ # NotificationProcessor (delivery orchestration)
│ ├── reliabilityMetrics/ # ChannelMetrics (entity, repository, service, mapper, controller, calculators)
│ ├── repository/ # NotificationRepository
│ ├── scoringAlogirthm/ # Scoring strategy, weights, factors, config service, Redis config
│ └── strategy/ # NotificationSender interface + EmailSender/SmsSender/PushSender + factory
│
└── user/ # User entity, DTOs, mapper, repository, service, controller
All schema objects are created via Flyway migration V1__initial_schema.sql; seed data for the scoring configuration comes from V2__seed_scoring_configuration.sql.
| Table | Purpose |
|---|---|
users |
Application users. Stores email, hashed password, timezone, preferred channel, and role. |
notifications |
Core notification records: message, urgency, assigned channel, delivery status, retry count. References users. |
factor_percentage |
Weight (%) assigned to each scoring factor (USER_PREFERENCE, URGENCY, RELIABILITY). Editable via admin API. |
urgency_weight |
Weight (%) for each (urgency, channel) pair — e.g. HIGH/SMS. Unique per combination. |
notification_selection_audit |
Records the per-channel score (email/SMS/push) and the selected channel for every notification, for traceability. References notifications. |
channel_metrics |
Running success/failure/retry counters per channel, keyed by channel name. Feeds the reliability factor in scoring. |
Relationships:
notifications.user_id → users.id(many-to-one)notification_selection_audit.notification_id → notifications.notification_id(many-to-one)channel_metrics.channelis the primary key (one row perChannelenum value:EMAIL,SMS,PUSH)
Indexes exist on notifications.user_id and notifications.notification_status for lookup performance.
sequenceDiagram
participant C as Client
participant API as NotificationController
participant S as NotificationService
participant Score as DefaultChannelScoringStrategy
participant DB as PostgreSQL
participant K as Kafka
participant Cons as NotificationEventConsumer
participant Proc as NotificationProcessor
participant Send as Channel Sender
C->>API: POST /notifications (JWT)
API->>S: createNotification(request)
S->>DB: load User
S->>Score: score(user, notification, channel) for EMAIL/SMS/PUSH
Score-->>S: scores per channel
S->>DB: save Notification (status=PENDING)
S->>DB: save NotificationAudit (scores + selected channel)
S->>K: publish NotificationEventDTO
S-->>C: NotificationResponseDTO
K->>Cons: consume event
Cons->>Proc: processNotification(user, notification)
Proc->>Send: sendNotification(user, notification)
alt delivery succeeds
Proc->>DB: status = SENT, record success metric
else delivery fails
Proc->>DB: status = RETRYING, record retry metric
Proc->>K: retry via @RetryableTopic
end
Note over K: after retries exhausted
K->>Cons: @DltHandler
Cons->>DB: status = FAILED, record failure metric
Request lifecycle in short: JWT authentication → bean validation → channel scoring → persistence → audit record → Kafka publish → async consumption → strategy-based delivery → status update → reliability metric update.
SignalLoop uses stateless JWT authentication:
- Signup (
POST /auth/signup) creates aROLE_USERaccount with a BCrypt-hashed password. - Login (
POST /auth/login) authenticates credentials via Spring Security'sAuthenticationManagerand returns a signed JWT (LoginResponseDTO) containing the user ID. - Every subsequent request must include
Authorization: Bearer <token>. JwtAuthFilter(aOncePerRequestFilter, registered beforeUsernamePasswordAuthenticationFilter) extracts and validates the token, loads the user viaCustomUserDetailsService, and populates theSecurityContext.- Tokens are signed with HMAC-SHA256, expire 24 hours after issuance, and carry
userIdas a custom claim. - Endpoints under
/auth/**are public; every other endpoint requires a valid token (SecurityConfig). - Admin-only endpoints (
/admin/**) are additionally restricted with@PreAuthorize("hasRole('ADMIN')").
Spring Boot Actuator is enabled with the following endpoints exposed:
GET /actuator/health— application health statusGET /actuator/info— application info
No additional Actuator endpoints (metrics, Prometheus, etc.) are exposed in the current configuration.
The stack is defined in docker-compose.yml with four services:
| Service | Image | Purpose |
|---|---|---|
postgres |
postgres:16 |
Primary datastore |
redis |
redis:7-alpine |
Scoring configuration cache |
kafka |
apache/kafka:4.1.0 |
Event broker (KRaft mode — combined broker + controller, no Zookeeper) |
notification-system |
built from local Dockerfile |
The Spring Boot application |
The application container depends on all three infrastructure services and connects to them using environment-variable-driven connection settings. The Dockerfile runs the pre-built jar (target/*.jar) on eclipse-temurin:21-jre and exposes port 8080.
docker compose up -d --buildConfiguration is split across three files:
application.yml— shared defaults: Kafka topic settings (notification-events, 3 partitions, 3 replicas), Redis cache name, Flyway settings, server port (8080), and the active profile (devby default).application-dev.yml— local development: SQL/Hibernate debug logging enabled,ddl-auto: validate, connects tolocalhostRedis.application-prod.yml— production: verbose SQL logging disabled, Redis/Kafka hosts read from environment variables.
Profile is selected via spring.profiles.active (defaults to dev; override with SPRING_PROFILES_ACTIVE in Docker/production).
- Java 17
- Maven (or use the included
mvnwwrapper) - Docker & Docker Compose (for PostgreSQL, Redis, Kafka)
git clone <repository-url>
cd signalLoop-devCopy the example file and fill in real values:
cp .env.example .envdocker compose up -d postgres redis kafkaFlyway runs automatically on application startup (spring.flyway.enabled: true).
With Maven:
./mvnw spring-boot:runOr build the full stack (app + infra) with Docker Compose:
docker compose up -d --buildcurl http://localhost:8080/actuator/health| Variable | Purpose | Required | Default |
|---|---|---|---|
POSTGRES_DB |
Postgres database name (Compose) | Yes | notification_db |
POSTGRES_USER |
Postgres username (Compose) | Yes | postgres |
POSTGRES_PASSWORD |
Postgres password (Compose) | Yes | postgres |
DB_URL |
JDBC URL used by the application | Yes | jdbc:postgresql://postgres:5432/notification_db |
DB_USERNAME |
Application datasource username | Yes | postgres |
DB_PASSWORD |
Application datasource password | Yes | postgres |
JWT_SECRET |
HMAC signing key for JWT tokens | Yes | — |
REDIS_HOST |
Redis host | Yes | redis |
REDIS_PORT |
Redis port | Yes | 6379 |
KAFKA_BOOTSTRAP_SERVERS |
Kafka broker address(es) | Yes (prod) | localhost:9092 (dev default in application.yml) |
SPRING_PROFILES_ACTIVE |
Active Spring profile | No | dev |
curl -X POST http://localhost:8080/auth/signup \
-H "Content-Type: application/json" \
-d '{
"email": "<email>",
"password": "<password>",
"preferredChannel": "EMAIL",
"timezone": "Asia/Kolkata"
}'curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "<email>",
"password": "<password>"
}'curl -X POST http://localhost:8080/notifications \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <JWT_TOKEN>" \
-d '{
"userId": 1,
"message": "Your order has shipped",
"urgency": "HIGH"
}'curl -X GET http://localhost:8080/notifications/1 \
-H "Authorization: Bearer <JWT_TOKEN>"curl -X GET http://localhost:8080/notifications/status/PENDING \
-H "Authorization: Bearer <JWT_TOKEN>"curl -X GET http://localhost:8080/notifications/user/1 \
-H "Authorization: Bearer <JWT_TOKEN>"curl -X GET http://localhost:8080/notifications/audit/1 \
-H "Authorization: Bearer <JWT_TOKEN>"curl -X GET http://localhost:8080/metrics/channels/EMAIL \
-H "Authorization: Bearer <JWT_TOKEN>"curl -X GET "http://localhost:8080/admin/all?page=0&size=20&sort=createdAt,desc" \
-H "Authorization: Bearer <ADMIN_JWT_TOKEN>"curl -X PUT http://localhost:8080/admin/scoring/factors \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <ADMIN_JWT_TOKEN>" \
-d '{
"factorWeights": {
"USER_PREFERENCE": 50,
"URGENCY": 30,
"RELIABILITY": 20
}
}'- Authentication: JWT (HMAC-SHA256), validated on every request via a custom
OncePerRequestFilter. - Password storage: BCrypt via Spring Security's
PasswordEncoder. - Session management: Stateless (
SessionCreationPolicy.STATELESS) — no server-side session state. - CSRF: Disabled, appropriate for a stateless, token-authenticated API.
- Authorization: Role-based (
ROLE_USER,ROLE_ADMIN) enforced with@PreAuthorizeon admin routes, in addition to the global authenticated-by-default rule inSecurityConfig.
A centralized @RestControllerAdvice (GlobalExceptionHandler) maps exceptions to a consistent JSON error body (ApiError: status, message, timestamp):
| Exception | HTTP Status |
|---|---|
UsernameNotFoundException |
404 Not Found |
NotificationNotFoundException |
404 Not Found |
EmailExistException |
409 Conflict |
AuthenticationException |
401 Unauthorized |
JwtException |
401 Unauthorized |
AccessDeniedException |
403 Forbidden |
NotificationDeliveryException |
500 Internal Server Error |
MethodArgumentNotValidException (bean validation failures) |
400 Bad Request |
Any other unhandled Exception |
500 Internal Server Error |
- Fork the repository and create a feature branch from
dev. - Follow the existing package-by-feature structure.
- Add tests for any service-layer changes (JUnit 5 + Mockito).
- Make sure
mvn clean verifypasses locally. - Open a PR into
devwith a clear description of what changed and why.
MIT — see LICENSE.