Modularization Guide
A phased approach to enforcing domain boundaries. Each phase builds on the previous one, is independently valuable, and can be paused without leaving the codebase in a broken state.
Phase 1: Logical Namespacing — COMPLETED (Apr 2026) + Expanded Since
Risk: Low — pure organization, no behavioral changes. Effort: Small-medium. Status: Complete and continuously expanded as new contexts land.
The goal was to make domain ownership visible in the file system without changing any runtime behavior.
Current state (2026-04-13)
Namespaced organization now spans 17 interaction directories (interaction count last verified 2026-07-10; see below) and 9 job directories:
Interactions (151 total across 17 namespaces, plus 2 unnamespaced top-level files — verified 2026-07-10):
| Namespace | Interactions |
|---|---|
admin/ | 6 |
ai/ | 7 |
ai_workforce/ | 7 |
billing/ | 4 |
content_moderation/ | 5 |
critical_path/ | 7 |
demo_data/ | 8 |
feedback/ | 5 |
gamification/ | 7 |
gdpr/ | 5 |
goal_tracking/ | 9 |
infrastructure/ | 1 |
onboarding/ | 2 |
social/ | 38 |
teams/ | 23 |
user_identity/ | 12 |
weekly_digest/ | 3 |
Plus 2 unnamespaced top-level files: dismiss_enneagram_card.rb, submit_enneagram_assessment.rb.
Jobs (25 total across 9 namespaces + root):
| Namespace | Jobs |
|---|---|
ai_coaching/ | 1 (generate_ai_check_in_prompts_job) |
ai_metrics/ | 2 (ai_budget_alert_job, record_ai_usage_event_job) |
ai_workforce/ | 5 (budget_alert_job, deliver_artifact_job, execute_run_job, schedule_runs_job, timeout_stale_runs_job) |
billing/ | 1 (expire_supporters_job) |
content_moderation/ | 1 |
feedback/ | 1 (notify_voters_job) |
gamification/ | 8 |
social/ | 5 |
| (root) | application_job.rb |
What was originally shipped (Phase 1 snapshot)
Backend interactions — originally 38 interactions namespaced into 5 context directories (
goal_tracking/,social/,user_identity/,gamification/,content_moderation/) with cross-cutting interactions at root. Since then, three new contexts (ai_workforce/,billing/,feedback/) plusadmin/andinfrastructure/have been added, and the root cross-cutting interactions have been moved intoadmin/orinfrastructure/. Current total: 52.Backend jobs — originally 13 jobs across 4 context directories. Since expanded to 25 jobs across 9 directories with the addition of
ai_metrics/,ai_workforce/,billing/, andfeedback/.Model annotations — All 44 model files annotated with context ownership comments (
# Context: Goal Tracking (Core Domain),# Aggregate Root, etc.).Frontend GraphQL split —
graphql.js(1,620 lines) split into 7 context-aligned modules (goals.js,social.js,user.js,gamification.js,ai.js,moderation.js,admin.js) with barrel re-export viaindex.js. All existing imports continue to work.Ubiquitous Language — Expanded from 25 terms to 60+ terms, organized by bounded context with Quick Reference table.
Specs — All interaction and job specs moved to matching namespace directories. Class references updated. 761 examples, 0 failures.
Backend: Namespace Interactions (Reference)
Move interactions into context-namespaced directories:
app/interactions/
goal_tracking/
add_goal.rb # GoalTracking::AddGoal
update_goal.rb
add_goal_event.rb
update_goal_event.rb
check_in_habit.rb
use_streak_freeze.rb
calculate_goal_progress.rb
social/
create_community.rb # Social::CreateCommunity
join_community.rb
leave_community.rb
follow_community.rb
unfollow_community.rb
create_community_post.rb
add_goal_to_community.rb
add_community_suggestion.rb
add_post_comment.rb
toggle_follow_goal.rb
toggle_goal_event_encouragement.rb
toggle_goal_event_reaction.rb
add_goal_event_comment.rb
build_unified_feed.rb
send_push_notification.rb
delete_notification.rb
content_moderation/
report_content.rb # ContentModeration::ReportContent
review_content_flag.rb
review_content_report.rb
user_identity/
clerk_user_sync.rb # UserIdentity::ClerkUserSync
update_user.rb
update_user_photo.rb
store_user_details.rb
store_device_token.rb
record_sign_in.rb
add_mood_log.rb
gamification/
acknowledge_action.rb # Gamification::AcknowledgeAction
acknowledge_notification.rb
achievements/
update_showcased_achievements.rbHow to do it incrementally:
- Create the namespace directories
- Move one interaction at a time, wrapping in the module
- Update the GraphQL mutation that calls it (e.g.,
GoalTracking::AddGoal.call(...)) - Run the affected specs after each move
Example:
# app/interactions/goal_tracking/add_goal.rb
module GoalTracking
class AddGoal < Interaction::Base
# ... existing code unchanged
end
endBackend: Namespace Jobs
app/jobs/
gamification/
create_first_goal_action_job.rb
create_second_category_action_job.rb
create_second_type_action_job.rb
join_first_community_action_job.rb
create_level_up_notification_job.rb
process_habit_streaks_job.rb
social/
generate_follow_feed_items_job.rb
generate_notification_job.rb
generate_reminder_notifications_job.rb
generate_past_due_notification_job.rb
generate_activity_reminder_job.rb
ai_coaching/
generate_ai_check_in_prompts_job.rb
content_moderation/
screen_content_job.rbBackend: Annotate Models
Models don't need to move (they share a database), but add a context annotation comment:
# frozen_string_literal: true
# Context: Goal Tracking (Core Domain)
# Aggregate Root
class Goal < PublicRecordFrontend: Split graphql.js — COMPLETED
The former ionic_frontend/src/constants/graphql.js single file has been split into context-aligned modules under ionic_frontend/src/constants/graphql/, including fragments.js, goals.js, social.js, user.js, gamification.js, ai.js, moderation.js, and further contexts added since (feedback.js, billing.js, challenges.js, dataExport.js, enneagram.js, criticalPath.js, notifications.js, teams.js, privacy.js, analytics.js, connectedApps.js, dataImport.js, seasonalEvents.js).
The barrel index.js re-exports all named exports so existing imports don't break:
export * from './fragments'
export * from './goals'
export * from './social'
export * from './user'
export * from './gamification'
export * from './ai'
export * from './moderation'
// ...and one line per additional context moduleFrontend: Organize Composables
Composables already map cleanly to contexts. Create index files for discoverability:
src/composables/
goal-tracking/
useGoalForm.ts
useProgressData.ts
social/
useCommunity.ts
useUnifiedFeed.ts
useNotifications.ts
gamification/
useAchievementNotifications.ts
ai/
useAiCoach.ts
useAiInsights.ts
user/
useClerkSync.ts
useProfileCompletion.tsTesting Strategy
- After each file move, run the relevant spec suite to confirm nothing broke
- Full
bundle exec rspecafter completing all backend moves - Full
npm run test:unit -- --runafter completing all frontend splits - Smoke tests should pass unchanged since runtime behavior doesn't change
Rollback
Git revert any commit. Since this is pure file reorganization with module wrapping, every move is independently revertable.
Phase 2: Domain Events
Risk: Medium — changes runtime behavior by decoupling call chains. Effort: Medium. Status: Not started. Zero ActiveSupport::Notifications usage in rails_api/ as of 2026-04-13.
Replace the 5 tight coupling points identified in the context map with explicit domain events using ActiveSupport::Notifications. Since the initial Phase 2 plan, three new bounded contexts have landed (AI Workforce, Billing, Feedback) that strengthen the case for event-based decoupling:
- The AI Workforce webhook (
Webhooks::AiWorkforceController→ProcessWebhookResult) and the Stripe webhook (Webhooks::StripeController) are natural event-emission points. Webhook handlers already translate external shapes into internal state, which is exactly the translation an event bus layer performs — the mental model is a perfect match. - The expanded event roster (see
04-domain-events.md) now includesAiRunTriggered,AiRunCompleted,AiArtifactApproved,AiArtifactRejected,PaymentProcessed,SubscriptionCreated,SupporterExpired,FeedbackPosted,FeedbackVoteToggled,FeedbackStatusChanged. - The recommended Phase 2 kickoff is no longer the 5 original coupling points but
FeedbackPostedandAiArtifactApproved— both have zero current subscribers, making them zero-risk dry runs of the publisher/subscriber/testing pattern. See04-domain-events.mdfor the rationale.
Since Critical Path shipped after this plan was written, a sixth coupling surface was identified: CriticalPath::AwardStimXp wrote directly to four stim_* progression fields on User (stim_xp_total, stim_streak_current, stim_streak_longest, stim_last_played_on) without routing through GamificationService. It's entry 6 in the Migration Order below. Josh decided Option C on 2026-08-09 (docs/architecture/proposals/critical-path-user-coupling.md): Option B (route through GamificationService/an event, no direct User write) is the target state, with Option A's direct write recorded as a transitional shared-kernel exception. Migrated per OBJ-2370 — see 03 — Context Map § Resolved: Critical Path → Gamification.
Step 1: Define Event Classes
Create typed event payloads as Ruby Data classes:
app/events/
goal_tracking/
goal_created.rb
goal_completed.rb
goal_event_added.rb
habit_checked_in.rb
streak_milestone_reached.rb
social/
community_joined.rb
goal_followed.rb
encouragement_toggled.rb
comment_added.rb
user_identity/
user_signed_in.rb
gamification/
badge_unlocked.rb
user_leveled_up.rbExample:
# app/events/goal_tracking/habit_checked_in.rb
module GoalTracking
HabitCheckedIn = Data.define(:user_id, :goal_id, :habit_completion_id, :completed_date, :new_streak)
endStep 2: Create Event Publisher Module
# app/lib/domain_events.rb
module DomainEvents
def self.publish(event_name, event)
ActiveSupport::Notifications.instrument(event_name, event: event)
end
endStep 3: Publish from Interactions
Replace direct coupling with event publishing. Do this one interaction at a time.
Before (CheckInHabit):
GamificationService.award_xp(current_user, 25, 'Habit Check-in')After:
DomainEvents.publish('goal_tracking.habit_checked_in',
GoalTracking::HabitCheckedIn.new(
user_id: current_user.id, goal_id: goal.id,
habit_completion_id: completion.id, completed_date: date,
new_streak: goal.habit_streak))Step 4: Subscribe from Consuming Contexts
# config/initializers/domain_event_subscriptions.rb
# Gamification subscribes to Goal Tracking events
ActiveSupport::Notifications.subscribe('goal_tracking.habit_checked_in') do |*, payload|
event = payload[:event]
user = User.find(event.user_id)
GamificationService.award_xp(user, 25, 'Habit Check-in')
end
ActiveSupport::Notifications.subscribe('goal_tracking.goal_created') do |*, payload|
event = payload[:event]
# Badge evaluation logic (extracted from AddGoal)
end
ActiveSupport::Notifications.subscribe('social.community_joined') do |*, payload|
event = payload[:event]
if event.is_first_community
Gamification::JoinFirstCommunityActionJob.perform_later(event.user_id)
end
end
ActiveSupport::Notifications.subscribe('user_identity.user_signed_in') do |*, payload|
event = payload[:event]
user = User.find(event.user_id)
GamificationService.award_xp(user, 50, 'Daily Streak') if user.current_streak > user.current_streak_was
endMigration Order
Decouple one interaction at a time, in order of risk:
JoinCommunity→ badge job (simplest, one job trigger)AddGoal→ badge jobs (three job triggers, slightly more complex)CheckInHabit→GamificationService.award_xp(most impactful, saves 25 XP per check-in)RecordSignIn/User#update_streak!→GamificationService.award_xp(in model, requires careful extraction)GamificationService.check_for_level_up→ level-up notification (within gamification, simplest)— Migrated (OBJ-2370).CriticalPath::AwardStimXp→ directUserwrites (stim_xp_total,stim_streak_current,stim_streak_longest,stim_last_played_on)CriticalPath::RecordPlaynow publishescritical_path.stim_completed;Gamification::AwardStimXp(moved from Critical Path) performs the write as the existing sanctioned Gamification exception — seedocs/architecture/proposals/critical-path-user-coupling.md
Testing Strategy
For each migration:
- Write a spec that the event is published with correct payload
- Write a spec that the subscriber handles the event correctly
- Verify existing integration specs still pass (the end result is identical)
- Run full suite after each interaction is migrated
Rollback
Each interaction migration is independent. If an event-based approach causes issues for one interaction, revert that single change while keeping others.
Phase 3: Packwerk Boundaries
Risk: Higher — introduces tooling that enforces boundaries at the package level. Effort: Large. Status: Deferred until Phase 2 ships. Attempting to model boundaries before events are in place would lock in the current tight-coupling graph rather than the desired event-driven graph. When: After Phase 2 (Domain Events) is complete, stable, and has demonstrated its value on at least the webhook seams and one of the original 5 coupling points.
Note for when we pick this back up: the Packwerk boundary graph must model the three contexts added since the original Phase 3 plan — AI Workforce, Billing, and Feedback. These each deserve their own pack with explicit public APIs; AI Workforce in particular needs a public/agent_runner_adapter.rb entry to seal the Agent Runner boundary.
What is Packwerk?
Packwerk is Shopify's tool for enforcing module boundaries in Rails monoliths. It:
- Defines "packages" (directories with
package.yml) - Checks for dependency violations (one package referencing another's internals)
- Checks for privacy violations (referencing non-public constants)
- Runs as a CI check, like a linter
Proposed Package Structure
rails_api/
packs/
goal_tracking/
package.yml
app/
models/
goal.rb
goal_event.rb
milestone.rb
habit_completion.rb
...
interactions/
add_goal.rb
...
public/ # Explicit public API
goal_tracking.rb
social/
package.yml
app/
models/
community.rb
community_post.rb
user_ally.rb
...
interactions/
...
public/
social.rb
gamification/
package.yml
app/
models/
user_action.rb
services/
gamification_service.rb
public/
gamification.rb
ai_coaching/
package.yml
app/
services/
ai/
coach_service.rb
public/
ai_coaching.rb
app/ # Core / User Identity stays in main app
models/
user.rb
...Package Dependencies
# packs/goal_tracking/package.yml
enforce_dependencies: true
enforce_privacy: true
dependencies:
- . # Main app (User Identity)
# packs/social/package.yml
enforce_dependencies: true
enforce_privacy: true
dependencies:
- . # Main app (User Identity)
- packs/goal_tracking # Reads goal data for feed, sharing
# packs/gamification/package.yml
enforce_dependencies: true
enforce_privacy: true
dependencies:
- . # Main app (User Identity)
# NOTE: No dependency on goal_tracking or social — communication via events only
# packs/ai_coaching/package.yml
enforce_dependencies: true
enforce_privacy: true
dependencies:
- . # Main app (User Identity)
- packs/goal_tracking # Reads goal data (conformist)Public APIs
Each package exposes a public module that other packages import:
# packs/goal_tracking/app/public/goal_tracking.rb
module GoalTracking
def self.find_goal(public_id)
Goal.public_find(public_id)
end
def self.active_habits_for(user)
Goal.where(user: user).active_habits
end
endOther packages reference GoalTracking.find_goal(id) rather than Goal.public_find(id) directly.
Migration Strategy
- Install Packwerk:
bundle add packwerk - Run
packwerk initto generate initial config - Create packages one at a time, starting with the most independent (AI Coaching)
- Run
packwerk checkto identify existing violations - Fix violations incrementally — some may require adding to the public API, others may require dependency adjustments
- Add
packwerk checkto CI pipeline - Work toward zero violations
Testing Strategy
- Packwerk is a static analysis tool — it doesn't change runtime behavior
- All existing specs continue to pass
- Add
packwerk checkas a CI step alongside RuboCop - Use
packwerk update-deprecationsto track progress on existing violations
Rollback
Remove package.yml files and the Packwerk gem. No runtime code changes are needed. The directory structure from Phase 1 remains useful regardless.
Frontend Alignment
The frontend should mirror backend context boundaries for consistency.
Current State
| Frontend Layer | Current Organization | Proposed Organization |
|---|---|---|
| GraphQL constants | Single graphql.js (1440 lines) | Split into graphql/goals.js, graphql/social.js, etc. (Phase 1) |
| Composables | Flat in src/composables/ | Group into composables/goal-tracking/, composables/social/, etc. (Phase 1) |
| Stores | Single themeStore.ts | Theme stays global; if new stores are added, namespace by context |
| Views | Flat in src/views/ | Keep flat (views map to routes, not contexts) |
| Components | Domain directories exist (/goal, /communities, /achievements, /admin) | Already well-organized — add /social if feed components grow |
Frontend Context Mapping
| Bounded Context | Composables | Views | Component Directories |
|---|---|---|---|
| Goal Tracking | useGoalForm, useProgressData | Goals, Goal, GoalCreate | /goal, /dashboard (goal widgets) |
| Social & Community | useCommunity, useUnifiedFeed, useNotifications | Communities, Community, ActivityFeed, PublicGoal | /communities, /community, /feed, /public |
| User Identity | useClerkSync, useProfileCompletion, usePhotoGallery | SignIn, SignUp, Settings | — |
| Gamification | useAchievementNotifications | Achievements | /achievements |
| AI Coaching | useAiCoach, useAiInsights | — (modal, not a page) | AiCoachModal, AiInsightCard in /ui |
| Content Moderation | — | Admin (moderation queue tab) | /admin (shared with reporting) |
| Admin (Reporting) | — | Admin | /admin |
Decision Log
| Decision | Rationale | Revisit When |
|---|---|---|
| Habits stay as Goal mode, not separate entity | Keeps schema simple, one aggregate to manage | Habit logic exceeds 50% of Goal model |
| Gamification state (xp, level) stays on User table | Avoids extra join on every authenticated request | Gamification needs its own aggregate (multipliers, seasonal rules) |
BuildUnifiedFeed stays as live queries, not materialized | Current load doesn't justify the complexity | Feed latency exceeds 500ms or user count passes 10k DAU |
ActiveSupport::Notifications for events, not a gem | Zero new dependencies, synchronous by default, easy to test | Need guaranteed delivery, async processing, or event replay |
| Packwerk over Rails Engines | Lighter weight, no separate gem dependencies, same repo | Need independent deployability or separate test suites |
| Mood Logging stays in User Identity | Too small for own context (8-line model) | AI coaching integrates mood patterns or wellbeing dashboard ships |
| Content Moderation stays as cross-cutting concern | Small scope (3 interactions, 2 models, 1 service). No independent aggregate lifecycle — attaches to content from other contexts. | Moderation gains its own workflow (appeal process, escalation tiers, audit dashboard) |
| AI Workforce is a first-class context, distinct from AI Coaching | AI Workforce owns persistent AiEmployee aggregates with autonomy levels, budgets, memory, schedules, and operator approval flow. AI Coaching is stateless per-user prompt generation. Conflating them would blur the very different invariants each context enforces. | If AI Coaching grows its own persistent state (e.g., per-user coach preferences become a full aggregate with lifecycle), revisit. |
| FeedbackPost is not moderated content today | Feedback is a low-volume product channel with direct staff review built into Feedback::UpdateFeedbackPostStatus. No ContentFlag polymorphic wiring, no moderation_status column. | User-to-user feedback replies become adversarial, or volume grows beyond staff review capacity. Then extend the existing Content Moderation cross-cutting wiring to feedback models. |
| Stripe webhook bypasses the interaction layer (today) | Historical — webhook handling landed in the controller before the DDD pass. | Always — this is a known gap; extract Billing::StripeAdapter + Billing::ProcessStripeWebhook before any further Stripe surface is added. |
Strategic Evolution
Five recommendations distilled from the April 2026 audit. Each is a small, independently valuable bet that reduces coupling, clarifies boundaries, or prepares the codebase for event-driven architecture.
1. Promote AI Workforce as a first-class context distinct from AI Coaching
What: Treat AI Workforce (ai_workforce/ interactions, AiEmployee/AiRun/AiArtifact/AiEmployeeMemory aggregates, Agent Runner integration) as a bounded context in its own right — not a subfeature of AI Coaching.
Why: The invariants are fundamentally different. AI Coaching is stateless, per-user, request/response. AI Workforce is stateful, per-employee, scheduled, with graduated autonomy and operator approval workflow. Keeping them separate in documentation and interaction namespaces protects both from each other's design pressures. (Already done in code via the ai_workforce/ vs. ai_coaching/ directory split; this audit formalizes it in docs.)
2. Add Billing::StripeAdapter ACL mirroring the Clerk pattern
What: Extract a Billing::StripeAdapter service (checkout, subscription, webhook translation) and a Billing::ProcessStripeWebhook interaction. Webhooks::StripeController becomes a thin HMAC verifier that delegates to the interaction.
Why: Today the controller performs webhook signature verification, event parsing, and PaymentRecord upserts all in one place. This is the single biggest ACL gap in the system. The Clerk ACL (ClerkJwtVerifier + ClerkUserSync) is the exact shape to mirror: one service isolates the external SDK, one service/interaction maps profile→local records, and the controller is a translator.
3. Treat webhook handlers as the first Domain Event emission points
What: Kick off Phase 2 at the two inbound webhook seams — Webhooks::AiWorkforceController (AiRunCompleted, AiArtifactProduced) and Webhooks::StripeController (PaymentProcessed, SubscriptionCreated) — rather than the original 5 tight-coupling points.
Why: Webhook handlers already do the "translate external shape → internal shape" step. Adding a DomainEvents.publish(...) on the way out is a one-line change, and the asynchronous nature of webhooks means zero concern about synchronous call-chain regressions. This establishes the event-bus pattern on the safest possible code paths before touching hot paths like CheckInHabit.
4. Decide explicitly whether FeedbackPost is moderable content (current answer: no)
What: Document the decision that Feedback models are not part of Content Moderation today, and define the revisit trigger.
Why: The question "does FeedbackPost get a moderation_status column?" is the kind of decision that, if left ambiguous, produces inconsistent product behavior and future refactor work. Making it explicit — with a revisit trigger tied to volume and adversarial behavior — saves that cost. (Decision recorded in 01-bounded-contexts.md and the Decision Log above.)
5. Defer Packwerk; enforce boundaries via events first
What: Postpone Phase 3 (Packwerk). Focus energy on Phase 2 (Domain Events) across webhook seams and the original 5 coupling points. Revisit Packwerk once events are the primary inter-context communication pattern.
Why: Packwerk freezes the dependency graph at whatever point you run it. Freezing today's graph — with CheckInHabit directly calling GamificationService.award_xp — codifies exactly the coupling we want to remove. Phase 2 changes which edges exist; Phase 3 then enforces the resulting (much cleaner) graph.
Last updated: 2026-09-19