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 boundaries — rails_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:
| Metric | Today | After Phase 2 |
|---|---|---|
| Cross-context direct calls migrated to events | 0 | ≥ 3 seams |
DomainEvents.publish call sites | 0 | ≥ 3 |
| Subscribers registered in initializers | 0 | ≥ 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:
- AI Workforce added a webhook handler.
Webhooks::AiWorkforceController → ProcessWebhookResultcurrently 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. - Stripe webhooks bypass interactions entirely.
Webhooks::StripeControllercallsStripeServicedirectly and mutatesPaymentRecord,User.plan, and firesBilling::ExpireSupportersJob— none of which is testable through the interaction layer. Billing is the only bounded context without a coherent application-services entry point. - Feedback (new context) already has a notification job.
NotifyVotersJobis enqueued fromCreateFeedbackPost. 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-goal | Why excluded |
|---|---|
| Event sourcing | No event log, no replay, no projections. Events are notifications, not a system of record. |
| CQRS | No 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 registry | Payload 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 once | 15+ implicit events cataloged in 04-domain-events.md. v1 migrates 3. |
Replacing after_commit callbacks | Model callbacks stay. Events emit from interactions, not from ActiveRecord lifecycle. |
| Packwerk / static boundary enforcement | Phase 3. Events must demonstrate value before we police them. |
Scope
What ships in v1
1. DomainEvents facade module — rails_api/app/lib/domain_events.rb
A ~40-line wrapper over ActiveSupport::Notifications with a two-method public surface:
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
endWhy a facade, not direct ActiveSupport::Notifications:
- Payload validation (fails loudly when
:aggregate_idis missing — no silent data gaps) - One place to add future concerns (Sentry breadcrumbs, Sidekiq dispatch, test helpers)
- Search-able:
DomainEvents.publishis more greppable thanActiveSupport::Notifications.instrument("domain.…") domain.prefix isolates our events from Rails' built-insql.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 ornilfor 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 matcher — spec/support/matchers/publish_domain_event.rb
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)
| # | Seam | Today | After |
|---|---|---|---|
| 1 | Feedback → Notifications | CreateFeedbackPost enqueues NotifyVotersJob inline | CreateFeedbackPost publishes feedback.post_created; subscriber in Feedback::Subscribers enqueues the job |
| 2 | AI Workforce → Delivery | ApproveArtifact enqueues DeliverArtifactJob inline | ApproveArtifact publishes ai_workforce.artifact_approved; subscriber enqueues delivery |
| 3 | Stripe webhook → Billing | Webhooks::StripeController calls StripeService + mutates user inline | Controller 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 subscribers — config/initializers/domain_event_subscribers.rb
Each context owns a <Context>::Subscribers module that registers its own subscriptions. The initializer is a one-liner manifest:
Rails.application.config.after_initialize do
Feedback::Subscribers.register
AiWorkforce::Subscribers.register
Billing::Subscribers.register
endSubscribers registered after initialization so ActiveRecord is loaded. Each .register method is idempotent (no duplicate handlers on Rails reload in dev).
6. Docs update — docs/architecture/ddd/04-domain-events.md
- Replace "Implementation Status: NOT STARTED" banner with "v1 shipped; 3 seams migrated"
- Move
FeedbackPosted,AiArtifactApproved,PaymentProcessedfrom "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::StripeControllerno longer callsStripeServicedirectly (verified bygrep -L "StripeService" rails_api/app/controllers/webhooks/stripe_controller.rb) - [ ]
CreateFeedbackPost,AiWorkforce::ApproveArtifactcontain zeroperform_latercalls (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.mdno longer contains the string "NOT STARTED" - [ ]
docs/architecture/ddd/04-domain-events.mdcontains a "Writing a new domain event" section - [ ]
CHANGELOG.mdhas 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.
| Option | Pros | Cons | Decision |
|---|---|---|---|
ActiveSupport::Notifications | Zero 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. |
wisper | Object-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_store | Battle-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-events | Clean API, type-safe. | Adds dry-rb stack. Team unfamiliarity. | Reject — not worth the learning-curve tax for v1. |
| Custom class registry | Total 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
| Risk | Likelihood | Mitigation |
|---|---|---|
| Subscribers swallow exceptions silently | Medium | DomainEvents::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 specs | High | Registry 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) | Medium | Code 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) | Medium | Facade enforces <context>.<snake_case> via regex; invalid names raise at publish time |
| Stripe webhook retries produce duplicate events | High | Billing::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 sprint | Low | Single 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).
- Day 1–2: Write
DomainEventsfacade + RSpec matcher +domain_event_subscribers.rbinitializer scaffold. - Day 3–4: Migrate seam 1 (Feedback). Smallest, lowest-risk — proves the pattern end-to-end.
- Day 5–6: Migrate seam 2 (AI Workforce artifact approval).
- Day 7–8: Migrate seam 3 (Stripe webhook) — largest, includes new
Billing::ProcessStripeWebhookinteraction. - Day 9: Update 04-domain-events.md, add
CHANGELOG.mdentry, update 01-bounded-contexts.md to reflect Billing now has a webhook interaction. - Day 10: PR review, merge, deploy to staging. Watch Sentry for
DomainEvents.publisherrors 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 seam | Why it becomes cheap |
|---|---|
habit.checked_in → Gamification XP + Social feed | The highest-traffic cross-context call; now paved road. |
goal.created / goal.completed → Gamification badge detection | Replaces three *Job enqueues inline in AddGoal; enables future "goal completed" email without touching Goal Tracking. |
community.post_created → Notification fan-out | Follower notifications decoupled from post creation. |
content.flagged → Moderation queue | Reporter-side interaction stays clean; moderation context owns the reaction. |
user.signed_up → Welcome email, analytics identify, admin notification | Three 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_eventstable 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:
- Should
DomainEvents.publishaccept 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. - Event name prefix —
domain.or bare? Leaningdomain.to isolate from Rails built-ins inActiveSupport::Notificationssubscribers and in any future Sentry breadcrumb noise. - Should subscribers register in the context's own file or a central manifest? Proposing per-context
Subscribers.registermodule invoked from one central initializer — discoverable (grep one file for all subscriptions) and still co-located (each context owns its handlers). - Stripe
PaymentRecordidempotency column name? Ifstripe_event_iddoesn't exist today, Seam 3 adds a migration. Confirm during planning.
References
- DDD Overview
- Bounded Contexts
- Domain Events — implicit inventory
- Modularization Guide
- Interaction Gem Evolution PRD — adjacent work on the primitive events are published from
- Rails docs: ActiveSupport::Notifications
Last updated: 2026-06-30 — Phase 100 audit. PRD for shipped feature (v1.5) archived to completed/.