While working on my project Finovara, I recently implemented a flexible notification system that reacts to user actions — like reaching a savings goal in a piggy bank.
Instead of going with a rigid structure, I decided to build something extensible and future-proof.
🧠 The Problem
Users perform different actions in the system:
- reaching a savings goal
- completing financial milestones
- (more coming soon…)
Each of these events should generate a notification, but with different payloads.
I didn’t want:
- dozens of tables
- messy conditional logic
- tightly coupled notification handling
⚙️ The Approach
I used JSON polymorphism with Jackson to store notifications in a single table, while keeping type-specific structures.
Each notification implements a common interface:
public interface NotificationResponse {
NotificationType type();
LocalDate createdAt();
String deduplicationKey();
}
And then I define specific types like this:
@JsonTypeName("PIGGY_BANK_GOAL_REACHED")
public record PiggyBankReachedDto(
NotificationType type,
LocalDate createdAt,
PiggyBankGoalType goalType,
String piggyBankName,
Long piggyBankId,
BigDecimal threshold
) implements NotificationResponse {
@Override
public String deduplicationKey() {
return "%s:%d:%s:%s".formatted(type, piggyBankId, goalType, threshold);
}
}
🧩 Generic Notification Engine
To avoid duplicating logic, I introduced a generic abstraction:
public abstract class ThresholdReachedService<E, C> implements NotificationCreator {
protected static final BigDecimal REACHED_THRESHOLD = BigDecimal.valueOf(100);
protected abstract List<E> fetchEntities(Long userId);
protected abstract C calculate(E entity, Long userId);
protected abstract BigDecimal getPercentage(C context);
protected abstract NotificationResponse buildNotification(E entity, C context, Long userId);
@Override
public List<NotificationResponse> getNotifications(Long userId) {
List<NotificationResponse> result = new ArrayList<>();
for (E entity : fetchEntities(userId)) {
C context = calculate(entity, userId);
BigDecimal percentage = getPercentage(context);
if (percentage.compareTo(REACHED_THRESHOLD) >= 0) {
result.add(buildNotification(entity, context, userId));
}
}
return result;
}
}
This allows me to:
- fetch domain entities
- calculate progress
- generate notifications only when a threshold is reached
💾 Persistence with Deduplication
One tricky part: avoiding duplicate notifications.
I solved it with a deduplicationKey:
.filter(dto -> !existingKeys.contains(dto.deduplicationKey()))
.filter(dto -> batchKeys.add(dto.deduplicationKey()))
This ensures:
- no duplicates from previous runs
- no duplicates within the same batch
🔄 Serialization Layer
Notifications are stored as JSON:
payload(toJson(dto))
and safely deserialized:
objectMapper.readValue(payload, NotificationResponse.class);
This gives me:
- flexibility in adding new notification types
- no schema changes needed
- clean separation of concerns
✅ What I Gained
✔️ Extensible architecture
✔️ Clean domain separation
✔️ No schema migrations for new notification types
✔️ Easy to plug in new features
Thanks for reading, and check out my github!
M4rc1nek
/
finovara-backend
Backend service for a personal finance management application
Finovara
Backend platform for a personal finance management application, built with Java 25 and Spring Boot 4 as a set of microservices.
About the Project
Finovara is a personal finance platform designed to help users take full control of their money. The backend exposes a secure REST API that powers tracking of income and expenses, budget management, savings goals, shared accounts and financial reporting.
The platform is built as a set of independent services, each responsible for a separate area of the domain, so that individual parts of the system can be developed, deployed and scaled on their own. Security is a central concern: authentication is based on JWT, sessions are tracked per device, and user activity is recorded in an audit log.
Key Features
Accounts and Security
- Authentication and Authorization - stateless JWT security built on Spring Security, with an access and refresh token flow.
- Device and Session Tracking…
Top comments (0)