Skip to content
md.mudassir
← all writing

· 4 min read

One pipeline, twenty events: designing an in-app notification system

How I modelled 20+ notification types for a blood-donation platform in Spring Boot — and why the event vocabulary mattered more than the push provider.

backendspring-bootmobile

When we started OneUnit+ — a veterinary blood-donation platform with an app for pet parents and one for hospital teams — notifications were a line item: "send a push when an appointment is booked." By launch there were more than twenty event types across two apps — donor lifecycle, consent, blood-unit status, low-stock alerts — and they all went through the same pipeline.

Here's how that pipeline is shaped, and the decisions I'd make again.

Start with the vocabulary, not the provider

The first instinct is to call the push API wherever something interesting happens. That works for three notifications. At twenty, you get duplicated copy, inconsistent deep links and no way to answer "did this donor get told?"

So before writing any delivery code, we wrote down every event as a noun-verb pair and gave it a home:

DomainExamples
Donor lifecycleDONOR_REGISTERED, ELIGIBILITY_CONFIRMED, DONATION_COMPLETED
ConsentCONSENT_REQUESTED, CONSENT_GRANTED, CONSENT_REVOKED
AppointmentsAPPOINTMENT_BOOKED, APPOINTMENT_REMINDER, APPOINTMENT_CANCELLED
InventoryUNIT_STATUS_CHANGED, LOW_STOCK

That list became an enum, and the enum became the contract between the backend, the donor app and the hospital app.

NotificationType.java
public enum NotificationType {
  DONOR_REGISTERED(Audience.DONOR, Channel.PUSH_AND_INBOX),
  CONSENT_REQUESTED(Audience.DONOR, Channel.PUSH_AND_INBOX),
  UNIT_STATUS_CHANGED(Audience.HOSPITAL_STAFF, Channel.INBOX),
  LOW_STOCK(Audience.HOSPITAL_STAFF, Channel.PUSH_AND_INBOX),
  // ...20+ more
  ;
 
  public final Audience audience;
  public final Channel channel;
 
  NotificationType(Audience audience, Channel channel) {
    this.audience = audience;
    this.channel = channel;
  }
}

Putting audience and channel on the type means a new event is a one-line change, and nobody has to remember that low-stock alerts should be loud while unit-status changes should be quiet.

Publish domain events, don't send notifications

Business code never talks about notifications. It publishes a domain event and moves on:

@Transactional
public Appointment book(BookingRequest req) {
  Appointment appt = appointments.save(Appointment.from(req));
  events.publishEvent(new DomainEvent(NotificationType.APPOINTMENT_BOOKED, appt.getDonorId(), appt.getId()));
  return appt;
}

A single listener turns events into notifications. The important bit is AFTER_COMMIT: if the transaction rolls back, nobody gets told about an appointment that doesn't exist.

@Component
@RequiredArgsConstructor
class NotificationListener {
  private final NotificationService notifications;
 
  @Async
  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  public void on(DomainEvent event) {
    notifications.dispatch(event);
  }
}

@Async keeps the request fast — the user's booking response doesn't wait for a third-party push service.

Inbox first, push second

Every notification is written to a notifications table before we attempt delivery. The in-app inbox reads from that table, so even if a push is dropped, the donor sees it next time they open the app. It also gives support a straight answer to "was this sent?"

Push is then a best-effort fan-out on top. With Expo, that means batching messages to the push API (it accepts up to 100 per request), storing the returned tickets, and checking receipts later. Receipts are where you learn the uncomfortable truths — most usefully DeviceNotRegistered, which means the token is dead and should be deleted so you stop paying to send into the void.

Each type maps to a template with a title, body and a deep-link route. The apps never hard-code where a notification should take you; the payload says route: "appointments/123" and the navigator handles it. Changing the destination of a notification doesn't require an app release.

What I'd keep, what I'd change

Keep: the enum as a shared contract, AFTER_COMMIT listeners, inbox-before-push. These made the system boring in the best way — new events were cheap and incidents were rare.

Change: I'd add per-user preferences and quiet hours from day one. Retro-fitting "don't push me reminders at 11pm" onto twenty types was more work than designing for it up front.

The lesson generalises beyond notifications: when a feature is going to multiply, spend your first day on the vocabulary. The code gets a lot simpler when everyone agrees on the nouns.

Written by Mohammed Mudassir, senior software engineer in Bengaluru. Turning ideas into scalable products.

say hello

Got an idea that should be a product?

I'm always up for a good product conversation — a role, a collaboration, or just comparing notes on shipping. Open to interesting product conversations.

I usually reply within a couple of days.

Local time
— · Bengaluru, IST
Status
Open to interesting product conversations