Skip to main content

puerto_core/
events.rs

1//! Domain events (ADR-0018): what a write records in the outbox, and the transport-neutral parts
2//! every transport shares — the envelope, the handler a consumer implements, the delivery policy
3//! and the ports the worker drives.
4
5pub mod delivery;
6pub mod handler;
7pub mod transport;
8
9use std::collections::BTreeMap;
10
11use chrono::{DateTime, Utc};
12use serde::de::DeserializeOwned;
13use serde_json::Value;
14use thiserror::Error;
15use uuid::Uuid;
16
17pub use handler::{
18    EventHandler, HandlerOutcome, REASON_MAX_DELIVERIES_EXCEEDED, REASON_PAYLOAD_INVALID,
19    REASON_PERMANENT_ERROR,
20};
21
22/// An event an aggregate records. Each entity's `{Entity}Event` implements it, generated from
23/// puerto.toml: the payload is the entity's snapshot, never the domain model itself.
24pub trait DomainEvent {
25    /// Past tense, snake_case: `product_created`.
26    fn event_type(&self) -> &'static str;
27    /// The entity it happened to, snake_case: `product`.
28    fn aggregate_type(&self) -> &'static str;
29    fn aggregate_id(&self) -> Uuid;
30    /// The payload's shape. A consumer that reads an older one can tell.
31    fn schema_version(&self) -> u16;
32    fn payload(&self) -> Value;
33}
34
35/// One event as it travels: the outbox row, a delivery, a Kafka message. Its `id` stays the same
36/// across redeliveries, so a handler deduplicates on it.
37///
38/// `#[non_exhaustive]`: built with [`EventEnvelope::new`] (a new event) or
39/// [`EventEnvelope::from_parts`] (one read back from a store), so a later field breaks no one.
40#[non_exhaustive]
41#[derive(Debug, Clone, PartialEq)]
42pub struct EventEnvelope {
43    pub id: Uuid,
44    pub event_type: String,
45    pub aggregate_type: String,
46    pub aggregate_id: Uuid,
47    pub schema_version: u16,
48    pub occurred_at: DateTime<Utc>,
49    pub payload: Value,
50    /// Context that is not the event's: `correlation_id`, later `tenant_id`.
51    pub headers: BTreeMap<String, String>,
52}
53
54/// Every field of an envelope, for an adapter that reads one back from where it was stored.
55#[derive(Debug, Clone, PartialEq)]
56pub struct EnvelopeParts {
57    pub id: Uuid,
58    pub event_type: String,
59    pub aggregate_type: String,
60    pub aggregate_id: Uuid,
61    pub schema_version: u16,
62    pub occurred_at: DateTime<Utc>,
63    pub payload: Value,
64    pub headers: BTreeMap<String, String>,
65}
66
67impl EventEnvelope {
68    /// A new event: the id is a UUID v7, so ids sort by the time they were made.
69    pub fn new(event: &impl DomainEvent) -> Self {
70        Self {
71            id: Uuid::now_v7(),
72            event_type: event.event_type().to_string(),
73            aggregate_type: event.aggregate_type().to_string(),
74            aggregate_id: event.aggregate_id(),
75            schema_version: event.schema_version(),
76            occurred_at: Utc::now(),
77            payload: event.payload(),
78            headers: BTreeMap::new(),
79        }
80    }
81
82    pub fn from_parts(parts: EnvelopeParts) -> Self {
83        Self {
84            id: parts.id,
85            event_type: parts.event_type,
86            aggregate_type: parts.aggregate_type,
87            aggregate_id: parts.aggregate_id,
88            schema_version: parts.schema_version,
89            occurred_at: parts.occurred_at,
90            payload: parts.payload,
91            headers: parts.headers,
92        }
93    }
94
95    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
96        self.headers.insert(key.into(), value.into());
97        self
98    }
99
100    /// The payload as the snapshot type the consumer subscribed to.
101    pub fn decode<T: DeserializeOwned>(&self) -> Result<T, DecodeError> {
102        serde_json::from_value(self.payload.clone()).map_err(|err| DecodeError {
103            event_type: self.event_type.clone(),
104            detail: err.to_string(),
105        })
106    }
107}
108
109/// A payload that is not the shape its reader expects. A handler answers it with
110/// `HandlerOutcome::DeadLetter` and [`REASON_PAYLOAD_INVALID`]: retrying cannot fix it.
111#[derive(Debug, Clone, PartialEq, Eq, Error)]
112#[error("event.payload_invalid")]
113pub struct DecodeError {
114    pub event_type: String,
115    /// What the decoder said, for the dead letter.
116    pub detail: String,
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use serde::{Deserialize, Serialize};
123    use serde_json::json;
124
125    #[derive(Debug, PartialEq, Serialize, Deserialize)]
126    struct ProductSnapshot {
127        id: Uuid,
128        name: String,
129        price: i64,
130    }
131
132    struct ProductCreated(ProductSnapshot);
133
134    impl DomainEvent for ProductCreated {
135        fn event_type(&self) -> &'static str {
136            "product_created"
137        }
138        fn aggregate_type(&self) -> &'static str {
139            "product"
140        }
141        fn aggregate_id(&self) -> Uuid {
142            self.0.id
143        }
144        fn schema_version(&self) -> u16 {
145            3
146        }
147        fn payload(&self) -> Value {
148            serde_json::to_value(&self.0).unwrap_or(Value::Null)
149        }
150    }
151
152    fn product_created() -> ProductCreated {
153        ProductCreated(ProductSnapshot {
154            id: Uuid::now_v7(),
155            name: "Desk lamp".into(),
156            price: 2_500,
157        })
158    }
159
160    #[test]
161    fn should_carry_the_event_metadata_when_built_from_a_domain_event() {
162        // Arrange
163        let event = product_created();
164        let before = Utc::now();
165
166        // Act
167        let envelope = EventEnvelope::new(&event);
168
169        // Assert
170        assert_eq!(envelope.event_type, "product_created");
171        assert_eq!(envelope.aggregate_type, "product");
172        assert_eq!(envelope.aggregate_id, event.0.id);
173        assert_eq!(envelope.schema_version, 3);
174        assert_eq!(envelope.payload, event.payload());
175        assert!(envelope.headers.is_empty());
176        assert!(envelope.occurred_at >= before && envelope.occurred_at <= Utc::now());
177        assert_eq!(envelope.id.get_version_num(), 7);
178    }
179
180    #[test]
181    fn should_give_every_envelope_its_own_id_when_built_from_the_same_event() {
182        let event = product_created();
183        assert_ne!(EventEnvelope::new(&event).id, EventEnvelope::new(&event).id);
184    }
185
186    #[test]
187    fn should_keep_every_field_when_restored_from_its_parts() {
188        let parts = EnvelopeParts {
189            id: Uuid::now_v7(),
190            event_type: "product_updated".into(),
191            aggregate_type: "product".into(),
192            aggregate_id: Uuid::now_v7(),
193            schema_version: 2,
194            occurred_at: Utc::now(),
195            payload: json!({ "name": "Desk lamp" }),
196            headers: BTreeMap::from([("correlation_id".into(), "req-1".into())]),
197        };
198
199        let envelope = EventEnvelope::from_parts(parts.clone());
200
201        assert_eq!(envelope.id, parts.id);
202        assert_eq!(envelope.event_type, parts.event_type);
203        assert_eq!(envelope.aggregate_type, parts.aggregate_type);
204        assert_eq!(envelope.aggregate_id, parts.aggregate_id);
205        assert_eq!(envelope.schema_version, parts.schema_version);
206        assert_eq!(envelope.occurred_at, parts.occurred_at);
207        assert_eq!(envelope.payload, parts.payload);
208        assert_eq!(envelope.headers, parts.headers);
209    }
210
211    #[test]
212    fn should_add_a_header_when_one_is_given() {
213        let envelope = EventEnvelope::new(&product_created())
214            .with_header("correlation_id", "req-1")
215            .with_header("correlation_id", "req-2");
216
217        assert_eq!(
218            envelope.headers,
219            BTreeMap::from([("correlation_id".to_string(), "req-2".to_string())])
220        );
221    }
222
223    #[test]
224    fn should_round_trip_the_payload_when_decoded_as_its_snapshot() {
225        let event = product_created();
226        let envelope = EventEnvelope::new(&event);
227
228        let decoded = envelope.decode::<ProductSnapshot>();
229
230        assert_eq!(decoded, Ok(event.0));
231    }
232
233    #[test]
234    fn should_reject_the_payload_when_decoded_as_another_shape() {
235        #[derive(Debug, Deserialize)]
236        #[allow(dead_code)]
237        struct OrderSnapshot {
238            total: i64,
239        }
240        let envelope = EventEnvelope::new(&product_created());
241
242        let result = envelope.decode::<OrderSnapshot>();
243
244        match result {
245            Err(err) => {
246                assert_eq!(err.to_string(), "event.payload_invalid");
247                assert_eq!(err.event_type, "product_created");
248                assert!(err.detail.contains("total"), "detail: {}", err.detail);
249            }
250            Ok(other) => panic!("decoded as another shape: {other:?}"),
251        }
252    }
253}