A lightweight Spring Boot notification service that publishes and consumes notification events via Kafka, supports multiple delivery channels (email, SMS, push), and uses an outbox pattern for reliable delivery.
Contents
- Overview
- Architecture
- Key Components
- Data Flow
- Decisions & Tradeoffs
- How to run
- Where to look in the codebase
- Future improvements
This project implements a notification microservice built with Spring Boot and Apache Kafka. It receives requests to create notifications, persists them, emits domain events to Kafka, consumes events, and processes delivery through pluggable channels.
Architecture diagram (high-level):
Diagram notes:
- The service uses an outbox to ensure database and message broker write consistency.
- Kafka topics are defined in src/main/java/com/dinesh/notificationservice/infrastructure/kafka/KafkaTopics.java.
- Notification events are partitioned to preserve ordering for related notification streams.
- API: NotificationController (src/main/java/com/dinesh/notificationservice/api/controller/NotificationController.java) — accepts HTTP requests.
- Service layer: NotificationService (src/main/java/com/dinesh/notificationservice/domain/service/NotificationService.java) — business logic and orchestration.
- Persistence: NotificationRepository, OutboxRepository (src/main/java/com/dinesh/notificationservice/infrastructure/repository/NotificationRepository.java).
- Messaging: NotificationProducer, NotificationConsumer, OutboxPublisher (src/main/java/com/dinesh/notificationservice/infrastructure/messaging).
- Worker/Processing: NotificationProcessor, channel implementations (src/main/java/com/dinesh/notificationservice/worker).
- Client hits
POST /notifyhandled byNotificationController. - Controller calls
NotificationServicewhich validates and persists aNotificationentity. - A corresponding
OutboxEventis created and saved to the outbox table. OutboxPublisherpublishes the event to Kafka topics.NotificationConsumerconsumes events and hands them toNotificationProcessor.NotificationProcessorroutes to a channel viaChannelFactory(email/sms/push) and attempts delivery.- If delivery fails, retry logic in
RetryWorker/RetryBackoffCalculatorhandles backoff and dead-lettering (DlqProducer).
- Idempotency is implemented at request-level.
NotificationRequestincludesidempotencyKey,NotificationServicechecksnotificationRepository.findByIdempotencyKey(...), and theNotificationentity has a unique database index onidempotencyKey. - Ordering is supported through Kafka partitioning.
KafkaTopicConfigcreatesnotification-eventsandnotification-dlqwith 3 partitions, whileNotificationProducerusesuserId_typeas the Kafka key. - Reliability is strong via the outbox pattern.
OutboxEventrecords are persisted first and published later byOutboxPublisher, reducing the risk of lost events during transient broker failures. - Failure handling exists for both retries and dead-lettering.
RetryWorker/RetryBackoffCalculatormanage retry attempts, andNotificationProcessorusesDlqProducerto send failed events to the DLQ. - Important implementation note: There is a topic name mismatch risk in the current codebase:
NotificationProducersends tonotifications-eventswhileNotificationConsumerlistens onnotification-events. This should be aligned before production use. - Response semantics: The API returns success after persistence and outbox creation; delivery is eventually consistent, not synchronous.
- Code organization: Clear separation exists between API, domain service, persistence, messaging infra, and worker/processor layers, which makes the service easier to maintain and extend.
- Outbox Pattern: chosen to guarantee atomicity between DB writes and Kafka publishes in environments without distributed transactions. Tradeoff: additional storage and publishing complexity; requires a background publisher (
OutboxPublisher). - Kafka for async delivery: provides scalability and decoupling. Tradeoff: operational complexity (brokers, partitioning, monitoring) versus simpler alternatives like in-process queues.
- Pluggable Channels:
ChannelFactoryenables switching/adding channels with minimal code changes. Tradeoff: slightly more indirection and abstraction. - Retry Strategy: exponential backoff via
RetryBackoffCalculatorand dedicatedRetryWorkergives robust delivery attempts. Tradeoff: increased implementation complexity and potential duplicate-delivery handling requirements. - DLQ (Dead Letter Queue): failed events are routed to a DLQ topic using
DlqProducer. This simplifies failure handling but requires human/operator processes to inspect and repair DLQ messages. - Synchronous API response: The controller responds quickly after persisting and scheduling an outbox publish, improving client latency at the cost of eventual consistency for delivery.
- Start Kafka and Zookeeper (local or docker)
# With Docker Compose (if you have Kafka compose services available)
docker-compose up -d
# Build and run the app
./mvnw clean package
./mvnw spring-boot:run- Configuration is in src/main/resources/application.properties.
- Application entry: src/main/java/com/dinesh/notificationservice/Application.java
- API layer: src/main/java/com/dinesh/notificationservice/api/controller/NotificationController.java
- Domain: src/main/java/com/dinesh/notificationservice/domain/model
- Messaging infra: src/main/java/com/dinesh/notificationservice/infrastructure/messaging
- Worker: src/main/java/com/dinesh/notificationservice/worker
- Unit tests are under
src/test/java(run with./mvnw test). - Integration tests should run against a Kafka test fixture or a testcontainer-based Kafka.
- Extend request-level idempotency to stronger exactly-once semantics and replay-safe outbox processing.
- Add metrics and distributed tracing (Prometheus + OpenTelemetry).
- Provide Kubernetes manifests and Helm chart for production deployment.
- Add automated DLQ replay tooling and a small web UI for observability.