v1.5 — DDD Phase 2: Domain Events
Ship a thin
DomainEventsfacade overActiveSupport::Notificationsand 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:
- Feedback —
UpdateFeedbackPostStatusnow publishesfeedback.post_status_changed; a subscriber enqueues the existing voter-notification job. - AI Workforce —
ApproveArtifactnow publishesai_workforce.artifact_approved; a subscriber enqueues the existing delivery job. - Billing — the Stripe webhook controller thinned from ~90 to 26 lines and delegates to a new
Billing::ProcessStripeWebhookinteraction; the interaction publishesbilling.payment_processedand uses astripe_event_ididempotency 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
DomainEventsfacade overActiveSupport::Notificationsand 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 includeaggregate_id,actor_id,occurred_at.DomainEvents.subscribe(name) { |event| ... }returning aDomainEvents::Eventstruct withname,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@registeredguard. DomainEvents.reset_for_testing!for RSpecafter(:each).
Phase 18 — Feedback + AI Workforce seams
Feedback::UpdateFeedbackPostStatuspublishesfeedback.post_status_changed.AiWorkforce::ApproveArtifactpublishesai_workforce.artifact_approved.- Subscribers registered in
Feedback::Subscribers.registerandAiWorkforce::Subscribers.registerenqueue the existingFeedback::NotifyVotersJobandAiWorkforce::DeliverArtifactJobrespectively. - Behaviour-preserving: existing specs passed without modification.
Phase 19 — Billing seam + Stripe webhook refactor
- New
Billing::ProcessStripeWebhookinteraction (Billing context's first application service). stripe_eventstable gained astripe_event_idunique index for idempotency.- Webhook controller thinned from ~90 to 26 lines and delegates to the interaction.
Billing::Subscribers.registerhandlesbilling.payment_processedfor 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.instrumentis not called directly outside theDomainEventsfacade.
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 17 | Event Infrastructure & Testing Support | Shipped | 1 | DomainEvents facade, RSpec matcher, subscriber scaffold |
| 18 | Seam 1 (Feedback) + Seam 2 (AI Workforce) | Shipped | 1 | Two low-risk isomorphic seams migrated in one PR |
| 19 | Seam 3 (Billing) + Stripe webhook refactor | Shipped | 1 | New interaction + idempotency column + thin controller |
| 20 | Documentation & acceptance gates | Shipped | 1 | Docs, 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
@registeredguard. - Greenfield event modelling deferred —
HabitCheckedInand 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.
| Prefix | Count | Meaning |
|---|---|---|
| EVT | 6 | Event primitive + payload contract |
| TEST | 2 | RSpec matcher + isolation helpers |
| SUB | 2 | Subscriber registration scaffold |
| Seam-specific | 22 | Per-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_idunique index makes replay-safety a database invariant rather than application logic.
Related Artifacts
- Milestones index: .planning/MILESTONES.md
- PRD: DDD Phase 2 — Domain Events PRD
- DDD docs: Domain Events
- Merge PR (PRD): #318
- Merge PR (implementation): #319
Related Commits
465d233b— feat(v1.5): DDD Phase 2 — Domain Events v1 (#319)858df244— docs(product): add DDD Phase 2 Domain Events v1 PRD3461f43f— chore: complete v1.5 milestone — archive and prepare for v1.6
Last updated: 2026-05-23