Add a new projection for handling the creation of bands with support for upserts in the database. Update the README to document the DanceFinder domain package's architecture and naming conventions, aligning with Domain-Driven Design and Event Sourcing principles. New dependencies are included for database testing and event sourcing functionalities. refactor(schema): rename UUID columns to standard IDs Simplify database schema by removing dual identity system and standardizing column names. All tables now use a single UUID-type `id` column, with foreign keys renamed to `band_id` and `dance_hall_id`, enhancing clarity and consistency in identifying records. Update all related code to reflect these changes, improving developer experience and maintaining data integrity throughout the migration. feat: add truncate functionality for projection tables Add functions to truncate the band, dance_hall, event, and user preference tables to enable rebuilding projections from events. Eliminate foreign key constraints between read view tables to ensure referential integrity is maintained at the write side and allow independent resets of read models. Include documentation explaining the architectural decision against foreign key usage within the system. feat(migrations): rename UUID columns to id and simplify schema Update database schema by renaming UUID columns to 'id' across event, band, dance_hall, ignored_band, and ignored_dance_hall tables. Drop old numeric IDs and foreign key constraints to streamline the schema, ensuring UUIDs serve as primary identifiers. Recreate necessary foreign key constraints with the updated column names to maintain referential integrity. feat(database): replace integer IDs with UUIDs for events Update event and associated entities to use UUIDs instead of integer IDs. Modify SQL queries and data structures to ensure band and dance hall identifiers are UUIDs, enhancing the system's scalability and integration capabilities.
12 KiB
Projections
This document describes the projection system used to maintain read models from domain events.
Overview
The DanceFinder application uses CQRS (Command Query Responsibility Segregation) with event sourcing. Domain events are stored in the event store, and projections update read model tables that are optimized for queries.
Architecture
Domain Events → Event Store → Projection Handlers → Read Model Tables
↓
AMQP/RabbitMQ
- Commands update aggregates and produce domain events
- Events are stored in the event store
- Events are published to AMQP
- Projection handlers consume events and update read model tables
- GraphQL queries read from the optimized read model tables
Projections
Band Projection
Projection Name: band
Handles Events:
BandCreated- Creates a new band in the read model
Updates Table: band
Reset Handler: Deletes all bands from the read model
DanceHall Projection
Projection Name: dancehall
Handles Events:
DanceHallCreated- Creates a new dance hall in the read modelDanceHallGeocoded- Updates dance hall coordinates
Updates Table: dance_hall
Reset Handler: Deletes all dance halls from the read model
Event Projection
Projection Name: event
Handles Events:
EventCreated- Creates a new event in the read modelEventUpdated- Updates event detailsEventDeleted- Removes an event from the read model
Updates Table: event
Reset Handler: Deletes all events from the read model
UserPreferences Projection
Projection Name: userpreferences
Handles Events:
OriginAdded/OriginRemoved- Manage user's home locationsBandIgnored/BandUnignored- Manage ignored bandsDanceHallIgnored/DanceHallUnignored- Manage ignored venuesCityIgnored/CityUnignored- Manage ignored citiesMunicipalityIgnored/MunicipalityUnignored- Manage ignored municipalitiesStateIgnored/StateUnignored- Manage ignored states
Updates Tables:
originignored_bandignored_dance_hallignored_cityignored_municipalityignored_state
Reset Handler: Deletes all user preference data from all related tables
Projection State Management
Each projection maintains its position in the event stream using the readviews table:
CREATE TABLE readviews (
name TEXT PRIMARY KEY,
position BIGINT NOT NULL
);
The position tracks the last processed event timestamp, ensuring projections:
- Process events exactly once
- Can resume from their last position after restart
- Don't miss events
Rebuilding Projections
Projections can be rebuilt from scratch using the reset functionality. This is useful when:
- Schema changes require rebuilding the read model
- Data corruption in read models needs to be fixed
- Projection logic changes and historical data needs reprocessing
Using Reset Handlers
Reset handlers are automatically called by the readview framework when a projection needs to be rebuilt.
Manual Reset (requires application code):
// Reset a specific projection
err := projectionManager.ResetProjection(ctx, "band")
// This will:
// 1. Call resetBand() which truncates the band table (with CASCADE)
// 2. Reset the projection position to 0
// 3. Replay all BandCreated events from the event store
// 4. Update the band table with current data
What happens during reset:
-
Reset handler executes in a transaction:
- Truncates projection tables (fast and efficient)
- For
band:TRUNCATE TABLE band CASCADE - For
dancehall:TRUNCATE TABLE dance_hall CASCADE - For
event:TRUNCATE TABLE event - For
userpreferences: Truncates all 6 related tables
-
Position is reset to 0
-
Events are replayed:
- All relevant events are read from the event store
- Events are processed in order
- Read model is rebuilt from scratch
-
Projection catches up to current state
TRUNCATE vs DELETE
The reset handlers use TRUNCATE TABLE CASCADE instead of DELETE for better performance:
Advantages:
- Much faster for clearing entire tables
- Reclaims disk space immediately
- Less transaction log overhead
- More efficient for large tables
CASCADE Usage:
- Handles any remaining foreign key dependencies
- Required even though we remove FK constraints in migrations
- Some constraint names may be auto-generated and differ from expected names
- Ensures reset succeeds even if FK constraints remain
No Foreign Key Constraints
Important: Read view tables have no foreign key constraints between them.
Rationale:
- Event store is the single source of truth
- Read models are just projections that can be rebuilt
- Foreign keys add unnecessary coupling between projections
- Simplifies independent projection resets
- No CASCADE side effects
- Better flexibility for projection evolution
What this means:
- Each projection can be reset independently without affecting others
- No need for CASCADE on TRUNCATE operations
- Reset order doesn't matter (though you may want to reset in a specific order for logical reasons)
- Referential integrity is maintained at the write side (domain aggregates), not read side
Reset Safety
Reset operations are:
- Transactional: If reset fails, no data is lost
- Coordinated: The readview framework manages the process
- Automatic: Once initiated, the framework handles replay
- Independent: No foreign key constraints means no side effects on other projections
- Safe: Event store is the source of truth, read models can always be rebuilt
Reset Scenarios
Scenario 1: Reset Single Projection
// Reset any single projection independently
// No side effects, no CASCADE, completely isolated
manager.ResetProjection(ctx, "band")
// Only bands are cleared and rebuilt
// Events, dance halls, and user prefs are unaffected
Scenario 2: Reset Multiple Related Projections
// If you have dangling references (e.g., events referencing non-existent bands),
// reset the dependent projections too
manager.ResetProjection(ctx, "band") // Reset bands
manager.ResetProjection(ctx, "event") // Reset events (they reference bands)
// This ensures referential consistency in your read models
Scenario 3: Reset All Projections
// Complete rebuild of all read models
// Order doesn't matter, but logical order is clearer:
manager.ResetProjection(ctx, "band")
manager.ResetProjection(ctx, "dancehall")
manager.ResetProjection(ctx, "event")
manager.ResetProjection(ctx, "userpreferences")
// All projections are rebuilt from their respective events
Scenario 4: Selective Reset for Debugging
// Reset only what you need to test/fix
manager.ResetProjection(ctx, "event")
// Test event projection logic without affecting other projections
Event Handler Patterns
Insert with Conflict Resolution
Most handlers use ON CONFLICT to handle idempotency:
query := `
INSERT INTO band (id, name, created)
VALUES ($1, $2, $3)
ON CONFLICT (name) DO UPDATE
SET id = EXCLUDED.id,
created = EXCLUDED.created
`
This ensures:
- Events can be replayed safely
- Duplicate event processing doesn't cause errors
- Latest event data wins in case of conflicts
Update by ID
Update handlers use the aggregate ID to find records:
query := `
UPDATE dance_hall
SET latitude = $1, longitude = $2
WHERE id = $3
`
Delete Operations
Delete handlers remove specific records:
query := `DELETE FROM event WHERE id = $1`
Foreign Key Lookups
Some handlers need to look up related entities:
// Find band ID by name
var bandID string
err := tx.QueryRowContext(ctx, `SELECT id FROM band WHERE name = $1`, event.BandName).Scan(&bandID)
// Use ID in ignored_band table
query := `INSERT INTO ignored_band (band_id, subject, created) VALUES ($1, $2, $3)`
Monitoring Projections
Check Projection Status
Query the readviews table to see projection positions:
SELECT name, position,
(SELECT MAX(tstamp) FROM events) as latest_event
FROM readviews;
Check Lag
Compare projection position with latest event:
SELECT
rv.name,
rv.position as projection_position,
MAX(e.tstamp) as latest_event_time,
MAX(e.tstamp) - rv.position as lag_seconds
FROM readviews rv
CROSS JOIN events e
GROUP BY rv.name, rv.position;
Verify Data Consistency
Count records in both event store and read models:
-- Count events in event store by type
SELECT name, COUNT(*)
FROM events
WHERE aggregate_name = 'domain.Band'
GROUP BY name;
-- Count bands in read model
SELECT COUNT(*) FROM band;
Error Handling
Projection errors are logged and the projection manager will:
- Log the error with event details
- Stop processing for that projection
- Preserve the position (won't advance past the error)
- Allow other projections to continue
To recover from projection errors:
- Fix the underlying issue (schema, logic, etc.)
- Restart the service
- The projection will resume from its last position
- If the error persists, reset the projection
Performance Considerations
Indexes
All read model tables have appropriate indexes:
- Primary keys on
idcolumns (UUID) - Foreign key indexes
- Unique constraints where needed
- Additional indexes for common queries
Batch Processing
Projections process events one at a time in transactions:
- Ensures consistency
- Prevents partial updates
- Allows rollback on errors
Concurrent Processing
Different projections run independently:
band,dancehall,event, anduserpreferencesprojections can process concurrently- Each maintains its own position
- No coordination required between projections
Testing Projections
Unit Testing Event Handlers
func TestBandCreatedHandler(t *testing.T) {
// Create test database and transaction
db := setupTestDB(t)
tx, _ := db.Begin()
defer tx.Rollback()
// Create test event
event := &domain.BandCreated{
BaseEvent: eventsourced.BaseEvent{
EventAggregateId: "test-uuid",
EventTime: time.Now(),
},
Name: "Test Band",
}
// Execute handler
err := handleBandCreated(context.Background(), tx, event)
require.NoError(t, err)
// Verify result
var count int
tx.QueryRow("SELECT COUNT(*) FROM band WHERE name = $1", "Test Band").Scan(&count)
assert.Equal(t, 1, count)
}
Integration Testing
func TestProjectionReplay(t *testing.T) {
// Store events in event store
// Reset projection
// Verify read model matches expected state
}
Best Practices
- Idempotent Handlers: Always use
ON CONFLICTor check for existence - Transactional Updates: All projection updates in a single transaction
- Error Handling: Return meaningful errors for debugging
- Schema Alignment: Keep projection tables aligned with domain model
- Foreign Keys: Use proper foreign key constraints for data integrity
- Reset Testing: Test reset handlers regularly
- Monitor Lag: Watch for projections falling behind
- Index Optimization: Add indexes for common query patterns
Troubleshooting
Projection is Stuck
Symptoms: Position not advancing, data not updating
Solutions:
- Check logs for errors
- Verify event store contains events
- Check for database locks
- Restart the service
Projection is Behind
Symptoms: High lag, old data in read models
Solutions:
- Check for slow queries in handlers
- Add missing indexes
- Optimize handler logic
- Consider increasing resources
Data Inconsistency
Symptoms: Read model doesn't match event store
Solutions:
- Reset the affected projection
- Verify handler logic
- Check for missing event handlers
- Review ON CONFLICT logic
Foreign Key Errors
Symptoms: Handler fails with foreign key violation
Solutions:
- Ensure dependent records exist
- Check projection order (bands/halls before events)
- Verify reset order
- Add missing event handlers