Skip to content

Application Services

Every Interaction::Base subclass in app/interactions/ is an application service — a use case that orchestrates domain logic. This document maps each interaction to its bounded context, classifies its DDD role, and flags cross-context coupling.

Classification Key

TypeDescription
Application ServiceOrchestrates a use case. Coordinates aggregates, calls domain services, triggers side effects.
Domain ServicePure business logic that doesn't belong to any single aggregate. Stateless.
Query ServiceRead-only. Aggregates data from one or more contexts without side effects.

Namespace Placement Rule

An interaction lives in the namespace of the bounded context that owns the aggregate it mutates — not the namespace of whichever context happens to share its verb. 01 — Bounded Contexts states the same principle for the two unnamespaced enneagram interactions ("should be UserIdentity::…"); this section makes it explicit and gives it a worked example.

Worked example — UserNotification: the model is unambiguously owned by Social & Community (rails_api/app/models/user_notification.rb:3# Context: Social & Community (Accountability)). That ownership splits into two different responsibilities:

  • Producing a notification (UserNotification.generate) is open to every context — it's shared delivery infrastructure (the model header calls it "Entity (delivery infrastructure)"). Gamification, Teams, Feedback, and AI all legitimately call it today.
  • Managing a notification's lifecycle or read-state — acknowledge, acknowledge-all, delete, list — belongs to the context that owns the aggregate: Social & Community.

AcknowledgeNotification and AcknowledgeAllNotifications lived under Gamification:: for a historical reason (the shared verb "Acknowledge," which AcknowledgeAction also uses for badges) until OBJ-2950 moved them into Social::, alongside the pre-existing DeleteNotification and BuildNotificationHistory. AcknowledgeAction itself stays in Gamification — it manages UserAction (badges), which Gamification owns, so the rule places it correctly as-is.

Interaction Registry

Goal Tracking

InteractionTypeDescriptionSide EffectsCoupling
AddGoalApplication ServiceCreate a goal with type, category, recurrence, optional milestonesCreates initial GoalEvent, triggers badge jobs (CreateFirstGoalActionJob, CreateSecondCategoryActionJob, CreateSecondTypeActionJob)Tight to Gamification — inline badge detection and job triggers. Should publish GoalCreated event.
UpdateGoalApplication ServiceEdit goal properties, mark complete/incomplete, soft deleteCreates completion event if newly completedSelf-contained
AddGoalEventApplication ServiceLog a progress update on a goalCreates GoalEvent, triggers GenerateFollowFeedItemsJobLoose to Social — async job (good pattern)
UpdateGoalEventApplication ServiceEdit or soft-delete a goal eventNoneSelf-contained
CheckInHabitApplication ServiceOne-tap daily habit completionCreates HabitCompletion, creates GoalEvent, updates streak, calls GamificationService.award_xp(user, 25), triggers GenerateFollowFeedItemsJobTight to Gamification — direct award_xp call. Loose to Social — async job.
UseStreakFreezeApplication ServiceApply a freeze token to cover a missed dayUpdates goal streak fieldsSelf-contained
CalculateGoalProgressQuery ServiceGroup events by period, return progress data pointsNone (read-only)Self-contained
BuildGoalSummaryQuery ServiceAggregate the current user's dashboard goal summary. Input: current_user (required, guards unauthenticated via fail_with(..., code: :unauthorized)). Returns result.details with exactly total_goals, avg_progress, completed_count, need_attention_count. Extracted from goal_queries.rb#goal_summary, which is now a thin resolver over this interaction — response shape unchanged.None (read-only)Self-contained

Social & Community

InteractionTypeDescriptionSide EffectsCoupling
CreateCommunityApplication ServiceCreate a community, make founder adminCreates CommunityMember with admin roleSelf-contained
JoinCommunityApplication ServiceAdd user to community as memberCreates CommunityFeedItem, triggers JoinFirstCommunityActionJobTight to Gamification — inline badge job trigger
LeaveCommunityApplication ServiceRemove membershipDestroys CommunityMemberSelf-contained
FollowCommunityApplication ServiceSubscribe to community notificationsCreates CommunityFollowSelf-contained
UnfollowCommunityApplication ServiceUnsubscribeDestroys CommunityFollowSelf-contained
CreateCommunityPostApplication ServicePost in a communityCreates CommunityPostSelf-contained
AddGoalToCommunityApplication ServiceShare a goal into a communityCreates CommunityGoal join recordReads from Goal Tracking (loose)
AddCommunitySuggestionApplication ServiceSubmit community recommendationCreates CommunitySuggestionSelf-contained
ToggleFollowGoalApplication ServiceFollow/unfollow a public goalCreates/destroys UserGoalFollow, triggers follow notificationReads from Goal Tracking (loose)
ToggleGoalEventEncouragementApplication ServiceLike/unlike a goal eventSoft-creates/deletes encouragement, triggers notification and feed itemReads from Goal Tracking (loose)
ToggleGoalEventReactionApplication ServiceToggle emoji reaction on a goal eventSoft-creates/deletes/swaps reaction, triggers notification and feed itemReads from Goal Tracking (loose)
AddGoalEventCommentApplication ServiceComment on a goal eventCreates comment, triggers notificationReads from Goal Tracking (loose)
AddPostCommentApplication ServiceComment on a community postCreates comment, triggers ContentModerationService.screenSelf-contained (content screening is fire-and-forget)
BuildUnifiedFeedQuery ServiceAggregate activity from allies, communities, own goals, notificationsNone (read-only)Reads from Goal Tracking, User Identity, Social (multi-context projection)
SendPushNotificationApplication ServiceDeliver push notification to user devicesSends push via external serviceReads from User Identity (devices)
AcknowledgeNotificationApplication ServiceMark a notification as readUpdates UserNotification.acknowledgedSelf-contained
AcknowledgeAllNotificationsApplication ServiceBatch mark-all-read, optional team_id scopeUpdates UserNotification.acknowledged via update_allSelf-contained
DeleteNotificationApplication ServiceRemove a notificationDestroys UserNotificationSelf-contained
BuildNotificationHistoryQuery ServicePaginated notification history + unread count, optional team scopeNone (read-only)Self-contained

User Identity & Profile

InteractionTypeDescriptionSide EffectsCoupling
ClerkUserSyncApplication ServiceFind or create local user from Clerk profileCreates/updates UserReads from Clerk (external, via ACL)
UpdateUserApplication ServiceEdit profile (name, username)Syncs to Clerk APIWrites to Clerk (external)
UpdateUserPhotoApplication ServiceUpload profile imageCreates/updates UserPhotoSelf-contained
StoreUserDetailsApplication ServiceSave extended profile metadataCreates/updates UserDetailSelf-contained
StoreDeviceTokenApplication ServiceRegister push notification tokenCreates UserDeviceSelf-contained
RecordSignInApplication ServiceTrack sign-in, update streakCreates UserSignInHistory, calls User#update_streak! which calls GamificationService.award_xpTight to Gamification — update_streak! directly calls award_xp

Gamification & Progression

InteractionTypeDescriptionSide EffectsCoupling
AcknowledgeActionApplication ServiceMark a badge as seenUpdates UserAction.acknowledgedSelf-contained
UpdateShowcasedAchievementsApplication ServiceCustomize profile badge showcaseUpdates user showcase preferencesSelf-contained
RepairStreakApplication ServiceRebuild missed habit completions for a user; triggered after vacation or gap. Loops through missed days, publishes gamification.streak_repaired domain event, awards repair_streak milestone badgeUpdates goal streak fields, calls GamificationService.award_xp to debit the repair cost, publishes gamification.streak_repairedTight to Gamification — direct award_xp call (XP debit). Loose to Goal Tracking.
AwardPartnerBonusApplication ServiceAward XP bonus when both allied users check in on the same day; called from Gamification::Subscribers after goal_tracking.habit_checked_in event firesCalls GamificationService.award_xpLoose to User Identity
GrantStarterFreezeApplication ServiceIssue one free streak freeze token to new users in their first week; triggered from UserIdentity::ClerkUserSyncUpdates goal.streak_freezes_availableLoose to Goal Tracking

AI Coaching

InteractionTypeDescriptionSide EffectsCoupling
AI mutations (via app/services/ai/)Domain ServiceGenerate milestones, refine descriptions, give advice, generate insightsWrites AiUsageEvent via AiMetrics::RecordAiUsageEventJobReads from Goal Tracking (conformist)
Ai::DocsAssistant::AnswerGuideQuestion (Guide Assistant sub-context — see 01 — Bounded Contexts)Application ServiceAnswer a public guide question, grounded only in the committed guide corpus; refuses questions out of scopeWrites AiUsageEvent via AiMetrics::RecordAiUsageEventJobSelf-contained — reads no other context

AI Workforce

InteractionTypeDescriptionSide EffectsCoupling
AiWorkforce::TriggerRunApplication ServiceCreate a queued AiRun (deduplicates against existing queued/running runs), enqueue ExecuteRunJobCreates AiRun, enqueues Sidekiq jobSelf-contained within context
AiWorkforce::UpdateEmployeeApplication ServiceUpdate employee configuration (name, schedule, budget, autonomy level)Writes AiEmployee with promotion validationSelf-contained
AiWorkforce::ApproveArtifactApplication ServiceOperator approval of a pending artifactUpdates AiArtifact.approval_status, enqueues DeliverArtifactJobSelf-contained (delivery is async)
AiWorkforce::RejectArtifactApplication ServiceOperator (or auto via post-filter) rejectionUpdates AiArtifact.approval_statusSelf-contained
AiWorkforce::ProcessWebhookResultApplication ServiceApply Agent Runner HMAC-verified webhook payload to AiRun lifecycleUpdates run status, creates AiArtifacts, records costExternal — receives webhook via Webhooks::AiWorkforceController. Direct JSON→model mapping today; candidate for AgentRunnerAdapter ACL.

Billing

InteractionTypeDescriptionSide EffectsCoupling
Billing::CreateCheckoutApplication ServiceCreate a Stripe checkout session for a chosen Plan, return redirect URLCalls StripeService (external)Writes to Stripe (external)
Billing::ProcessStripeWebhookApplication ServiceIdempotent processor for Stripe webhook events (checkout.session.completed, invoice.paid, customer.subscription.deleted, charge.refunded); stamps stripe_event_id on PaymentRecord and publishes billing.payment_processedPersists PaymentRecord, publishes domain eventDelegated-to from Webhooks::StripeController after signature verification; Billing::Subscribers mutates user plan in response to the event

Feedback

InteractionTypeDescriptionSide EffectsCoupling
Feedback::CreateFeedbackPostApplication ServiceCreate a feedback postPersists FeedbackPostSelf-contained
Feedback::CreateFeedbackCommentApplication ServiceReply to a feedback post; sets is_official for staffPersists FeedbackComment, updates counter cacheReads User role
Feedback::ToggleFeedbackVoteApplication ServiceAdd or remove a votePersists/destroys FeedbackVote, updates counter cacheSelf-contained
Feedback::UpdateFeedbackPostStatusApplication ServiceAdmin transitions status (openplannedin_progresscompleted/declined); sets shipped_at when completedUpdates post, enqueues Feedback::NotifyVotersJobAdmin-only; notification is async (good pattern)

GDPR & Data Rights (Cross-Cutting)

InteractionTypeDescriptionSide EffectsCoupling
Gdpr::SubmitRequestApplication ServicePublic intake for a new GDPR request (export, deletion, or rectification — not "access", see 01 — Bounded Contexts).Creates GdprRequest(status: 'received'), enqueues Gdpr::SendAcknowledgmentJob (→ GdprMailer.acknowledgment)Reads User Identity (email); writes to GDPR context
Gdpr::CascadePreviewQuery ServiceAdmin-only dry-run: per-model counts of what a deletion would touch.None (read-only projection)Reads from all contexts (Goal Tracking, Social, Gamification, User Identity, AI, Admin)
Gdpr::ExecuteDeletionApplication ServiceAdmin-triggered, step-up-gated. Burns the step-up token, resolves the GdprRequest, delegates the actual cascade to Gdpr::UserDataCascade, then finalizes the request. Mixed hard/soft-delete cascade — not a uniform soft-delete. Hard-deletes goals, mood logs, badges, devices, community memberships, AI memories; soft-deletes/scrubs community posts/comments and the User row itself.Cascading hard + soft deletes across multiple tables, atomic transactionWrites to Goal Tracking, Social, Gamification, User Identity, AI, Admin (scrub only)
Gdpr::DeleteSelfServiceAccountApplication ServiceSelf-service (OBJ-2183), no admin gate — an authenticated user deletes their own account. Delegates the same cascade to Gdpr::UserDataCascade.Same cascading hard + soft deletes as Gdpr::ExecuteDeletion; writes its own AdminAction (actor: the user, no GdprRequest)Writes to Goal Tracking, Social, Gamification, User Identity, AI
Gdpr::UserDataCascadeDomain ServiceShared cascade — the actual hard/soft-delete steps for a full account erasure, called by both Gdpr::ExecuteDeletion and Gdpr::DeleteSelfServiceAccount. Not itself an Interaction::Base; runs inside the caller's own transaction.Cascading hard + soft deletes across multiple tablesWrites to Goal Tracking, Social, Gamification, User Identity, AI, Admin (scrub only)
Gdpr::ExecuteExportApplication ServiceAdmin-only (super_admin/support). Transitions request to processing, delegates the actual build to a job.Enqueues Gdpr::BuildExportBundleJob, which builds via Gdpr::ExportBuilder, uploads to GCS, sets export_file_url on the GdprRequest, sends GdprMailer.export_readyReads all contexts; writes to GDPR context; sends mail
Gdpr::RequestSelfServiceExportApplication ServiceSelf-service export (no admin gate), 24h per-email cooldown.Same downstream job as ExecuteExportSame as above

Admin (Cross-Cutting)

InteractionTypeDescriptionSide EffectsCoupling
Admin::GenerateWordCloudQuery ServiceWord-frequency analysis of goal names (moved from root cross-cutting)None (read-only)Reads from Goal Tracking
Admin::LoggedApplication Service (helper/base)Wraps admin-scoped actions to write AdminAction audit entriesCreates AdminActionCross-cutting — used by admin-scoped mutations
Admin::UpdateUserRolesApplication ServiceGrant/revoke AdminRole records for a userWrites AdminRole, syncs legacy User#admin flagWrites User Identity
Admin::UserSearchQuery ServicePaginated admin user search by email/username/roleNone (read-only)Reads User Identity

Content Moderation (Cross-Cutting)

InteractionTypeDescriptionSide EffectsCoupling
ReportContentApplication ServiceUser reports content (post, comment). Creates ContentReport and ContentFlag.Creates both records in a single transactionSelf-contained. Allowed types: CommunityPost, PostComment, GoalEventComment
ReviewContentFlagApplication ServiceAdmin approves or rejects a content flag. Updates flagged content's moderation_status.Updates flag status and content moderation_statusSelf-contained (admin-only)
ReviewContentReportApplication ServiceAdmin reviews or dismisses a user report.Updates report statusSelf-contained (admin-only)

Cross-Cutting / Infrastructure

InteractionTypeDescriptionCoupling
Infrastructure::GeneratePublicIdDomain ServiceGenerate URL-safe base64 tokenInfrastructure — used by all PublicRecord models
UserIdentity::AddMoodLogApplication ServiceCreate mood check-inSelf-contained (within User Identity)
UserIdentity::UpdateCoachingPreferencesApplication ServiceUpdate a user's AI coaching preferences (frequency, tone, etc.)Self-contained (within User Identity)

Note: GenerateWordCloud now lives under the Admin:: namespace; GeneratePublicId now lives under Infrastructure::.

Domain Services (Non-Interaction)

These are service objects in app/services/ that contain domain logic but aren't interactions.

Registration rule (gate g4)

app/services/ holds external adapters (Stripe, Clerk, PostHog, Sentry) and domain services registered in the table below. Registration requires passing a two-part discriminator, both parts required:

  • (a) Side-effect-free — no create!/update!/save!/destroy anywhere in the class. An external adapter is exempt from this part only (e.g. StripeService#find_or_create_customer legitimately calls user.update! to cache the Stripe customer id).
  • (b) Shared by ≥2 caller files, or mirroring a documented cross-stack contract (e.g. a frontend composable of the same shape).

A class meeting the discriminator but missing from this table is a doc-drift finding — register it, don't relocate it. A class in app/services/ that orchestrates writes and isn't an external adapter — fails part (a), not registered, no adapter exemption — is the actual boundary violation and belongs in app/interactions/. This is gate g4's own wording (.claude/skills/objectuve-keystone/references/arch-gates.md:21); registry presence in the table below is the documented exception the gate defers to — the mechanism that closes a "worth documenting, not silently tolerating" audit finding rather than leaving it as ambient allowance.

ServiceContextPurposeCalled By
GamificationServiceGamificationAward XP, check level-up, calculate thresholdsCheckInHabit, User#update_streak!, various jobs
ClerkJwtVerifierUser IdentityVerify Clerk RS256 JWTs via JWKSGraphQL context setup (every request)
AdminStatsServiceReportingPlatform-wide metrics (users, goals, growth)Admin GraphQL queries
Ai::CoachServiceAI CoachingAI milestone generation, advice, insightsAI GraphQL mutations (Mutations::Ai::*)
Ai::SettingsGuardAI CoachingShared fail-closed helper for Settings.ai feature flags — an unknown/renamed flag is treated as disabled rather than letting the error propagateAI GraphQL mutations (GenerateGoalDraft, RefineDescription, GenerateMilestones, GetInsight, GetAdvice, RefineGoal), AiCoaching::GenerateGoalQuickUpdatesJob, Ai::ServiceClient
Ai::RateLimiterAI CoachingRedis-backed fixed-window rate limiter — would_exceed? (read-only pre-check) and check_and_increment (atomic enforcer)AI GraphQL mutations (GetAdvice, GenerateGoalDraft, RefineGoal, GenerateMilestones, SuggestGoals), Ai::ServiceClient
Ai::ModelCatalogAI CoachingAllowlist and effective-dated rate schedule for routable Gemini model ids (allowlisted?/routable?/rate_for)Ai::SetCoachModel, Ai::CoachModel, Ai::Pricing, admin GraphQL queries (admin_queries.rb)
ContentModerationServiceContent ModerationEntry point for automated screening (profanity filter + async AI screening)AddPostComment, CreateCommunityPost, and other content-creating interactions
ContentModeration::ProfanityFilterContent ModerationRule-based profanity detection, creates ContentFlag if issues foundContentModerationService.screen
Gdpr::ExportBuilderGDPR & Data RightsBuilds the export bundle content read across bounded contexts. Respects soft deletions and privacy settings.Gdpr::BuildExportBundleJob (enqueued from Gdpr::ExecuteExport / Gdpr::RequestSelfServiceExport)
Ai::AgentRunnerClientAI WorkforceHTTP client dispatching runs to Agent Runner (Faraday, 202 fire-and-forget)AiWorkforce::ExecuteRunJob
Ai::OidcAuthAI WorkforceOIDC identity-token fetcher for authenticated calls to GCP Cloud Run servicesAgentRunnerClient, ServiceClient
StripeServiceBillingWrapper around the stripe gem (checkout, subscription, billing portal)Billing::CreateCheckout, Webhooks::StripeController
GoalProgressCalculatorGoal TrackingShared progress derivation (OBJ-1978) — habit completionRate → roadmap milestone % → currentAmount/targetAmount → not-tracked (percent: nil) precedence. Mirrors ionic_frontend's useGoalProgress composable so backend and frontend can't drift on what "progress" means. Pure computation, no writes.PersonalAnalytics::CalculateCompletionByCategory, GoalTracking::BuildGoalSummary (reached transitively via goal_queries.rb#goal_summary)
CommunityAccessPolicySocial & CommunityTeam-scoped room access boundary — gates feed/member/badge/post access to a team_id-present community to actual CommunityMembers (team membership alone covers room metadata, not room contents); admins bypass. Pure read/predicate, no writes.Social::CreateCommunityPost, app/graphql/types/community_type.rb, app/graphql/resolvers/community_queries.rb
TeamAccessPolicyBilling (Teams)Centralizes every "can user X read/mutate Y in Team T" role check (view_team_data/manage_team/manage_billing against owner/admin/member/external_coach roles) — every Teams read and mutate path routes through it instead of inline role checks. Pure predicate methods, no writes. Pre-existing precedent for this registration rule; previously registered nowhere despite ~20 caller files (19 Teams:: interactions + 1 resolver).Teams::* interactions (CreateTeamInvite, AdjustTeamSeats, PromoteTeamMember, and 16 more), app/graphql/resolvers/team_queries.rb
Social::ShareableSubjectSocial & CommunityResolves and authorizes the celebration a share/preview refers to — owned-and-earned check shared by the preview query and the record mutation, so both agree on exactly what a caller may see or share. Pure predicate/lookup, no writes.Social::RecordShare, Resolvers::SocialQueries#shareable_moment
Social::ShareableMomentCopySocial & CommunityMaps a resolved, owned-and-earned share subject onto the fact fields the client's ShareCardData discriminated union expects (ionic_frontend/src/share/types.ts) — despite the name, returns facts only; template strings live client-side in src/share/copy.ts. Pure computation, no writes. Registered under discriminator (b)'s cross-stack-contract clause (one real caller today, mirrors a documented frontend type shape).Resolvers::SocialQueries#shareable_moment

Coupling Heatmap

Summary of cross-context write dependencies (reads are generally acceptable):

Goal Tracking ──────► Gamification    (2 coupling points)
  - AddGoal triggers CreateFirstGoalActionJob
  - AddGoal triggers CreateSecondCategoryActionJob / CreateSecondTypeActionJob
  (CheckInHabit no longer calls GamificationService.award_xp directly as of v1.12
   Phase 53 — it publishes `goal_tracking.habit_checked_in`, consumed by
   Gamification::Subscribers. Now event-driven/loose; moved out of this tight count.)

Social & Community ──► Gamification   (3 coupling points)
  - JoinCommunity triggers JoinFirstCommunityActionJob
  - Social::AcceptPartnerRequest#award_xp_to_both calls GamificationService.award_xp (both users)
  - Social::AcceptAllyInvite#award_xp_to_both calls GamificationService.award_xp (both users)
    (two independently duplicated `award_xp_to_both` definitions, not a shared method —
     tracked as a g27 duplicate-rule finding; fixing the duplication won't remove the
     coupling itself, just its double-implementation)

User Identity ──────► Gamification    (2 coupling points)
  - User#update_streak! calls GamificationService.award_xp
  - UserIdentity::ClerkUserSync calls Gamification::GrantStarterFreeze (rescued —
    failure is logged to Sentry, doesn't fail the sync)

Goal Tracking ──────► Teams (sub-context of Billing)   (2 coupling points)
  - CheckInHabit enqueues Teams::TeamLeaderboardUpsertJob + Teams::CollectiveGoalContributionUpsertJob
  - AddGoalEvent enqueues Teams::TeamLeaderboardUpsertJob + Teams::CollectiveGoalContributionUpsertJob

Total tight coupling points: 9 — 7 flow toward Gamification, 2 flow toward Teams. This is the largest boundary cluster worth decoupling via domain events.

A tenth coupling point used to exist here but was never folded into that count: Critical Path → User Identity (CriticalPath::AwardStimXp wrote directly to stim_* progression fields on User rather than routing through GamificationService). Resolved and migrated per OBJ-2370 (decided 2026-08-09, Option C — see docs/architecture/proposals/critical-path-user-coupling.md): CriticalPath::RecordPlay now publishes critical_path.stim_completed, and Gamification::Subscribers reacts by calling the moved Gamification::AwardStimXp interaction. Now event-driven/loose, same shape as the CheckInHabit exclusion above — it was never counted in the tight total of 9 and still isn't. See 06 — Modularization Guide for how it's staged in the migration order.

Interaction Counts (verified 2026-09-11)

As of this verification pass, rails_api/app/interactions/ contains 204 interaction files across 20 namespaced directories, plus 3 unnamespaced top-level files (dismiss_enneagram_card.rb, submit_enneagram_assessment.rb, dismiss_dashboard_hint.rb) — per find rails_api/app/interactions -type f -name "*.rb" | wc -l and find rails_api/app/interactions -mindepth 1 -maxdepth 1 -type d | wc -l:

NamespaceInteraction Count
admin/9
ai/9 (includes the nested ai/docs_assistant/ directory — Ai::DocsAssistant::AnswerGuideQuestion, v4.55 Phase 3)
ai_workforce/9
billing/4
content_moderation/5
critical_path/6
demo_data/8
feedback/5
gamification/8
gdpr/6
goal_tracking/12
infrastructure/1
integrations/13
onboarding/2
personal_analytics/7
social/42
supporter/1
teams/32
user_identity/22
weekly_digest/3

Background jobs live in rails_api/app/jobs/ across 9 namespaced directories (plus application_job.rb at the root) for a total of 25 job files: ai_coaching/ (1), ai_metrics/ (2), ai_workforce/ (5), billing/ (1), content_moderation/ (1), feedback/ (1), gamification/ (8), social/ (5).

BuildUnifiedFeed: Deep Dive

This interaction deserves special attention as the only multi-context query service in the system.

What it does: Assembles a user's activity feed from four data sources, normalizes them into a common shape, sorts by timestamp, and paginates.

Data sources:

  1. GoalEvent records from accepted allies' public goals (via UserAllyGoalGoalEvent)
  2. CommunityPost records from communities the user belongs to (via CommunityMemberCommunityPost)
  3. UserFeedItem records (badges, encouragements, follows)
  4. User's own public GoalEvent records

All queries use a 14-day lookback window and are capped at 50 (allies/community) or 30 (feed items) records per source.

DDD classification: This is a CQRS read model — a projection that reads from multiple aggregates without writing. In a future architecture, this could become a materialized view updated by event subscribers rather than querying live data on every request.

Performance note: Currently does 4 separate queries, merges in memory, sorts in Ruby. If feed performance becomes an issue, consider:

  1. Pre-computing feed items into a single unified_feed_items table, updated by event subscribers
  2. Using a dedicated feed service (Redis sorted sets, or a purpose-built feed store)

Last updated: 2026-09-13 — registered Social::ShareableSubject and Social::ShareableMomentCopy (OBJ-3684, keystone g4)

Prior update: 2026-09-11

Loading…