Skip to content

v1.5 — DDD Phase 2: Domain Events

Ship a thin DomainEvents facade over ActiveSupport::Notifications and migrate three cross-context seams (Feedback, AI Workforce, Billing) so contexts publish intent-events instead of directly enqueuing each other's jobs.

Summary

The DDD roadmap split the domain-driven-design work into phases: Phase 1 established bounded-context structure and aggregate roots; Phase 2 introduced the event infrastructure that lets contexts communicate without direct coupling. v1.5 delivered Phase 2 v1 — deliberately thin, deliberately synchronous.

The milestone shipped a single primitive: DomainEvents.publish(name, payload) with a matching subscriber model, an RSpec matcher (publish_domain_event), and a subscriber-registration scaffold that survives Rails dev-reload. Three cross-context seams migrated to the primitive:

  1. FeedbackUpdateFeedbackPostStatus now publishes feedback.post_status_changed; a subscriber enqueues the existing voter-notification job.
  2. AI WorkforceApproveArtifact now publishes ai_workforce.artifact_approved; a subscriber enqueues the existing delivery job.
  3. Billing — the Stripe webhook controller thinned from ~90 to 26 lines and delegates to a new Billing::ProcessStripeWebhook interaction; the interaction publishes billing.payment_processed and uses a stripe_event_id idempotency column to dedupe replays.

Phase 20 closed the milestone with documentation updates, the CHANGELOG entry, runbook additions, and grep gates that prevent regressions. The milestone was entirely behaviour-preserving — users saw no change. The payoff is structural: future features can add new subscribers without editing the publishing interaction.

Goal

Ship a thin, synchronous DomainEvents facade over ActiveSupport::Notifications and migrate three concrete cross-context seams (Feedback, AI Workforce, Billing) so contexts publish intent-events instead of directly enqueuing each other's jobs. The primitive must be simple enough that adding a new event is a three-line change, and subscribers can be added without touching the publishing interaction.

Scope — What Shipped

Phase 17 — Event Infrastructure & Testing Support

  • DomainEvents.publish(name, payload) with validation: name must match <context>.<snake_case_past_tense>; payload must include aggregate_id, actor_id, occurred_at.
  • DomainEvents.subscribe(name) { |event| ... } returning a DomainEvents::Event struct with name, aggregate_id, actor_id, occurred_at, payload.
  • Raising subscribers do not propagate; Sentry receives the exception tagged with event_name.
  • RSpec matcher: expect { ... }.to publish_domain_event('x.y_happened').with(payload_matcher) supporting both literals and matchers (kind_of, match, include).
  • Subscriber registration scaffold in config/initializers/domain_event_subscribers.rb — dev-reload safe via an idempotent @registered guard.
  • DomainEvents.reset_for_testing! for RSpec after(:each).

Phase 18 — Feedback + AI Workforce seams

  • Feedback::UpdateFeedbackPostStatus publishes feedback.post_status_changed.
  • AiWorkforce::ApproveArtifact publishes ai_workforce.artifact_approved.
  • Subscribers registered in Feedback::Subscribers.register and AiWorkforce::Subscribers.register enqueue the existing Feedback::NotifyVotersJob and AiWorkforce::DeliverArtifactJob respectively.
  • Behaviour-preserving: existing specs passed without modification.

Phase 19 — Billing seam + Stripe webhook refactor

  • New Billing::ProcessStripeWebhook interaction (Billing context's first application service).
  • stripe_events table gained a stripe_event_id unique index for idempotency.
  • Webhook controller thinned from ~90 to 26 lines and delegates to the interaction.
  • Billing::Subscribers.register handles billing.payment_processed for downstream side effects.

Phase 20 — Documentation & acceptance gates

  • DDD docs updated with the events pattern and a 5-step contributor runbook.
  • CHANGELOG entry recorded under "DDD Phase 2 — Domain Events v1".
  • AI runbook updated with the new subscriber-registration pattern.
  • All five hard grep gates are green: GATE-01 through GATE-05 ensure ActiveSupport::Notifications.instrument is not called directly outside the DomainEvents facade.

Phases

PhaseNameStatusPlansHighlights
17Event Infrastructure & Testing SupportShipped1DomainEvents facade, RSpec matcher, subscriber scaffold
18Seam 1 (Feedback) + Seam 2 (AI Workforce)Shipped1Two low-risk isomorphic seams migrated in one PR
19Seam 3 (Billing) + Stripe webhook refactorShipped1New interaction + idempotency column + thin controller
20Documentation & acceptance gatesShipped1Docs, CHANGELOG, runbook, 5/5 grep gates green

Key Decisions

  • Thin facade over ActiveSupport::Notifications — Rails already provides the primitive; the facade adds name + payload validation and a subscriber-registration scaffold. Writing our own pub/sub was out of scope.
  • Synchronous publish — v1 events fire in the publishing thread. Async delivery is deferred to a future Phase 2.1+ once the pattern has been lived with for a milestone.
  • Four phases, not three — collapsing Phases 18 + 19 was considered and rejected: Billing introduces a brand-new interaction, a schema migration, and idempotency semantics. Different blast radius, different review surface.
  • Per-context Subscribers modules registered via central initializer — keeps subscriber wiring discoverable and Rails-dev-reload safe via an idempotent @registered guard.
  • Greenfield event modelling deferredHabitCheckedIn and other candidate events were evaluated and dropped. v1 migrated only the three seams already shaped as "interaction publishes → subscriber enqueues existing job".
  • Behaviour-preserving migration — every existing spec passed unchanged. No semantic changes to Feedback, AI Workforce, or Billing in this milestone.

Requirements Coverage

32 / 32 requirements satisfied.

PrefixCountMeaning
EVT6Event primitive + payload contract
TEST2RSpec matcher + isolation helpers
SUB2Subscriber registration scaffold
Seam-specific22Per-seam migration + behaviour preservation

Outcomes

  • Contexts now publish intent-events rather than directly enqueuing other contexts' jobs.
  • Adding a new subscriber (e.g., "email the admin on artifact approval") requires zero edits to the publishing interaction.
  • Every existing user-visible behaviour is preserved.
  • The subscriber-registration pattern is documented and CI-enforced via the five grep gates.
  • Stripe webhook idempotency is now structural — the stripe_event_id unique index makes replay-safety a database invariant rather than application logic.
  • 465d233b — feat(v1.5): DDD Phase 2 — Domain Events v1 (#319)
  • 858df244 — docs(product): add DDD Phase 2 Domain Events v1 PRD
  • 3461f43f — chore: complete v1.5 milestone — archive and prepare for v1.6

Last updated: 2026-05-23

Loading…