Skip to content

CQRS Projections

Architecture Overview

FundlyHub uses CQRS to separate Write operations (Commands) from Read operations (Queries).

  • Commands (Writes) go to the Primary Database (Users, Campaigns, Donations tables).
  • Events are published to the Events Database.
  • Projections (Reads) are built asynchronously in the Analytics/Read Database from these events.

Why Projections?

Projections allow us to serve complex data requirements without complex, slow JOINs on normalized tables. The Read Database contains tables specifically designed for UI views (e.g., "Campaign Card", "Dashboard Stats").

Key Projection Tables

campaign_summary_view

A flat, denormalized view optimized for the "Browse Campaigns" page.

Contains:

  • Campaign basic info (title, slug, image)
  • Owner details (name, avatar) - Pre-joined
  • Category info (name, emoji) - Pre-joined
  • Live stats (total raised, donor count) - Pre-calculated

user_dashboard_stats

Aggregated stats for the user dashboard, updated in near real-time.

Contains:

  • Total campaigns created
  • Total funds raised across all campaigns
  • Total unique donors
  • Recent activity summary

Querying Projections via API

Frontend clients view projection data by calling standard GET endpoints. The API handles routing the query to the Read Database.

javascript
// Frontend: Get Campaign Summary (uses projection)
const getCampaigns = async () => {
  // Calls GET /api/v1/fundraisers (Read Model)
  const response = await fetch('https://api.fundlyhub.org/api/v1/fundraisers?status=active');
  const campaigns = await response.json();
  
  // Data is already "hydrated" with category/owner details
  // No need for client-side joins or cascading fetches
  campaigns.forEach(c => {
    console.log(`${c.title} by ${c.owner_name} (${c.category_name})`);
  });
};

Eventual Consistency

Because projections are updated by asynchronous event processors, there may be a slight delay (typically milliseconds to seconds) between a write action (e.g., making a donation) and the read model updating (e.g., "Total Raised" increasing).

The frontend handles this optimistically where appropriate, or simply relies on the fast update cycle.

Built with VitePress