Skip to content

DDD Phase 2 — Domain Events v1

Product: Objectuve (codename: Enkidu) Feature: Explicit domain events as the decoupling primitive between bounded contexts Status: ✅ Shipped (v1.5) Date: April 2026 Type: Architecture / Infrastructure PRD Depends on: DDD Phase 1 (logical namespacing — shipped April 2026) Blocks: DDD Phase 3 (Packwerk enforcement)


TL;DR

Phase 1 of our DDD work gave us logical boundariesrails_api/app/interactions/ is now organised by bounded context (10 namespaces), and the DDD docs describe 8 contexts with explicit aggregates, ubiquitous language, and coupling. Phase 1 moved the files. It did not move the dependencies.

Today, a cross-context reaction to a domain action is still expressed as a direct method call or an inline perform_later. CheckInHabit calls GamificationService.award_xp directly. ApproveArtifact enqueues DeliverArtifactJob inline. CreateFeedbackPost enqueues NotifyVotersJob inline. The two webhook controllers (Stripe, AI Workforce) bypass the interaction layer entirely.

This PRD ships Domain Events v1 — a thin, synchronous, in-process event bus built on ActiveSupport::Notifications, a three-field payload convention, RSpec matchers, and the migration of three concrete seams to prove the pattern. It is explicitly not event sourcing, not CQRS, not an async queue. It is the smallest change that lets us decouple cross-context reactions without buying more infrastructure.

Success is measured by three counts going up and one going down:

MetricTodayAfter Phase 2
Cross-context direct calls migrated to events0≥ 3 seams
DomainEvents.publish call sites0≥ 3
Subscribers registered in initializers0≥ 3
ActiveSupport::Notifications.instrument mentions in PR reviews as "ad hoc"0 (replaced by the DomainEvents facade)

Scope is deliberately small: two weeks, one engineer, one PR. Phase 3 (Packwerk) and async transport (Sidekiq-backed subscribers) are explicitly out of scope and get their own PRDs once v1 has shipped and been lived with for a milestone.


Why now?

Three forcing functions have emerged since Phase 1 shipped:

  1. AI Workforce added a webhook handler. Webhooks::AiWorkforceController → ProcessWebhookResult currently does run-status mutation, artifact persistence, and job enqueueing in one interaction. The next integration (Slack, GitHub PR delivery confirmation) will copy-paste this shape unless we give it a seam.
  2. Stripe webhooks bypass interactions entirely. Webhooks::StripeController calls StripeService directly and mutates PaymentRecord, User.plan, and fires Billing::ExpireSupportersJob — none of which is testable through the interaction layer. Billing is the only bounded context without a coherent application-services entry point.
  3. Feedback (new context) already has a notification job. NotifyVotersJob is enqueued from CreateFeedbackPost. This is the smallest possible cross-context dependency in the codebase, and the cheapest possible pilot for an event-driven alternative.

If we don't establish the pattern now, the next 3–6 months of feature work will bake in another dozen direct couplings that Phase 2 will have to unwind. Do it once, do it small, do it while the list is short.


Non-goals

Explicit exclusions — each deserves a future PRD, none of them belong in v1:

Non-goalWhy excluded
Event sourcingNo event log, no replay, no projections. Events are notifications, not a system of record.
CQRSNo read models, no separate write/read paths. ActiveRecord stays authoritative.
Async transport (Sidekiq-backed subscribers)Synchronous, in-process only in v1. Async is a follow-up once we understand failure modes.
Event versioning / schema registryPayload shape is convention-enforced, not contract-enforced. Revisit if >10 events.
Cross-service events (Agent Runner)Agent Runner webhooks remain HTTP + HMAC. Events are in-process Rails only.
Migrating every implicit event at once15+ implicit events cataloged in 04-domain-events.md. v1 migrates 3.
Replacing after_commit callbacksModel callbacks stay. Events emit from interactions, not from ActiveRecord lifecycle.
Packwerk / static boundary enforcementPhase 3. Events must demonstrate value before we police them.

Scope

What ships in v1

1. DomainEvents facade modulerails_api/app/lib/domain_events.rb

A ~40-line wrapper over ActiveSupport::Notifications with a two-method public surface:

ruby
module DomainEvents
  # Publish an event. Namespace = "<context>.<action>", past tense.
  # Payload must include :aggregate_id, :actor_id, :occurred_at.
  def self.publish(event_name, payload)
    validated = validate_payload!(event_name, payload)
    ActiveSupport::Notifications.instrument("domain.#{event_name}", validated)
  end

  # Subscribe to an event. Block receives a DomainEvents::Event struct.
  def self.subscribe(event_name, &block)
    ActiveSupport::Notifications.subscribe("domain.#{event_name}") do |*args|
      block.call(Event.from_notification(args))
    end
  end
end

Why a facade, not direct ActiveSupport::Notifications:

  • Payload validation (fails loudly when :aggregate_id is missing — no silent data gaps)
  • One place to add future concerns (Sentry breadcrumbs, Sidekiq dispatch, test helpers)
  • Search-able: DomainEvents.publish is more greppable than ActiveSupport::Notifications.instrument("domain.…")
  • domain. prefix isolates our events from Rails' built-in sql.active_record, process_action.action_controller, etc.

2. Event naming + payload convention — documented in docs/architecture/ddd/04-domain-events.md

  • Name: <context>.<entity>_<verb_past_tense> — e.g., feedback.post_created, ai_workforce.artifact_approved, billing.payment_processed
  • Required payload keys: :aggregate_id (public_id of the root), :actor_id (User public_id or nil for system), :occurred_at (Time)
  • Optional payload: Any domain-relevant fields; avoid full serialized models (subscribers should re-fetch if they need fresh state)

3. RSpec matcherspec/support/matchers/publish_domain_event.rb

ruby
expect { CreateFeedbackPost.call(user_id: user.public_id, title: 'x', body: 'y') }
  .to publish_domain_event('feedback.post_created')
  .with(aggregate_id: kind_of(String), actor_id: user.public_id)

Lets us assert event emission without asserting downstream effects — the interaction under test stays focused.

4. Three seam migrations (the core of the work)

#SeamTodayAfter
1Feedback → NotificationsCreateFeedbackPost enqueues NotifyVotersJob inlineCreateFeedbackPost publishes feedback.post_created; subscriber in Feedback::Subscribers enqueues the job
2AI Workforce → DeliveryApproveArtifact enqueues DeliverArtifactJob inlineApproveArtifact publishes ai_workforce.artifact_approved; subscriber enqueues delivery
3Stripe webhook → BillingWebhooks::StripeController calls StripeService + mutates user inlineController calls new Billing::ProcessStripeWebhook interaction, which publishes billing.payment_processed; subscribers handle plan mutation and ExpireSupportersJob scheduling

Seam 3 is the biggest and the most valuable — it pulls Billing inside the interaction layer for the first time, closing the one bounded-context gap called out in the DDD audit.

5. Initializer for subscribersconfig/initializers/domain_event_subscribers.rb

Each context owns a <Context>::Subscribers module that registers its own subscriptions. The initializer is a one-liner manifest:

ruby
Rails.application.config.after_initialize do
  Feedback::Subscribers.register
  AiWorkforce::Subscribers.register
  Billing::Subscribers.register
end

Subscribers registered after initialization so ActiveRecord is loaded. Each .register method is idempotent (no duplicate handlers on Rails reload in dev).

6. Docs updatedocs/architecture/ddd/04-domain-events.md

  • Replace "Implementation Status: NOT STARTED" banner with "v1 shipped; 3 seams migrated"
  • Move FeedbackPosted, AiArtifactApproved, PaymentProcessed from "Currently Implicit" to "Published (v1)" table
  • Add "Writing a new domain event" runbook (5 steps: name it, publish, subscribe, test, document)

Acceptance criteria

A reviewer should be able to verify v1 is complete by running these checks:

  • [ ] grep -r "DomainEvents.publish" rails_api/app/interactions/ returns ≥ 3 call sites
  • [ ] grep -r "DomainEvents.subscribe" rails_api/app/ returns ≥ 3 subscriber registrations across ≥ 3 context namespaces
  • [ ] Webhooks::StripeController no longer calls StripeService directly (verified by grep -L "StripeService" rails_api/app/controllers/webhooks/stripe_controller.rb)
  • [ ] CreateFeedbackPost, AiWorkforce::ApproveArtifact contain zero perform_later calls (verified by grep)
  • [ ] A new RSpec matcher file exists at spec/support/matchers/publish_domain_event.rb
  • [ ] Each migrated interaction has a spec asserting event publication (3 new specs minimum)
  • [ ] Each subscriber has a spec asserting its handler runs on the matching event (3 new specs minimum)
  • [ ] docs/architecture/ddd/04-domain-events.md no longer contains the string "NOT STARTED"
  • [ ] docs/architecture/ddd/04-domain-events.md contains a "Writing a new domain event" section
  • [ ] CHANGELOG.md has an entry under the active version: DDD Phase 2 v1 — Domain events shipped for Feedback, AI Workforce, Billing
  • [ ] All existing specs pass unchanged (no regression from migrated seams)
  • [ ] SimpleCov coverage stays ≥ 80%

Design decisions

Why ActiveSupport::Notifications and not [gem]?

Candidates considered: wisper, rails_event_store, dry-events, custom class-registry.

OptionProsConsDecision
ActiveSupport::NotificationsZero dependencies. Already loaded. Subscribers auto-isolate (exceptions in one handler don't affect others per Rails 7.1+). Search-indexed by Rails developers.Stringly-typed. Payload is Hash, not a struct. Built for instrumentation, not domain events.Chosen. Minimal surface, wrapper fixes the ergonomic gaps.
wisperObject-based publishers, ergonomic subscribe.Adds gem. Async support requires wisper-sidekiq addon. Last release 2022.Reject — maintenance concerns, and AS::N already gives us what we need.
rails_event_storeBattle-tested, persists events, built-in projections.Brings a full event-sourcing mental model we don't want. DB table. Schema migrations per event change.Reject — scope creep. Revisit if we ever want event sourcing.
dry-eventsClean API, type-safe.Adds dry-rb stack. Team unfamiliarity.Reject — not worth the learning-curve tax for v1.
Custom class registryTotal control, domain-modelled event classes.Reinvents instrumentation. Zero value over ActiveSupport::Notifications.Reject — we're not here to build frameworks.

Why synchronous?

Async transport (Sidekiq-backed subscribers) is seductive but introduces a pile of new concerns: retry semantics, dead-letter queues, out-of-order delivery, subscriber idempotency, monitoring. The three migrated seams all already hand off to Sidekiq via perform_later — synchronous subscribers that themselves call perform_later preserve existing behavior exactly. Async dispatch of the event itself solves no current problem. Revisit if we ever need a subscriber that must not block the publisher (we don't today).

Why webhook controllers publish events through an interaction, not directly?

Publishing from a controller would:

  • Split the "authorization + parsing + domain action" flow across two layers
  • Skip the interaction layer's built-in Sentry breadcrumb + error handling
  • Make the controller untestable without the webhook payload shape

Wrapping webhook logic in an interaction (Billing::ProcessStripeWebhook, AiWorkforce::ProcessWebhookResult already exists) keeps the pattern uniform: controllers and jobs are thin; interactions are the only thing that publishes events.

Why three seams and not one?

One seam is an anecdote. Three seams prove the pattern works across three different shapes:

  • Feedback: simplest (one interaction, one subscriber, one job) — proves the basic flow.
  • AI Workforce: subscriber composes with existing DeliverArtifactJob — proves events work alongside existing async infra.
  • Billing: webhook + multiple subscribers (plan mutation, supporter scheduling) — proves fan-out and proves the webhook pattern.

Three is also the smallest number that forces us to write the facade generically rather than hard-coding to one seam.

Why not migrate CheckInHabit → GamificationService.award_xp?

The highest-traffic cross-context coupling in the codebase is the obvious pick, but it's also the riskiest. XP awards are part of the activation funnel and touch every user. v1 deliberately avoids user-visible hot paths and picks seams that are already async (all three proposed seams already hand off to Sidekiq). HabitCheckedIn is a Phase 2.1 candidate, after we've lived with the v1 pattern for one milestone.


Risks and mitigations

RiskLikelihoodMitigation
Subscribers swallow exceptions silentlyMediumDomainEvents::Event wrapper captures subscriber exceptions to Sentry with event_name tag; integration spec asserts Sentry capture path
Test pollution — subscribers registered in config.after_initialize leak across specsHighRegistry tracks subscriptions and exposes DomainEvents.reset_for_testing! for RSpec after(:each); documented in testing guide
Developers publish events then also do the inline work ("belt and suspenders" anti-pattern)MediumCode review checklist: migrated interaction should delete the inline call, not duplicate it; matcher assertion catches duplicate side effects
Event naming drift (post_created vs feedback_post_created vs feedback.posted)MediumFacade enforces <context>.<snake_case> via regex; invalid names raise at publish time
Stripe webhook retries produce duplicate eventsHighBilling::ProcessStripeWebhook interaction uses Stripe's event.id for idempotency (lookup-or-create on PaymentRecord.stripe_event_id) — same defense as today, pattern unchanged
Phase 2 work blocks unrelated feature work during the sprintLowSingle PR, single engineer, reversible — facade plus 3 isolated migrations. Nothing in the public API changes.

Rollout plan

Single PR, reviewed and merged over ~2 weeks. No feature flag needed — the change is behavior-preserving (same side effects happen, just routed through one more layer).

  1. Day 1–2: Write DomainEvents facade + RSpec matcher + domain_event_subscribers.rb initializer scaffold.
  2. Day 3–4: Migrate seam 1 (Feedback). Smallest, lowest-risk — proves the pattern end-to-end.
  3. Day 5–6: Migrate seam 2 (AI Workforce artifact approval).
  4. Day 7–8: Migrate seam 3 (Stripe webhook) — largest, includes new Billing::ProcessStripeWebhook interaction.
  5. Day 9: Update 04-domain-events.md, add CHANGELOG.md entry, update 01-bounded-contexts.md to reflect Billing now has a webhook interaction.
  6. Day 10: PR review, merge, deploy to staging. Watch Sentry for DomainEvents.publish errors for 48 hours before tagging a production release.

Rollback: Revert the single PR. The facade, subscribers, and migrated interactions land together, so reverting restores pre-Phase-2 behavior exactly. No schema changes to unwind.


Phase 2.1+ — what v1 unlocks

After v1 has shipped and been lived with for one milestone, the following become cheap incremental wins (each is 1–3 days, no new infrastructure):

Next seamWhy it becomes cheap
habit.checked_in → Gamification XP + Social feedThe highest-traffic cross-context call; now paved road.
goal.created / goal.completed → Gamification badge detectionReplaces three *Job enqueues inline in AddGoal; enables future "goal completed" email without touching Goal Tracking.
community.post_created → Notification fan-outFollower notifications decoupled from post creation.
content.flagged → Moderation queueReporter-side interaction stays clean; moderation context owns the reaction.
user.signed_up → Welcome email, analytics identify, admin notificationThree separate subscribers; today a 40-line ClerkUserSync method.

And further out:

  • Sidekiq-backed subscribers (Phase 2.2): introduce DomainEvents.subscribe_async(event_name, job_class) for subscribers that must not block the publisher. Opt-in, per-subscriber.
  • Event audit log (Phase 2.3): optional persistence of published events to a domain_events table for debugging and analytics. Not event sourcing — a ring buffer for the last N days.
  • Packwerk enforcement (Phase 3): with events as the cross-context API, Packwerk can enforce that interactions in context A don't reference interactions/models in context B except through published events. The boundary graph becomes real.

Open questions

Non-blocking for v1, but worth resolving during implementation review:

  1. Should DomainEvents.publish accept a block for "work done before the event fires"? Useful for "publish after DB commit" semantics. Leaning no — keep the facade tiny; callers can wrap in their own transaction.
  2. Event name prefix — domain. or bare? Leaning domain. to isolate from Rails built-ins in ActiveSupport::Notifications subscribers and in any future Sentry breadcrumb noise.
  3. Should subscribers register in the context's own file or a central manifest? Proposing per-context Subscribers.register module invoked from one central initializer — discoverable (grep one file for all subscriptions) and still co-located (each context owns its handlers).
  4. Stripe PaymentRecord idempotency column name? If stripe_event_id doesn't exist today, Seam 3 adds a migration. Confirm during planning.

References


Last updated: 2026-06-30 — Phase 100 audit. PRD for shipped feature (v1.5) archived to completed/.

Loading…