Fisheries VMS Domain · Lesson 18 of ∞ · Second pass · ← Lesson 17
Second pass · design practice, with a working precedent this time
Lessons 15 through 17 found nothing to imitate. This one is different — a working, well-reasoned consumer already exists elsewhere in the codebase, built for a harder version of the same problem.
Same posture as Lessons 15–17: everything quoted is real, the assembly into working dispatch is a sketch. The difference this time is how much of the sketch can lean on an existing, battle-shaped pattern rather than starting from nothing — worth noticing when that's true, since it changes how much of the design is actually still open.
EBoat.Gateway already has two background services consuming Subjects.TelemetryPosition, and they deliberately choose different halves of IEventBus:
public interface IEventBus
{
Task PublishAsync<T>(string subject, T payload, CancellationToken ct = default);
IAsyncEnumerable<T> SubscribeAsync<T>(string subject, string durableName, CancellationToken ct = default);
IAsyncEnumerable<IEventMessage<T>> SubscribeWithAckAsync<T>(string subject, string durableName, CancellationToken ct = default);
}
PositionConsumerService (durable name "map-projection") uses plain SubscribeAsync — no acknowledgement. PositionArchiveService (durable name "position-archive") uses SubscribeWithAckAsync, acknowledging only after a batch commits to TimescaleDB. Same subject, same broker, two different delivery guarantees — and the reason traces straight back to something this workspace already established: "Map state lives in memory; history lives in the database." Losing one position update from the live map projection is harmless — the next update seconds later supersedes it. Losing one from permanent history is a real loss. The guarantee matches what's actually at stake, not a blanket default.
A missed Critical-severity violation alert isn't superseded by anything — there's no "next update" that fixes a notification that silently never arrived. Notification dispatch belongs firmly on the SubscribeWithAckAsync side, alongside archiving, not alongside the map projection.
PositionArchiveService.ExecuteAsync reads from the broker into a bounded Channel<IEventMessage<PositionRecord>>, decoupling the fast broker read loop from the slower batched DB write on a separate task. That decoupling matters even more for notifications: an SMTP send or an SMS gateway call is slower and less reliable than a local database insert. Blocking the broker-read loop on a live network call to an external provider would be a worse mistake here than it would be for archiving.
It would be easy to assume the "correct" .NET answer here is Polly, the standard resilience library for exactly this kind of transient-failure handling. It isn't referenced anywhere in this solution — not once, across all ten projects. The pattern that is established, in PositionArchiveService.WriteBatchAsync, is hand-rolled and worth reading exactly:
if (attempt >= 3)
{
// Left unacknowledged on purpose: JetStream redelivers after
// AckWait, so the positions are not lost by giving up here.
logger.LogError(ex, "Failed to archive {Count} positions after {Attempts} attempts; " +
"leaving them unacknowledged for redelivery.", rows.Count, attempt);
return;
}
Three quick manual retries with a linear delay, then a deliberate give-up — not because the data is abandoned, but because NatsEventBus configures AckWait = TimeSpan.FromMinutes(1) on ack-based subscriptions: JetStream itself will redeliver an unacknowledged message after that window. The retry strategy here is genuinely two-tiered: a few fast in-process attempts for the kind of hiccup that resolves in milliseconds, then trust the broker's own slower redelivery cycle as the real retry mechanism, rather than building an elaborate in-process backoff ladder to do the same job twice. A SmtpChannel.SendAsync implementation should follow the identical shape: catch transient SMTP failures, a few quick retries, then let the exception propagate so the dispatcher doesn't ack — permanent failures (invalid recipient, auth failure) shouldn't retry at all and should fail fast instead, the same transient-vs-permanent split general retry practice recommends.
At-least-once delivery (JetStream's actual guarantee, per its own docs) means a redelivered message is a normal, expected event, not a bug — and that has a direct consequence for NotificationMessage:
public sealed record NotificationMessage
{
public required string Recipient { get; init; }
public required string Subject { get; init; }
public required string Body { get; init; }
public required NotificationSeverity Severity { get; init; }
public Guid? RelatedViolationId { get; init; }
}
There's no id field on this record at all. If the dispatcher retries after a redelivery — the exact scenario AckWait exists to handle — nothing distinguishes "this is a fresh notification" from "this is the same one, delivered again because the last ack was lost in transit, not because the send actually failed." Without a stable idempotency key, a redelivered Critical violation alert can page an inspector twice. This isn't a hypothetical design nicety; it's a structural requirement created by the specific delivery guarantee (SubscribeWithAckAsync) already argued for above — the two decisions have to be made together, not the channel guarantee first and the message shape as an afterthought.
| Channel | What's missing to build it |
|---|---|
SmtpChannel | No mail package referenced anywhere (no MailKit, no System.Net.Mail usage) — and no SMTP host/port/credential config exists in appsettings.json today, the same "needs a bound options class" gap Lesson 15 found for Storage. |
SmsChannel | The tender's assumption A-1 names real, concrete infrastructure — TurboSMS or the Kyivstar API (Ukrainian SMS gateways) or a state gateway — needing an HTTP client and an API key, neither present yet. |
InAppInboxChannel | No persistence entity exists (no Notification/Inbox table in EBoat.Data) and no delivery mechanism — the only SignalR hub, PositionsHub, broadcasts to one global group with no per-recipient targeting at all. |
PositionsHub is deliberately "free of logic" — every connection joins one shared group, because every viewer needs the same position stream. A notification hub is the opposite shape: it has to target one specific recipient, not broadcast to everyone. The pattern worth keeping — a thin hub plus an external service doing the actual pushing — transfers; the single-shared-group design doesn't. A notification hub needs per-user groups, e.g. joining Context.ConnectionId to a group keyed by the authenticated user's id, something PositionsHub has never needed to do.
The optional messenger channels (Telegram Bot API, Viber Business Messages, Signal) aren't simply unbuilt like everything else in this lesson — they're explicitly gated on КСЗІ approval per assumption A-1, and not assumed enabled at go-live at all. КСЗІ is the Ukrainian information-security certification regime touched in Lesson 11's sovereignty discussion. The stated fallback is clean: if КСЗІ blocks every messenger channel, the baseline stack (email, SMS, in-app inbox) alone still satisfies ToR §5.11 — this is a case where "not built" is a pending policy gate, not an engineering gap at all.
Both NotificationDispatcherService.cs and INotificationChannel.cs cite "assumption A-01." The tender's own assumptions register has no entry by that id — the actual row is A-1 (no leading zero). This is the third time this lesson series has found a code comment's citation not matching its source exactly — after the unresolved "risk 3.3.3(1)" in Lesson 17 and the bare, narrative-free compliance-matrix rows in Lesson 14. Three instances is a pattern, not a coincidence — worth raising as a standing practice question (a lint step checking that cited ids actually resolve, perhaps) rather than fixing each occurrence individually as it's found.
eboat.notifications (NATS, durable "notification-dispatch") │ SubscribeWithAckAsync<NotificationMessage> ◀── at-least-once; needs the id field above ▼ bounded Channel<IEventMessage<NotificationMessage>> ◀── decouples broker read from slow I/O │ ▼ for each enabled INotificationChannel: SendAsync(message) ── SmtpChannel / SmsChannel / InAppInboxChannel │ transient failure → a few quick retries, then throw │ permanent failure → fail fast, no retry ▼ ack only once required channels have succeeded ◀── open question, see below
Acknowledgement is per-message in JetStream, but dispatch fans one message out across several independent channels. If email succeeds and SMS fails, should the message be acked (accepting partial delivery) or left unacked (retrying all channels, including the one that already succeeded)? Neither answer is obviously correct — re-sending a successful email on every SMS retry is its own kind of bug. This is a real policy decision the design needs, not something to default silently either way.
Something unclear, or want to go deeper on any term here? Ask the agent that built this lesson — it's your teacher for this workspace, not just a lesson generator.