feat: event infrastructure — payload, transport, NATS adapter

- EventPublisher now takes &DomainEvent (11 call sites + 3 impls updated)
- EventEnvelope + EventConsumer port in domain
- event-payload: serializable DomainEvent mirror with subject routing
- event-transport: generic Transport/MessageSource traits, publisher/consumer adapters
- adapters-nats: JetStream publish + durable pull consumer
This commit is contained in:
2026-05-31 11:50:16 +02:00
parent dacfc3d453
commit 0e9911ebfc
24 changed files with 1294 additions and 21 deletions

View File

@@ -0,0 +1,99 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{DomainEvent, EventEnvelope},
ports::{EventConsumer, EventPublisher},
};
use event_payload::EventPayload;
use futures::stream::BoxStream;
#[async_trait]
pub trait Transport: Send + Sync {
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError>;
}
pub struct EventPublisherAdapter<T: Transport> {
transport: T,
}
impl<T: Transport> EventPublisherAdapter<T> {
pub fn new(transport: T) -> Self {
Self { transport }
}
}
#[async_trait]
impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
let payload = EventPayload::from(event);
let subject = payload.subject();
let bytes =
serde_json::to_vec(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
tracing::debug!(subject, "publishing event");
self.transport.publish_bytes(subject, &bytes).await
}
}
pub struct RawMessage {
pub subject: String,
pub payload: Vec<u8>,
pub delivery_count: u64,
pub ack: Box<dyn Fn() + Send + Sync>,
pub nack: Box<dyn Fn() + Send + Sync>,
}
pub trait MessageSource: Send + Sync {
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>>;
}
pub struct EventConsumerAdapter<S: MessageSource> {
source: S,
}
impl<S: MessageSource> EventConsumerAdapter<S> {
pub fn new(source: S) -> Self {
Self { source }
}
}
impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
use futures::StreamExt;
let stream = self.source.messages();
Box::pin(stream.filter_map(|result| async move {
match result {
Err(e) => {
tracing::warn!("transport error: {e}");
None
}
Ok(msg) => {
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
Ok(p) => p,
Err(e) => {
tracing::warn!("failed to deserialize event payload, acking: {e}");
(msg.ack)();
return None;
}
};
let event = match DomainEvent::try_from(payload) {
Ok(e) => e,
Err(e) => {
tracing::warn!("unknown event type, acking: {e}");
(msg.ack)();
return None;
}
};
Some(Ok(EventEnvelope {
event,
delivery_count: msg.delivery_count,
ack: msg.ack,
nack: msg.nack,
}))
}
}
}))
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,61 @@
use crate::{EventPublisherAdapter, Transport};
use async_trait::async_trait;
use domain::{
errors::DomainError, events::DomainEvent, ports::EventPublisher, value_objects::SystemId,
};
use std::sync::{Arc, Mutex};
struct RecordingTransport {
messages: Arc<Mutex<Vec<(String, Vec<u8>)>>>,
}
#[async_trait]
impl Transport for RecordingTransport {
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
self.messages
.lock()
.unwrap()
.push((subject.to_string(), bytes.to_vec()));
Ok(())
}
}
#[tokio::test]
async fn adapter_publishes_with_correct_subject() {
let messages = Arc::new(Mutex::new(Vec::new()));
let adapter = EventPublisherAdapter::new(RecordingTransport {
messages: messages.clone(),
});
let event = DomainEvent::JobCompleted {
job_id: SystemId::new(),
timestamp: domain::value_objects::DateTimeStamp::now(),
};
adapter.publish(&event).await.unwrap();
let recorded = messages.lock().unwrap();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].0, "jobs.completed");
}
#[tokio::test]
async fn published_bytes_are_valid_json() {
let messages = Arc::new(Mutex::new(Vec::new()));
let adapter = EventPublisherAdapter::new(RecordingTransport {
messages: messages.clone(),
});
let event = DomainEvent::AssetIngested {
asset_id: SystemId::new(),
owner_user_id: SystemId::new(),
timestamp: domain::value_objects::DateTimeStamp::now(),
};
adapter.publish(&event).await.unwrap();
let recorded = messages.lock().unwrap();
let payload: event_payload::EventPayload =
serde_json::from_slice(&recorded[0].1).expect("should be valid JSON");
assert_eq!(payload.subject(), "assets.ingested");
}