DraftHouse had a local file-based parser for deriving a review summary from channel history. Claudony wants a digest panel from the work channel. The pattern is always the same: read the messages, fold to state, render. I wanted one abstraction instead of two diverging local implementations.

ChannelProjection<S> is a pure left-fold: identity() returns a fresh empty state, apply(S, MessageView) folds one message and returns the next state. The service reads the channel’s message history and folds it. Simple enough.

The design review earned its keep

Three review rounds substantially changed what shipped.

The first change was removing channelType(). I’d kept it as a reserved hook for a future CDI registry that selects projections by channel name — a defensive move that costs nothing. The review disagreed: three abstract methods means no @FunctionalInterface; the String return type is wrong if the registry later needs a Pattern or Predicate; no default means every implementation must supply a no-op String. Removing it now is zero cost. Adding it back later via a @ChannelBound qualifier annotation doesn’t break existing implementations. Gone.

The second change was ProjectionRenderer<S>. I’d put it in api/spi/ as a named @FunctionalInterface for “turns state into a string.” The review pointed out it isn’t an SPI if the service never calls it — it’s documentation stored as code. Function<S, String> from the JDK already does this job, and it’s named, typed, and composable. Gone.

The third change was the most structural. The original API returned raw <S>. That’s clean but breaks incremental re-projection: after a full fold, you have state but no cursor. To resume from where you left off, you’d need a separate findLastMessage() call — which races with new arrivals. The review flagged it. We wrapped the return in ProjectionResult<S>(S state, Long lastMessageId) where lastMessageId is null for an empty channel. Pass it back to the incremental overload and it folds only messages since the cursor. No race.

Six bugs total surfaced across the reviews, including:

  • Silent wrong results if previous.isEmpty() but previous.state() was non-null — the incremental overload now enforces projection.identity() whenever the cursor is null
  • scope.descending(true) produces a right-fold masquerading as a left-fold and silently corrupts state-machine projections — rejected at the validation gate

The DTO constraint

ChannelProjection must live in api/spi/ so consumers depend only on the lightweight api module. That means the fold function can’t take Message — it’s a JPA entity in runtime/. We introduced MessageView, a pure-Java record in api/message/ that maps the consumer-relevant fields. The entity’s messageType field becomes type on the DTO — consistent with DispatchResult.type, worth the rename.

QhorusEntityMapper gains toMessageView(Message) alongside the existing toChannelDetail() and toTimelineEntry() methods. Same pattern.

The reactive gap

The reactive service uses ReactiveMessageStore.stream(MessageQuery) returning Multi<Message>. The fold runs via Mutiny’s collect().in() with a private mutable accumulator class — collect().in() takes BiConsumer (void mutation), not BiFunction (returns new state), so the pure fold function can’t be used as the accumulator directly.

PanacheQuery.stream() returning Multi<T> doesn’t exist in Quarkus 3.32 Hibernate Reactive Panache. We documented the gap honestly: stream() on ReactiveJpaMessageStore wraps scan().toMulti() — materialises the full list but gives the right shape. When Hibernate Reactive exposes cursor streaming, only the store implementation changes.

What shipped

Both issues — #230 (base SPI) and #231 (incremental cursor) — went in one branch. The fold logic itself is trivially testable without any framework: construct a ChannelProjection<VoteState>, call identity(), fold MessageView records by hand, assert on counts. The full @QuarkusTest integration suite uses messageStore.put() directly rather than MessageService.dispatch() — no ledger overhead, projection behaviour only.

DraftHouse (#31) and Claudony can now drop their local implementations and implement the SPI. Per-channel InboundNormaliser (#216) is next.


<
Previous Post
Arc42Stories and a Lighter Ledger
>
Next Post
Two Writing Styles, one commit — and a spec that forgot to check the skill