Three epics this session: business-hours SLA, outbound notifications, and something that started as a detail and became its own repository.

48 hours means 48 business hours

The design question for business-hours deadlines is where the SPI lives and how implementations get selected.

We put BusinessCalendar in quarkus-work-api — pure Java, zero deps, reusable by CaseHub. The matching HolidayCalendar SPI follows the same rule. Three built-in holiday sources: a static config list, an iCal feed fetched at startup, and whatever an application provides. The selection mechanism is the interesting part.

The natural reach for “overridable default” in CDI is @Alternative @Priority(1) on the overriding bean — which means the application has to know about Quarkus ArC’s priority system. We used @Produces @DefaultBean instead, on a producer method in the library:

@Produces
@DefaultBean
@ApplicationScoped
public HolidayCalendar produce() {
    return config.businessHours().holidayIcalUrl()
            .filter(url -> !url.isBlank())
            .map(ICalHolidayCalendar::new)
            .orElseGet(() -> new ConfigHolidayCalendar(config));
}

Any @ApplicationScoped HolidayCalendar the application provides wins automatically. No annotation needed on the application side. It’s a Quarkus ArC feature — io.quarkus.arc.DefaultBean, not in the CDI spec — which is why we didn’t reach for it immediately. We tried jakarta.enterprise.inject.DefaultBean first and the compiler promptly told us that class doesn’t exist.

Notifications without a framework

The notifications architecture is a CDI observer that fires after a WorkItem transition commits, loads matching rules from the database, dispatches each channel asynchronously on a virtual-thread executor. The observer uses AFTER_SUCCESS so a rolled-back transition produces no notification. The channels implement a NotificationChannel SPI; the dispatcher discovers them all via CDI injection.

The question that opened up: where does the actual HTTP code live? Slack and Teams are both just POST {json} to a webhook URL. We had the implementations inline in the notifications module. Then I asked whether there was already something in the Quarkus ecosystem for this.

Claude searched and found: camel-quarkus-slack exists, but Camel is a freight train for an outbound webhook. quarkus-slack in Quarkiverse is a Bolt framework — for building Slack bots, not sending messages. No Teams support anywhere. No unified outbound notification connector library.

casehub-connectors

I decided the HTTP logic shouldn’t live inside quarkus-work at all. A library that sends messages to Slack, Teams, Twilio SMS, and WhatsApp is genuinely reusable — CaseHub engine will want it, Claudony will want it, and any Quarkus application could use it independently.

We created casehubio/connectors as its own repository. The Connector SPI is four lines:

public interface Connector {
    String id();
    void send(ConnectorMessage message);
}

Four HTTP-based connectors — Slack, Teams, Twilio SMS, WhatsApp — all using java.net.http.HttpClient, zero additional dependencies. Email goes in a separate module because quarkus-mailer is a Quarkus extension that stalls at augmentation time if no SMTP is configured.

quarkus-work-notifications now depends on casehub-connectors and uses SlackConnector and TeamsConnector directly. The notification channels are thin adapters: translate WorkItemLifecycleEvent into ConnectorMessage, delegate delivery. The HTTP logic belongs in the connector library.


<
Previous Post
The Normative Ledger Ships — and It Turned Out to Be More Than Logging
>
Next Post
Wiring the SPIs and adding lifecycle control