Architecture

The design keeps a Spring-free core generic over the application’s event type E, with a thin Spring Boot starter on top. The core never references any application domain type — a single NotificationAdapter<E> bridges it.

1. Modules

Module Role

notify4j-core

The engine. No Spring dependency; uses the JDK HttpClient.

notify4j-spring-boot-starter

Auto-configuration binding notify4j.*; adds the email channel (the one channel that is not a URL).

notify4j-bom

Dependency BOM for version alignment.

notify4j-sample

Runnable example. Built under the default profile only; never released.

2. Core concepts

Notifier<E>

The SPI — void notify(E). Implementations must never throw; a failing channel must not break the caller.

NotificationAdapter<E>

Supplied by the application. Maps E to id (stable identity for transition tracking), status, and message. The only place the app’s domain type is referenced, which is what lets channels stay event-agnostic.

Notifications<E>

The application-facing facade. Holds a fan-out list of channels. send(event) / send(event, routeTags) delivers to every channel whose tags overlap the route tags (untagged channels always fire). Two gates sit in front: a runtime-mute filter and per-channel tags; each channel additionally applies its own transition filter. Per-channel `RuntimeException`s are caught and logged.

NotificationsFactory<E>

Builds Notifications facades from a URL list with shared defaults. Single-tenant apps get one global facade; multi-tenant apps inject the factory and build one facade per tenant (and set notify4j.global=false).

NotifierUrlParser<E>

Turns a URL into a channel (notifier + routing tags). Adding a channel means adding a case here plus the notifier class.

3. Delivery flow

send(event, tags)
  └─ mute gate (FilteringNotifier)        suppress active runtime mutes
       └─ for each channel matching tags:
            └─ transition filter           fire only on a real status change
                 └─ channel notifier       POST the channel-specific payload
                      (RuntimeException -> caught + logged, never propagated)

4. Notifier hierarchy

AbstractEventNotifier<E>

Base: an enabled flag, a shouldNotify guard, and error isolation (notify is final, catches and logs). Subclasses implement doNotify.

AbstractHttpNotifier<E>

Base for webhook-style channels. Configured with functions (no subclass-per-event); owns the JDK HttpClient, applies a transition filter, POSTs JSON. Subclasses implement payload(event) and optionally headers(). Concrete: Slack, Teams, Discord, Mattermost, Rocket.Chat, Google Chat, Webhook, Telegram, Ntfy, Gotify, Pushover, Pushbullet, Zulip, Twilio (SMS), Signal, WhatsApp, PagerDuty, OpsGenie.

AbstractTransitionNotifier<E>

Alternative base for non-HTTP notifiers that subclass instead of taking functions.

Wrappers (decorators): CompositeNotifier (fan-out), FilteringNotifier (mute), RemindingNotifier (re-notify entities stuck in a state), AsyncNotifier (deliver off the caller thread on an Executor), LoggingNotifier (default sink).

5. Delivery reliability

The webhook-style channels retry transient failures (HTTP 5xx/429, IOException) with bounded attempts and exponential backoff (see notify4j.http.max-attempts / retry-backoff); other 4xx fail fast. When an Executor is supplied (the starter provides one by default), the facade wraps every channel in an AsyncNotifier, so send returns immediately and one hung channel can’t block the caller or its siblings. Failures stay isolated and logged — a channel never throws to the caller.

Because failures are swallowed there, delivery outcomes are recorded against a dependency-free NotificationMetrics SPI inside AbstractEventNotifier (sent/failed/suppressed, per channel). The core defaults to no-op; the starter supplies a Micrometer-backed implementation when a MeterRegistry is present. See Metrics.

6. Using the engine without Spring

The core has no Spring dependency. Build a facade directly:

var config = NotificationsConfig.builder()
        .ignoreChanges(List.of("*:RUNNING"))
        .build();                // log sink on, synchronous, default HTTP, no metrics
var notifications = new Notifications<>(
        List.of("slack://...", "pagerduty://<key>?tags=failed"), adapter, config);
notifications.send(event);
// notifications.close();        // closes any resource-holding channel (e.g. reminders)

Options (executor for async delivery, retry/timeouts, metrics) are set on the NotificationsConfig builder rather than constructor arguments.