Skip to content

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):

NamespaceInteractions
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):

NamespaceJobs
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)

  1. 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/) plus admin/ and infrastructure/ have been added, and the root cross-cutting interactions have been moved into admin/ or infrastructure/. Current total: 52.

  2. 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/, and feedback/.

  3. Model annotations — All 44 model files annotated with context ownership comments (# Context: Goal Tracking (Core Domain), # Aggregate Root, etc.).

  4. Frontend GraphQL splitgraphql.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 via index.js. All existing imports continue to work.

  5. Ubiquitous Language — Expanded from 25 terms to 60+ terms, organized by bounded context with Quick Reference table.

  6. 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.rb

How to do it incrementally:

  1. Create the namespace directories
  2. Move one interaction at a time, wrapping in the module
  3. Update the GraphQL mutation that calls it (e.g., GoalTracking::AddGoal.call(...))
  4. Run the affected specs after each move

Example:

ruby
# app/interactions/goal_tracking/add_goal.rb
module GoalTracking
  class AddGoal < Interaction::Base
    # ... existing code unchanged
  end
end

Backend: 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.rb

Backend: Annotate Models

Models don't need to move (they share a database), but add a context annotation comment:

ruby
# frozen_string_literal: true

# Context: Goal Tracking (Core Domain)
# Aggregate Root
class Goal < PublicRecord

Frontend: 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:

js
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 module

Frontend: 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.ts

Testing Strategy

  • After each file move, run the relevant spec suite to confirm nothing broke
  • Full bundle exec rspec after completing all backend moves
  • Full npm run test:unit -- --run after 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::AiWorkforceControllerProcessWebhookResult) 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 includes AiRunTriggered, AiRunCompleted, AiArtifactApproved, AiArtifactRejected, PaymentProcessed, SubscriptionCreated, SupporterExpired, FeedbackPosted, FeedbackVoteToggled, FeedbackStatusChanged.
  • The recommended Phase 2 kickoff is no longer the 5 original coupling points but FeedbackPosted and AiArtifactApproved — both have zero current subscribers, making them zero-risk dry runs of the publisher/subscriber/testing pattern. See 04-domain-events.md for 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.rb

Example:

ruby
# app/events/goal_tracking/habit_checked_in.rb
module GoalTracking
  HabitCheckedIn = Data.define(:user_id, :goal_id, :habit_completion_id, :completed_date, :new_streak)
end

Step 2: Create Event Publisher Module

ruby
# app/lib/domain_events.rb
module DomainEvents
  def self.publish(event_name, event)
    ActiveSupport::Notifications.instrument(event_name, event: event)
  end
end

Step 3: Publish from Interactions

Replace direct coupling with event publishing. Do this one interaction at a time.

Before (CheckInHabit):

ruby
GamificationService.award_xp(current_user, 25, 'Habit Check-in')

After:

ruby
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

ruby
# 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
end

Migration Order

Decouple one interaction at a time, in order of risk:

  1. JoinCommunity → badge job (simplest, one job trigger)
  2. AddGoal → badge jobs (three job triggers, slightly more complex)
  3. CheckInHabitGamificationService.award_xp (most impactful, saves 25 XP per check-in)
  4. RecordSignIn / User#update_streak!GamificationService.award_xp (in model, requires careful extraction)
  5. GamificationService.check_for_level_up → level-up notification (within gamification, simplest)
  6. CriticalPath::AwardStimXp → direct User writes (stim_xp_total, stim_streak_current, stim_streak_longest, stim_last_played_on)Migrated (OBJ-2370). CriticalPath::RecordPlay now publishes critical_path.stim_completed; Gamification::AwardStimXp (moved from Critical Path) performs the write as the existing sanctioned Gamification exception — see docs/architecture/proposals/critical-path-user-coupling.md

Testing Strategy

For each migration:

  1. Write a spec that the event is published with correct payload
  2. Write a spec that the subscriber handles the event correctly
  3. Verify existing integration specs still pass (the end result is identical)
  4. 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

yaml
# 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:

ruby
# 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
end

Other packages reference GoalTracking.find_goal(id) rather than Goal.public_find(id) directly.

Migration Strategy

  1. Install Packwerk: bundle add packwerk
  2. Run packwerk init to generate initial config
  3. Create packages one at a time, starting with the most independent (AI Coaching)
  4. Run packwerk check to identify existing violations
  5. Fix violations incrementally — some may require adding to the public API, others may require dependency adjustments
  6. Add packwerk check to CI pipeline
  7. 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 check as a CI step alongside RuboCop
  • Use packwerk update-deprecations to 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 LayerCurrent OrganizationProposed Organization
GraphQL constantsSingle graphql.js (1440 lines)Split into graphql/goals.js, graphql/social.js, etc. (Phase 1)
ComposablesFlat in src/composables/Group into composables/goal-tracking/, composables/social/, etc. (Phase 1)
StoresSingle themeStore.tsTheme stays global; if new stores are added, namespace by context
ViewsFlat in src/views/Keep flat (views map to routes, not contexts)
ComponentsDomain directories exist (/goal, /communities, /achievements, /admin)Already well-organized — add /social if feed components grow

Frontend Context Mapping

Bounded ContextComposablesViewsComponent Directories
Goal TrackinguseGoalForm, useProgressDataGoals, Goal, GoalCreate/goal, /dashboard (goal widgets)
Social & CommunityuseCommunity, useUnifiedFeed, useNotificationsCommunities, Community, ActivityFeed, PublicGoal/communities, /community, /feed, /public
User IdentityuseClerkSync, useProfileCompletion, usePhotoGallerySignIn, SignUp, Settings
GamificationuseAchievementNotificationsAchievements/achievements
AI CoachinguseAiCoach, useAiInsights— (modal, not a page)AiCoachModal, AiInsightCard in /ui
Content ModerationAdmin (moderation queue tab)/admin (shared with reporting)
Admin (Reporting)Admin/admin

Decision Log

DecisionRationaleRevisit When
Habits stay as Goal mode, not separate entityKeeps schema simple, one aggregate to manageHabit logic exceeds 50% of Goal model
Gamification state (xp, level) stays on User tableAvoids extra join on every authenticated requestGamification needs its own aggregate (multipliers, seasonal rules)
BuildUnifiedFeed stays as live queries, not materializedCurrent load doesn't justify the complexityFeed latency exceeds 500ms or user count passes 10k DAU
ActiveSupport::Notifications for events, not a gemZero new dependencies, synchronous by default, easy to testNeed guaranteed delivery, async processing, or event replay
Packwerk over Rails EnginesLighter weight, no separate gem dependencies, same repoNeed independent deployability or separate test suites
Mood Logging stays in User IdentityToo small for own context (8-line model)AI coaching integrates mood patterns or wellbeing dashboard ships
Content Moderation stays as cross-cutting concernSmall 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 CoachingAI 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 todayFeedback 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

Loading…