Skip to main content

made_core/value_objects/outbox/
outbox_message.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use time::OffsetDateTime;
4
5use crate::error::DomainError;
6use crate::value_objects::EventId;
7
8use super::OutboxSubject;
9
10/// A message enqueued in the same transaction as the state it reports.
11///
12/// The outbox exists so publication cannot disagree with what was
13/// persisted. Because delivery is at-least-once, the message carries
14/// the originating event's identity: that is what lets a consumer
15/// recognise a redelivery instead of acting twice.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct OutboxMessage {
18    event_id: EventId,
19    subject: OutboxSubject,
20    payload: Value,
21    #[serde(with = "time::serde::rfc3339")]
22    enqueued_at: OffsetDateTime,
23}
24
25impl OutboxMessage {
26    pub fn new(
27        event_id: EventId,
28        subject: OutboxSubject,
29        payload: Value,
30        enqueued_at: OffsetDateTime,
31    ) -> Result<Self, DomainError> {
32        if payload.is_null() {
33            return Err(DomainError::EmptyField {
34                field: "outbox_message.payload",
35            });
36        }
37        Ok(Self {
38            event_id,
39            subject,
40            payload,
41            enqueued_at,
42        })
43    }
44
45    /// Stable across redeliveries — the consumer's idempotency key.
46    #[must_use]
47    pub fn event_id(&self) -> &EventId {
48        &self.event_id
49    }
50
51    #[must_use]
52    pub fn subject(&self) -> &OutboxSubject {
53        &self.subject
54    }
55
56    #[must_use]
57    pub fn payload(&self) -> &Value {
58        &self.payload
59    }
60
61    #[must_use]
62    pub fn enqueued_at(&self) -> OffsetDateTime {
63        self.enqueued_at
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use serde_json::json;
71    use time::macros::datetime;
72
73    fn subject() -> OutboxSubject {
74        OutboxSubject::new("made.ceremony.completed").unwrap()
75    }
76
77    #[test]
78    fn a_null_payload_is_rejected() {
79        assert!(matches!(
80            OutboxMessage::new(
81                EventId::new("e1").unwrap(),
82                subject(),
83                Value::Null,
84                datetime!(2026-07-29 09:00:00 UTC),
85            ),
86            Err(DomainError::EmptyField {
87                field: "outbox_message.payload"
88            })
89        ));
90    }
91
92    #[test]
93    fn the_event_identity_survives_a_round_trip() {
94        let message = OutboxMessage::new(
95            EventId::new("e1").unwrap(),
96            subject(),
97            json!({ "ceremony_id": "c1" }),
98            datetime!(2026-07-29 09:00:00 UTC),
99        )
100        .unwrap();
101
102        let restored: OutboxMessage =
103            serde_json::from_value(serde_json::to_value(&message).unwrap()).unwrap();
104
105        assert_eq!(restored, message);
106        assert_eq!(restored.event_id().as_str(), "e1");
107    }
108}