Skip to content

Domain Events

Implementation Status: v1 shipped — 3 seams migrated (2026-04-14). The DomainEvents facade (rails_api/app/lib/domain_events.rb) wraps ActiveSupport::Notifications with payload validation and domain. 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 CheckInHabit requires 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

FieldValue
ContextFeedback
PublisherFeedback::UpdateFeedbackPostStatus (admin-only status transition)
SubscriberFeedback::Subscribers → enqueues Feedback::NotifyVotersJob.perform_later(post_id, new_status)
Published whenThe 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: }
ReplacesInline Feedback::NotifyVotersJob.perform_later(...) call inside the interaction (removed in Phase 18).

ai_workforce.artifact_approved

FieldValue
ContextAI Workforce
PublisherAiWorkforce::ApproveArtifact (operator approval of a pending artifact)
SubscriberAiWorkforce::Subscribers → enqueues AiWorkforce::DeliverArtifactJob.perform_later(artifact_public_id)
Published whenAfter 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 }
ReplacesInline AiWorkforce::DeliverArtifactJob.perform_later(artifact.public_id) call inside the interaction (removed in Phase 18).

billing.payment_processed

FieldValue
ContextBilling
PublishersBilling::ProcessStripeWebhook (Stripe path) and Billing::ProcessRevenueCatWebhook (RevenueCat IAP path)
SubscriberBilling::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 whenStripe: 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: }
ReplacesDirect 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.
IdempotencyStripe: short-circuits on existing stripe_event_id. RevenueCat: short-circuits on existing provider_event_id.

actor_id: nil is valid. Webhook-originated events have no human actor; the DomainEvents facade validates presence of the key, not non-nil value. See rails_api/app/lib/domain_events.rb.

Verified (2026-07-03): WeeklyDigest::AssembleDigestData, WeeklyDigest::FetchWeeklyDigestPreferences, and WeeklyDigest::SetWeeklyDigestPreferences publish no domain events. AssembleDigestData emits a weekly_digest_insight_fallback PostHog observability event on AI fallback, but this is not a DomainEvents publication. SubmitEnneagramAssessment and DismissEnneagramCard publish no domain events — both operations are fully within User Identity (writes to EnneagramAssessment and UserDetail.enneagram_card_dismissed respectively, no cross-context side effects). No new domain events are required for these three features.

goal_tracking.habit_checked_in

FieldValue
ContextGoal Tracking
PublisherGoalTracking::CheckInHabit (user logs a habit completion)
SubscribersGamification::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 whenAfter 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: }
ReplacesDirect inline GamificationService.award_xp and Gamification::AwardPartnerBonus.call invocations inside the interaction (removed in v1.12 Phase 53).

gamification.streak_advanced

FieldValue
ContextGamification
PublisherGoalTracking::CheckInHabit, GoalTracking::UseStreakFreeze (both in Goal Tracking context)
SubscriberGamification::Subscribers → enqueues Gamification::CheckBadgesJob for streak milestone badges
Published whenAfter 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: }
ReplacesImplicit side effect of habit check-in; now explicit for subscriber reactions.

gamification.streak_broken

FieldValue
ContextGamification
PublisherGoal Tracking (ProcessHabitStreaksJob — daily scheduled job)
SubscriberGamification::Subscribers → enqueues Gamification::StreakBrokenJob for resilience badges
Published whenWhen 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 }
ReplacesImplicit side effect of streak lapse; now explicit for subscriber reactions.

gamification.streak_repaired

FieldValue
ContextGamification
PublisherTwo, 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.
SubscriberNone. 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 whenAfter 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.
ReplacesImplicit side effect of repair; fire-and-forget today (no consumer wired up yet).

gamification.streak_lapsed

FieldValue
ContextGamification
PublisherGoal Tracking (ProcessHabitStreaksJob#process_lapsed_users — daily scheduled job)
SubscriberNone. 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 whenFor 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
ReplacesImplicit side effect of inactivity; published but currently unconsumed — see Subscriber above.

critical_path.stim_completed

FieldValue
ContextCritical Path
PublisherCriticalPath::RecordPlay (user submits today's completed puzzle)
SubscriberGamification::Subscribers → loads User and CriticalPathPlay by public_id and calls Gamification::AwardStimXp.call(user:, critical_path_play:) synchronously, in-process (not perform_laterionic_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 whenEvery 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 }
ReplacesDirect 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

FieldValue
ProducerGoal Tracking (AddGoal)
ConsumersGamification (badge detection)
Current ImplementationAddGoal#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

FieldValue
ProducerGoal Tracking (Goal model callback)
ConsumersGoal Tracking (creates completion event), potentially Gamification (completion badges)
Current Implementationafter_update :create_completion_event, if: :goal_completed? on Goal model
Payload{ user_id:, goal_id:, completed_at: }

GoalEventAdded

FieldValue
ProducerGoal Tracking (AddGoalEvent)
ConsumersSocial & Community (feed generation for followers)
Current ImplementationAddGoalEvent triggers GenerateFollowFeedItemsJob (already async — good pattern)
Payload{ user_id:, goal_id:, goal_event_id:, content: }

StreakFreezeUsed

FieldValue
ProducerGoal Tracking (UseStreakFreeze)
ConsumersNone currently (could notify user, award resilience badge)
Current ImplementationDirect model update within interaction
Payload{ user_id:, goal_id:, date:, remaining_freezes: }

CommunityJoined

FieldValue
ProducerSocial & Community (JoinCommunity)
ConsumersGamification (first community badge), Social & Community (community feed item)
Current ImplementationJoinCommunity triggers JoinFirstCommunityActionJob inline, creates CommunityFeedItem directly
Payload{ user_id:, community_id:, role:, is_first_community: }

UserSignedIn

FieldValue
ProducerUser Identity (RecordSignIn)
ConsumersGamification (streak XP, streak update), User Identity (sign-in history)
Current ImplementationRecordSignIn calls User#update_streak!, which calls GamificationService.award_xp directly
Payload{ user_id:, signed_in_at:, new_streak: }

UserLeveledUp

FieldValue
ProducerGamification (GamificationService.check_for_level_up)
ConsumersSocial & Community (notification, feed item)
Current Implementationcheck_for_level_up triggers CreateLevelUpNotificationJob directly
Payload{ user_id:, new_level:, xp: }

GoalFollowed

FieldValue
ProducerSocial & Community (ToggleFollowGoal)
ConsumersSocial & Community (follow notification to goal owner)
Current ImplementationNotification generated inline via UserNotification.add_follow_notification
Payload{ follower_user_id:, goal_owner_user_id:, goal_id: }

EncouragementToggled

FieldValue
ProducerSocial & Community (ToggleGoalEventEncouragement)
ConsumersSocial & Community (encouragement notification, feed item)
Current ImplementationTriggers UserNotification.add_encouragement_notification and UserFeedItem creation inline
Payload`{ user_id:, goal_event_id:, goal_id:, goal_owner_user_id:, action: 'added'

ReactionToggled

FieldValue
ProducerSocial & Community (ToggleGoalEventReaction)
ConsumersSocial & Community (reaction notification, feed item)
Current ImplementationTriggers 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

FieldValue
ProducerSocial & Community (AddGoalEventComment)
ConsumersSocial & Community (comment notification)
Current ImplementationTriggers UserNotification.add_comment_notification inline
Payload{ commenter_user_id:, goal_event_id:, goal_id:, goal_owner_user_id:, content: }

AiRunTriggered

FieldValue
ProducerAI Workforce (AiWorkforce::TriggerRun)
ConsumersAI Workforce (ExecuteRunJob), Admin (audit via AdminAction)
Current ImplementationTriggerRun enqueues ExecuteRunJob inline after creating the AiRun in queued state
Payload{ ai_employee_id:, ai_run_id:, triggered_by:, scheduled: }

AiRunCompleted

FieldValue
ProducerAI Workforce (AiWorkforce::ProcessWebhookResult)
ConsumersAI Workforce (artifact creation, cost roll-up), AI Metrics (RecordAiUsageEventJob), Admin (operator notification)
Current ImplementationWebhook 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

FieldValue
ProducerAI Workforce (AiWorkforce::RejectArtifact, or Agent Runner post-filter via ProcessWebhookResult)
ConsumersAI Workforce (memory update — feed back what failed), Admin (audit)
Current ImplementationStatus 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

FieldValue
ProducerBilling (Stripe subscription-created webhook)
ConsumersUser Identity (grant entitlement), Admin (audit)
Current ImplementationInline in webhook controller
Payload{ user_id:, plan_id:, stripe_subscription_id:, starts_at:, interval: }

SupporterExpired

FieldValue
ProducerBilling (Billing::ExpireSupportersJob)
ConsumersUser Identity (revoke entitlement), Social (optional "come back" notification)
Current ImplementationJob directly updates user entitlement fields
Payload{ user_id:, payment_record_id:, expired_at: }

FeedbackVoteToggled

FieldValue
ProducerFeedback (Feedback::ToggleFeedbackVote)
ConsumersFeedback (counter cache — handled by counter_cache: :vote_count), potentially Admin (trending-items alert)
Current ImplementationCounter 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

FieldValue
ProducerGamification (proposed BadgeEvaluator)
ConsumersSocial & Community (badge notification, feed item, push notification)
WhyCurrently 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

FieldValue
ProducerUser Identity (AddMoodLog)
ConsumersAI Coaching (mood-based insights), potentially Gamification (wellbeing badges)
WhyCurrently 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

FieldValue
ProducerGoal Tracking (Goal#update_habit_streak!)
ConsumersGamification (streak badges like consistency_is_key), Social & Community (celebration notification)
WhyStreak 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

FieldValue
ProducerSocial & Community (CreateCommunityPost)
ConsumersGamification (contribution points, badges), Social & Community (member notifications)
WhyCommunity 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

FieldValue
ProducerContent Moderation (ContentModerationService.screen, ScreenContentJob)
ConsumersAdmin notification (new item in moderation queue), potentially Social & Community (hide content pending review)
WhyCurrently, 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

FieldValue
ProducerContent Moderation (ReportContent)
ConsumersAdmin notification (moderation queue), auto-flag evaluation (escalate if report count exceeds threshold)
WhyCurrently, 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:

ruby
# 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:

ruby
# 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
end

The 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.


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 + potential feedback_contributor badge. 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::AiWorkforceControllerAiRunCompleted + AiArtifactProduced. Already uses an interaction (ProcessWebhookResult) so the shape is right — just emit events on the way out.
  • Webhooks::StripeControllerPaymentProcessed / SubscriptionCreated. First requires extracting the Billing::StripeAdapter ACL + Billing::ProcessStripeWebhook interaction (see 06-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).

ruby
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.

ruby
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
end

Handler 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_event matcher:
    ruby
    expect { 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

Loading…