Domain Events
Implementation Status: v1 shipped — 3 seams migrated (2026-04-14). The
DomainEventsfacade (rails_api/app/lib/domain_events.rb) wrapsActiveSupport::Notificationswith payload validation anddomain.prefixing. Three cross-context seams now publish through it:feedback.post_status_changed,ai_workforce.artifact_approved,billing.payment_processed. See the DDD Phase 2 PRD for rationale, non-goals, and deferred seams.
Domain events represent "something noteworthy that happened" in a bounded context. Until v1.5, Objectuve handled these as implicit side effects — direct service calls, inline job triggers, and model callbacks. v1.5 shipped the primitive (facade + RSpec matcher + per-context subscriber registries) and migrated the three lowest-risk seams. This document catalogs both the events now published explicitly and the many events that still exist only as implicit side effects — the latter are candidates for future phases.
Why Explicit Events?
Currently, CheckInHabit directly calls GamificationService.award_xp(current_user, 25, 'Habit Check-in'). This means:
- Goal Tracking knows about Gamification's internal API
- Adding a new reaction to habit check-ins (e.g., mood prompt, community notification) requires modifying the Goal Tracking interaction
- Testing
CheckInHabitrequires stubbing Gamification
With explicit events, CheckInHabit would publish HabitCheckedIn and be done. Gamification, Social, and any future context subscribe independently.
Published (v1)
These events are published explicitly through the DomainEvents facade as of milestone v1.5. Each has a publisher interaction, one or more subscriber handlers, and a documented payload contract. Payload keys aggregate_id, actor_id, and occurred_at are required by the facade; remaining keys are event-specific.
feedback.post_status_changed
| Field | Value |
|---|---|
| Context | Feedback |
| Publisher | Feedback::UpdateFeedbackPostStatus (admin-only status transition) |
| Subscriber | Feedback::Subscribers → enqueues Feedback::NotifyVotersJob.perform_later(post_id, new_status) |
| Published when | The post's status actually changes (guarded on old_status != new_status). No-op on same-status updates. |
| Payload | { aggregate_id: post.public_id, actor_id: admin.public_id, occurred_at: Time.current, post_id: post.id, old_status:, new_status:, title: } |
| Replaces | Inline Feedback::NotifyVotersJob.perform_later(...) call inside the interaction (removed in Phase 18). |
ai_workforce.artifact_approved
| Field | Value |
|---|---|
| Context | AI Workforce |
| Publisher | AiWorkforce::ApproveArtifact (operator approval of a pending artifact) |
| Subscriber | AiWorkforce::Subscribers → enqueues AiWorkforce::DeliverArtifactJob.perform_later(artifact_public_id) |
| Published when | After AiArtifact.approval_status transitions to approved. |
| Payload | { aggregate_id: artifact.public_id, actor_id: approver.public_id, occurred_at: Time.current, artifact_id: artifact.public_id, run_id: run.public_id, employee_id: employee.public_id } |
| Replaces | Inline AiWorkforce::DeliverArtifactJob.perform_later(artifact.public_id) call inside the interaction (removed in Phase 18). |
billing.payment_processed
| Field | Value |
|---|---|
| Context | Billing |
| Publishers | Billing::ProcessStripeWebhook (Stripe path) and Billing::ProcessRevenueCatWebhook (RevenueCat IAP path) |
| Subscriber | Billing::Subscribers → mutates User.supporter_until + marks PaymentRecord completed/refunded depending on event_type. Subscriber is provider-agnostic — both Stripe and RevenueCat publish the same event name with the same required fields. |
| Published when | Stripe: checkout.session.completed, invoice.paid, customer.subscription.deleted, or charge.refunded, not a duplicate (stripe_event_id unique-indexed). RevenueCat: INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, UNCANCELLATION, PRODUCT_CHANGE (grant/entitle) or EXPIRATION, REFUND, PAUSED (revoke/expire), not a duplicate (provider_event_id unique-indexed). |
| Stripe payload | { aggregate_id: payment_record.public_id, actor_id: user.public_id or nil, occurred_at: Time.current, event_type:, payment_record_id: payment_record.id, user_id: user.id, stripe_event_id: } |
| RevenueCat payload | { aggregate_id: user.public_id, actor_id: nil, occurred_at: Time.current, event_type: rc_type, event_id: rc_event_id, user_public_id: user.public_id, provider: 'revenuecat', plan_slug:, expires_at:, product_id:, store: } |
| Replaces | Direct inline User + PaymentRecord mutation inside Webhooks::StripeController (removed in Phase 19). The controller now verifies the signature and delegates to the interaction. RevenueCat follows the identical pattern. |
| Idempotency | Stripe: short-circuits on existing stripe_event_id. RevenueCat: short-circuits on existing provider_event_id. |
actor_id: nilis valid. Webhook-originated events have no human actor; theDomainEventsfacade validates presence of the key, not non-nil value. Seerails_api/app/lib/domain_events.rb.
Verified (2026-07-03):
WeeklyDigest::AssembleDigestData,WeeklyDigest::FetchWeeklyDigestPreferences, andWeeklyDigest::SetWeeklyDigestPreferencespublish no domain events.AssembleDigestDataemits aweekly_digest_insight_fallbackPostHog observability event on AI fallback, but this is not aDomainEventspublication.SubmitEnneagramAssessmentandDismissEnneagramCardpublish no domain events — both operations are fully within User Identity (writes toEnneagramAssessmentandUserDetail.enneagram_card_dismissedrespectively, no cross-context side effects). No new domain events are required for these three features.
goal_tracking.habit_checked_in
| Field | Value |
|---|---|
| Context | Goal Tracking |
| Publisher | GoalTracking::CheckInHabit (user logs a habit completion) |
| Subscribers | Gamification::Subscribers → invokes GamificationService.award_xp(user, 25, 'Habit Check-in') and Gamification::AwardPartnerBonus.call(user:, completed_date:). Ai::Subscribers → enqueues AiCoaching::GenerateFollowUpCoachMessageJob.perform_later(user.id) (fail-open: rescues and logs/Sentry-captures on enqueue failure). |
| Published when | After a HabitCompletion record is persisted and committed to the database. |
| Payload | { aggregate_id: goal.public_id, actor_id: user.public_id, occurred_at: Time.current, goal_id: goal.id, user_id: user.id, habit_completion_id: completion.id, completed_date:, new_streak: } |
| Replaces | Direct inline GamificationService.award_xp and Gamification::AwardPartnerBonus.call invocations inside the interaction (removed in v1.12 Phase 53). |
gamification.streak_advanced
| Field | Value |
|---|---|
| Context | Gamification |
| Publisher | GoalTracking::CheckInHabit, GoalTracking::UseStreakFreeze (both in Goal Tracking context) |
| Subscriber | Gamification::Subscribers → enqueues Gamification::CheckBadgesJob for streak milestone badges |
| Published when | After Goal#update_habit_streak! returns with streak > prior_streak; published from the calling interaction |
| Payload | { aggregate_id: goal.public_id, actor_id: user.public_id, occurred_at: Time.current, goal_id: goal.public_id, user_id: user.public_id, prior_streak:, current_streak: } |
| Replaces | Implicit side effect of habit check-in; now explicit for subscriber reactions. |
gamification.streak_broken
| Field | Value |
|---|---|
| Context | Gamification |
| Publisher | Goal Tracking (ProcessHabitStreaksJob — daily scheduled job) |
| Subscriber | Gamification::Subscribers → enqueues Gamification::StreakBrokenJob for resilience badges |
| Published when | When ProcessHabitStreaksJob#process_broken_streaks finds an active-habit goal whose last_checked_in_date is not yesterday-or-later (i.e. any single missed day, not 14+ days — resets habit_streak to 0 per goal) |
| Payload | { aggregate_id: goal.public_id, actor_id: goal.user.public_id, occurred_at: Time.current, goal_id: goal.public_id, user_id: goal.user.public_id } |
| Replaces | Implicit side effect of streak lapse; now explicit for subscriber reactions. |
gamification.streak_repaired
| Field | Value |
|---|---|
| Context | Gamification |
| Publisher | Two, as of v4.67: Gamification::RepairStreak (free, XP-charged, user- or AI-Coaching-triggered) and Gamification::ClaimStreakInsurance (Supporter-only, no XP cost, once per calendar month — OBJ-4022). Both publish the same event type for the same underlying effect (a broken streak backfilled), distinguished by the payload's via: field. |
| Subscriber | None. Published (rails_api/app/interactions/gamification/repair_streak.rb, rails_api/app/interactions/gamification/claim_streak_insurance.rb) but no subscriber consumes it — Gamification::Subscribers (rails_api/app/interactions/gamification/subscribers.rb) only registers gamification.streak_broken, gamification.streak_advanced, and goal_tracking.habit_checked_in. The Gamification::CreateFeedbackBadgeJob this row previously claimed as the subscriber's side effect does not exist anywhere in the codebase. ClaimStreakInsurance becoming a second publisher does not change this — it's a second publisher of an already-gapped event, not a new gap. |
| Published when | After streak is rebuilt via backfill of missed days; published once per repair session (either lane) |
| Payload | { aggregate_id: goal.public_id, actor_id: user.public_id, occurred_at: Time.current, goal_id: goal.public_id, user_id: user.public_id, via: } — via: is only present when published by ClaimStreakInsurance (via: 'insurance'); RepairStreak's publish omits the key entirely rather than sending via: 'repair', so a payload with no via: means the free XP lane. |
| Replaces | Implicit side effect of repair; fire-and-forget today (no consumer wired up yet). |
gamification.streak_lapsed
| Field | Value |
|---|---|
| Context | Gamification |
| Publisher | Goal Tracking (ProcessHabitStreaksJob#process_lapsed_users — daily scheduled job) |
| Subscriber | None. Published but no subscriber consumes it — same registration list as streak_repaired above. There is no "get back on track" notification wired to this event. |
| Published when | For every user whose last_activity_date equals exactly LAPSE_THRESHOLD_DAYS.days.ago (5 days, not 14 — see LAPSE_THRESHOLD_DAYS = 5 in process_habit_streaks_job.rb); a one-time per-user trigger on the day they cross the threshold, not a repeating sweep of everyone already lapsed |
| Payload | { aggregate_id: user.public_id, actor_id: user.public_id, occurred_at: Time.current, user_id: user.public_id, days_since_active: } — days_since_active is always LAPSE_THRESHOLD_DAYS (5), not a variable count |
| Replaces | Implicit side effect of inactivity; published but currently unconsumed — see Subscriber above. |
critical_path.stim_completed
| Field | Value |
|---|---|
| Context | Critical Path |
| Publisher | CriticalPath::RecordPlay (user submits today's completed puzzle) |
| Subscriber | Gamification::Subscribers → loads User and CriticalPathPlay by public_id and calls Gamification::AwardStimXp.call(user:, critical_path_play:) synchronously, in-process (not perform_later — ionic_frontend/src/play/PlayView.vue does a read-after-write refetch of stim-XP status immediately after the mutation resolves, which depends on the award already having landed) |
| Published when | Every RecordPlay call, including the idempotent retry path where the play already exists — stim_xp_earned is derived from the StimXpLedger row after publish, not from the publish itself, so a duplicate call reports nil rather than re-reporting the earlier delta |
| Payload | { aggregate_id: play.public_id, actor_id: user.public_id, occurred_at: Time.current, user_id: user.public_id, critical_path_play_id: play.public_id, puzzle_date: play.puzzle_date, elapsed_seconds: play.elapsed_seconds } |
| Replaces | Direct Gamification::AwardStimXp.call(...) invocation inside CriticalPath::RecordPlay, which itself replaced a direct User write inside CriticalPath::AwardStimXp (removed per OBJ-2370 — see 03 — Context Map § Shared Kernel). |
Currently Implicit Events
These events already happen in the system but are implemented as direct calls rather than published events. Each is a candidate for a future phase; v1.5 deliberately migrated only the three seams above.
GoalCreated
| Field | Value |
|---|---|
| Producer | Goal Tracking (AddGoal) |
| Consumers | Gamification (badge detection) |
| Current Implementation | AddGoal#handle_goal_creation checks goal count, triggers CreateFirstGoalActionJob, CreateSecondCategoryActionJob, CreateSecondTypeActionJob inline |
| Payload | { user_id:, goal_id:, goal_type_id:, goal_category_id:, is_milestone: } |
GoalCompleted
| Field | Value |
|---|---|
| Producer | Goal Tracking (Goal model callback) |
| Consumers | Goal Tracking (creates completion event), potentially Gamification (completion badges) |
| Current Implementation | after_update :create_completion_event, if: :goal_completed? on Goal model |
| Payload | { user_id:, goal_id:, completed_at: } |
GoalEventAdded
| Field | Value |
|---|---|
| Producer | Goal Tracking (AddGoalEvent) |
| Consumers | Social & Community (feed generation for followers) |
| Current Implementation | AddGoalEvent triggers GenerateFollowFeedItemsJob (already async — good pattern) |
| Payload | { user_id:, goal_id:, goal_event_id:, content: } |
StreakFreezeUsed
| Field | Value |
|---|---|
| Producer | Goal Tracking (UseStreakFreeze) |
| Consumers | None currently (could notify user, award resilience badge) |
| Current Implementation | Direct model update within interaction |
| Payload | { user_id:, goal_id:, date:, remaining_freezes: } |
CommunityJoined
| Field | Value |
|---|---|
| Producer | Social & Community (JoinCommunity) |
| Consumers | Gamification (first community badge), Social & Community (community feed item) |
| Current Implementation | JoinCommunity triggers JoinFirstCommunityActionJob inline, creates CommunityFeedItem directly |
| Payload | { user_id:, community_id:, role:, is_first_community: } |
UserSignedIn
| Field | Value |
|---|---|
| Producer | User Identity (RecordSignIn) |
| Consumers | Gamification (streak XP, streak update), User Identity (sign-in history) |
| Current Implementation | RecordSignIn calls User#update_streak!, which calls GamificationService.award_xp directly |
| Payload | { user_id:, signed_in_at:, new_streak: } |
UserLeveledUp
| Field | Value |
|---|---|
| Producer | Gamification (GamificationService.check_for_level_up) |
| Consumers | Social & Community (notification, feed item) |
| Current Implementation | check_for_level_up triggers CreateLevelUpNotificationJob directly |
| Payload | { user_id:, new_level:, xp: } |
GoalFollowed
| Field | Value |
|---|---|
| Producer | Social & Community (ToggleFollowGoal) |
| Consumers | Social & Community (follow notification to goal owner) |
| Current Implementation | Notification generated inline via UserNotification.add_follow_notification |
| Payload | { follower_user_id:, goal_owner_user_id:, goal_id: } |
EncouragementToggled
| Field | Value |
|---|---|
| Producer | Social & Community (ToggleGoalEventEncouragement) |
| Consumers | Social & Community (encouragement notification, feed item) |
| Current Implementation | Triggers UserNotification.add_encouragement_notification and UserFeedItem creation inline |
| Payload | `{ user_id:, goal_event_id:, goal_id:, goal_owner_user_id:, action: 'added' |
ReactionToggled
| Field | Value |
|---|---|
| Producer | Social & Community (ToggleGoalEventReaction) |
| Consumers | Social & Community (reaction notification, feed item) |
| Current Implementation | Triggers UserNotification.add_reaction_notification and UserFeedItem.add_reaction_feed_item inline |
| Payload | `{ user_id:, goal_event_id:, goal_id:, goal_owner_user_id:, emoji:, action: 'added' |
CommentAdded
| Field | Value |
|---|---|
| Producer | Social & Community (AddGoalEventComment) |
| Consumers | Social & Community (comment notification) |
| Current Implementation | Triggers UserNotification.add_comment_notification inline |
| Payload | { commenter_user_id:, goal_event_id:, goal_id:, goal_owner_user_id:, content: } |
AiRunTriggered
| Field | Value |
|---|---|
| Producer | AI Workforce (AiWorkforce::TriggerRun) |
| Consumers | AI Workforce (ExecuteRunJob), Admin (audit via AdminAction) |
| Current Implementation | TriggerRun enqueues ExecuteRunJob inline after creating the AiRun in queued state |
| Payload | { ai_employee_id:, ai_run_id:, triggered_by:, scheduled: } |
AiRunCompleted
| Field | Value |
|---|---|
| Producer | AI Workforce (AiWorkforce::ProcessWebhookResult) |
| Consumers | AI Workforce (artifact creation, cost roll-up), AI Metrics (RecordAiUsageEventJob), Admin (operator notification) |
| Current Implementation | Webhook controller calls ProcessWebhookResult, which updates the run and creates artifacts inline in a single transaction |
| Payload | { ai_run_id:, ai_employee_id:, status:, cost_cents:, total_tokens:, artifact_ids: [] } |
AiArtifactRejected
| Field | Value |
|---|---|
| Producer | AI Workforce (AiWorkforce::RejectArtifact, or Agent Runner post-filter via ProcessWebhookResult) |
| Consumers | AI Workforce (memory update — feed back what failed), Admin (audit) |
| Current Implementation | Status updated inline; no side effects today beyond the status flip |
| Payload | { ai_artifact_id:, ai_run_id:, ai_employee_id:, kind:, approval_status: 'rejected' | 'auto_rejected', reason: } |
SubscriptionCreated
| Field | Value |
|---|---|
| Producer | Billing (Stripe subscription-created webhook) |
| Consumers | User Identity (grant entitlement), Admin (audit) |
| Current Implementation | Inline in webhook controller |
| Payload | { user_id:, plan_id:, stripe_subscription_id:, starts_at:, interval: } |
SupporterExpired
| Field | Value |
|---|---|
| Producer | Billing (Billing::ExpireSupportersJob) |
| Consumers | User Identity (revoke entitlement), Social (optional "come back" notification) |
| Current Implementation | Job directly updates user entitlement fields |
| Payload | { user_id:, payment_record_id:, expired_at: } |
FeedbackVoteToggled
| Field | Value |
|---|---|
| Producer | Feedback (Feedback::ToggleFeedbackVote) |
| Consumers | Feedback (counter cache — handled by counter_cache: :vote_count), potentially Admin (trending-items alert) |
| Current Implementation | Counter cache auto-updates; no other side effects |
| Payload | { feedback_post_id:, user_id:, action: 'added' | 'removed', new_vote_count: } |
Proposed New Events
Events that don't exist yet but would enable better decoupling or future features.
BadgeUnlocked
| Field | Value |
|---|---|
| Producer | Gamification (proposed BadgeEvaluator) |
| Consumers | Social & Community (badge notification, feed item, push notification) |
| Why | Currently UserAction.generate directly creates a UserFeedItem and triggers notifications. Separating the "badge was earned" event from "notify the user" delivery enables future flexibility (e.g., delayed celebration, batch notifications). |
| Payload | { user_id:, badge_name:, badge_id: } |
MoodLogged
| Field | Value |
|---|---|
| Producer | User Identity (AddMoodLog) |
| Consumers | AI Coaching (mood-based insights), potentially Gamification (wellbeing badges) |
| Why | Currently AddMoodLog has no side effects. Publishing an event opens the door for AI coaching to adapt prompts based on mood trends, or for a future wellbeing dashboard. |
| Payload | { user_id:, mood:, goal_id:, note: } |
StreakMilestoneReached
| Field | Value |
|---|---|
| Producer | Goal Tracking (Goal#update_habit_streak!) |
| Consumers | Gamification (streak badges like consistency_is_key), Social & Community (celebration notification) |
| Why | Streak milestones (7, 14, 30, 60, 90 days) are meaningful moments worth celebrating. Currently only freeze tokens are awarded at 7-day intervals. A dedicated event enables richer rewards. |
| Payload | `{ user_id:, goal_id:, streak_length:, milestone: 7 |
CommunityPostCreated
| Field | Value |
|---|---|
| Producer | Social & Community (CreateCommunityPost) |
| Consumers | Gamification (contribution points, badges), Social & Community (member notifications) |
| Why | Community posts currently have no side effects beyond persistence. An event enables contribution tracking, engagement badges, and targeted notifications. |
| Payload | { user_id:, community_id:, post_id:, post_type: } |
ContentFlagged
| Field | Value |
|---|---|
| Producer | Content Moderation (ContentModerationService.screen, ScreenContentJob) |
| Consumers | Admin notification (new item in moderation queue), potentially Social & Community (hide content pending review) |
| Why | Currently, screening creates a ContentFlag record but has no further side effects. An event enables real-time admin notification, automatic content hiding, and escalation rules based on severity. |
| Payload | { content_flag_id:, flaggable_type:, flaggable_id:, source:, severity: } |
ContentReported
| Field | Value |
|---|---|
| Producer | Content Moderation (ReportContent) |
| Consumers | Admin notification (moderation queue), auto-flag evaluation (escalate if report count exceeds threshold) |
| Why | Currently, ReportContent creates a ContentReport and a ContentFlag in a single transaction. An event enables decoupled side effects: auto-escalation when multiple users report the same content, reporter acknowledgment notification, and admin queue prioritization. |
| Payload | { content_report_id:, reporter_user_id:, reportable_type:, reportable_id:, reason: } |
Implementation Approach
See 06 — Modularization Guide, Phase 2 for the full implementation plan. In summary:
Step 1: Publish from interactions using the DomainEvents facade:
# In CheckInHabit#call, after HabitCompletion is persisted:
DomainEvents.publish(
'goal_tracking.habit_checked_in',
aggregate_id: goal.public_id,
actor_id: current_user.public_id,
occurred_at: Time.current,
goal_id: goal.id,
user_id: current_user.id,
habit_completion_id: completion.id,
completed_date: date,
new_streak: goal.habit_streak
)The DomainEvents facade wraps ActiveSupport::Notifications with payload validation (requires :aggregate_id, :actor_id, :occurred_at) and prefixes the event name with domain. before publishing.
Step 2: Subscribe from consuming contexts:
# app/lib/gamification/subscribers.rb
module Gamification
module Subscribers
def self.register
return if @registered
@registered = true
DomainEvents.subscribe('goal_tracking.habit_checked_in') do |event|
user = User.public_find(event.payload[:user_id])
GamificationService.award_xp(user, 25, 'Habit Check-in')
Gamification::AwardPartnerBonus.call(
user:,
completed_date: event.payload[:completed_date]
)
end
end
end
endThe subscriber module is idempotent via a @registered guard and is wired into config/initializers/domain_event_subscribers.rb. Subscribers receive a DomainEvents::Event struct exposing name, aggregate_id, actor_id, occurred_at, and payload. Handler exceptions are captured to Sentry and do not propagate to the publisher.
Recommended Phase 2 Kickoff
Start with events that are already async via jobs (lowest behavioral risk) and sit at the two highest-leverage seams in the system: the inbound webhook handlers. Converting these seams first means every future webhook-driven feature inherits the event pattern for free.
Priority 1: FeedbackPosted and AiArtifactApproved
Why these two first:
- Lowest blast radius. Both have zero current consumers beyond the producing interaction itself. Publishing an event changes nothing until a subscriber is added — it's a safe dry run of the whole stack.
- Clear second-wave consumers.
FeedbackPosted→ admin Slack alert + potentialfeedback_contributorbadge.AiArtifactApproved→ audit log + possible Social celebration feed item. Both are obvious, incremental, and independently valuable. - Establishes the pattern before touching hot paths.
CheckInHabit(25 XP on every habit check-in) is the scariest migration. Getting the publisher, subscriber, and testing patterns proven on cooler paths first makes that migration trivial.
Priority 2: Webhook seams
After the two above, migrate the inbound webhook handlers:
Webhooks::AiWorkforceController→AiRunCompleted+AiArtifactProduced. Already uses an interaction (ProcessWebhookResult) so the shape is right — just emit events on the way out.Webhooks::StripeController→PaymentProcessed/SubscriptionCreated. First requires extracting theBilling::StripeAdapterACL +Billing::ProcessStripeWebhookinteraction (see06-modularization-guide.md). Once extracted, emit events from the interaction.
These two controllers are the highest-leverage seams for a unified event emitter because they already translate external shapes into internal ones — the mental model is a near-perfect match for an event bus translation layer.
Priority 3: The 5 tight-coupling points
Only after the above is stable, tackle the original 5 coupling points: JoinCommunity, AddGoal, CheckInHabit, RecordSignIn/update_streak!, and check_for_level_up. See 06-modularization-guide.md Phase 2 for migration order.
Writing a New Domain Event
Follow these five steps whenever you want to decouple a new cross-context side effect. The v1 seams (feedback.post_status_changed, ai_workforce.artifact_approved, billing.payment_processed) all follow this exact shape.
1. Name it <context>.<snake_case_past_tense>
The event name must match /\A[a-z_]+\.[a-z_]+(_[a-z]+)*\z/ — one context token, a dot, then a past-tense snake_case phrase. The DomainEvents facade prefixes the name with domain. before handing it to ActiveSupport::Notifications, so feedback.post_status_changed becomes domain.feedback.post_status_changed on the bus. Invalid names raise DomainEvents::InvalidEventNameError at publish time.
2. Publish from an interaction
Inside the interaction's #call, after the aggregate has been persisted but before return, publish via DomainEvents.publish. The payload must include :aggregate_id, :actor_id, and :occurred_at (the facade raises DomainEvents::InvalidPayloadError when any are missing — actor_id: nil is valid for webhook-originated events).
DomainEvents.publish(
'feedback.post_status_changed',
aggregate_id: post.public_id,
actor_id: admin.public_id,
occurred_at: Time.current,
post_id: post.id,
old_status: old_status,
new_status: new_status,
title: post.title
)3. Subscribe in <Context>::Subscribers.register
Add the handler to the subscriber module for the consuming context (app/lib/<context>/subscribers.rb). The module is idempotent via a @registered guard and is wired into config/initializers/domain_event_subscribers.rb. Subscribers receive a DomainEvents::Event struct exposing name, aggregate_id, actor_id, occurred_at, and payload.
module Feedback
module Subscribers
def self.register
return if @registered
@registered = true
DomainEvents.subscribe('feedback.post_status_changed') do |event|
Feedback::NotifyVotersJob.perform_later(
event.payload[:post_id],
event.payload[:new_status]
)
end
end
end
endHandler exceptions are captured to Sentry with an event_name tag and do not propagate to the publisher.
4. Spec both halves
- Interaction spec asserts publication via the
publish_domain_eventmatcher:rubyexpect { Feedback::UpdateFeedbackPostStatus.call(...) } .to publish_domain_event('feedback.post_status_changed').with( aggregate_id: kind_of(String), post_id: kind_of(Integer), new_status: 'completed' ) - Subscriber spec asserts the handler's side effect (usually
have_enqueued_job) runs when the matching event is published.
See spec/interactions/feedback/update_feedback_post_status_spec.rb and spec/lib/feedback/subscribers_spec.rb for reference specs.
5. Document it here
Add a row to the "Published (v1)" section above. Include publisher interaction, subscriber(s), when the event is published, and the full payload contract. If the new event replaces an entry in "Currently Implicit Events", remove that entry so the doc stays authoritative.
Last updated: 2026-08-10