Event Sourcing Architecture
ID PASS DataCollect implements a comprehensive event sourcing architecture that provides complete audit trails, time-travel debugging, and cryptographic integrity verification. This document explains the design principles, implementation details, and practical usage.
Overview
Event sourcing is an architectural pattern where all changes to application state are stored as a sequence of events. Instead of storing just the current state, we store a full history of actions that led to that state.
Core Concepts
Events as First-Class Citizens
In ID PASS DataCollect, every change is represented as an immutable event:
interface FormSubmission {
guid: string; // Unique event identifier
entityGuid: string; // Entity this event affects
type: string; // Event type (create-individual, update-group, etc.)
data: any; // Event payload
timestamp: string; // When the event occurred
userId: string; // Who created the event
syncLevel: SyncLevel; // Synchronization status
}
Event Types
The system supports various event types for different operations:
- Entity Creation:
create-individual,create-group - Entity Updates:
update-individual,update-group - Group Management:
add-member,remove-member - Entity Deletion:
delete-entity - Custom Events: Extensible for domain-specific operations
Event Store
The EventStore manages immutable event storage with cryptographic integrity:
class EventStoreImpl implements EventStore {
private merkleRoot: MerkleNode | null = null;
private storageAdapter: EventStorageAdapter;
async saveEvent(form: FormSubmission): Promise<string> {
// Store event immutably
const guids = await this.storageAdapter.saveEvents([form]);
// Update Merkle tree for integrity
await this.loadMerkleTree();
return guids[0];
}
}
Implementation Details
Event Application
Events are applied to entities through the EventApplierService:
class EventApplierService {
async submitForm(formData: FormSubmission): Promise<EntityDoc | null> {
// 1. Save event to event store
const eventId = await this.eventStore.saveEvent(formData);
// 2. Apply event to create/update entity
const entity = await this.applyEvent(formData);
// 3. Save updated entity state
if (entity) {
await this.entityStore.saveEntity(entity.initial, entity.modified);
}
return entity;
}
}
Custom Event Appliers
You can register custom event appliers for domain-specific logic:
// Define custom event applier
const customApplier: EventApplier = {
eventType: 'assign-case-worker',
apply: async (entity: EntityDoc, event: FormSubmission): Promise<EntityDoc> => {
return {
...entity,
data: {
...entity.data,
caseWorker: event.data.caseWorkerId,
assignmentDate: event.timestamp
},
version: entity.version + 1,
lastUpdated: event.timestamp
};
}
};
// Register the applier
eventApplierService.registerEventApplier(customApplier);
Merkle Tree Integrity
Every event is included in a Merkle tree for tamper-evident storage:
class MerkleNode {
left: MerkleNode | null = null;
right: MerkleNode | null = null;
hash: string;
constructor(data: string) {
this.hash = this.calculateHash(data);
}
private calculateHash(data: string): string {
return CryptoJS.SHA256(data).toString(CryptoJS.enc.Hex);
}
}
Event Sourcing Benefits
1. Complete Audit Trail
Every change is recorded with full context:
const auditTrail = await eventStore.getAuditTrailByEntityGuid('person-123');
auditTrail.forEach(entry => {
console.log(`${entry.timestamp}: ${entry.action} by ${entry.userId}`);
console.log('Changes:', entry.changes);
});
2. Time Travel Debugging
Reconstruct entity state at any point in time:
async function getEntityAtTime(entityId: string, timestamp: string): Promise<EntityDoc> {
// Get all events up to the specified time
const events = await eventStore.getEvents();
const relevantEvents = events.filter(e =>
e.entityGuid === entityId &&
e.timestamp <= timestamp
);
// Replay events to reconstruct state
let entity = null;
for (const event of relevantEvents) {
entity = await eventApplier.applyEvent(entity, event);
}
return entity;
}
3. Event Replay
Rebuild entire system state from events:
async function rebuildFromEvents(): Promise<void> {
// Clear current state
await entityStore.clearStore();
// Get all events
const events = await eventStore.getAllEvents();
// Replay in order
for (const event of events) {
await eventApplierService.submitForm(event);
}
}
4. Cryptographic Verification
Verify data integrity using Merkle proofs:
// Get proof for an event
const proof = await eventStore.getProof(suspiciousEvent);
// Verify the event hasn't been tampered with
const isValid = eventStore.verifyEvent(suspiciousEvent, proof);
if (!isValid) {
throw new Error('Data integrity compromised!');
}
Event Design Patterns
1. Command-Event Separation
Commands represent intentions, events represent facts:
// Command (intent)
interface CreateIndividualCommand {
name: string;
age: number;
address: Address;
}
// Event (fact)
interface IndividualCreatedEvent extends FormSubmission {
type: 'create-individual';
data: {
name: string;
age: number;
address: Address;
createdAt: string;
};
}
2. Event Enrichment
Add metadata during event creation:
function enrichEvent(event: FormSubmission): FormSubmission {
return {
...event,
metadata: {
deviceId: getDeviceId(),
location: getCurrentLocation(),
appVersion: getAppVersion(),
networkStatus: getNetworkStatus()
}
};
}
3. Event Versioning
Handle schema evolution:
interface VersionedEvent extends FormSubmission {
version: number;
migratedFrom?: number;
}
function migrateEvent(event: VersionedEvent): VersionedEvent {
switch (event.version) {
case 1:
// Migrate v1 to v2
return {
...event,
version: 2,
data: migrateV1ToV2(event.data)
};
default:
return event;
}
}
Performance Considerations
Event Storage Optimization
-
Indexing Strategy
// IndexedDB indexes for fast queries
db.createIndex('timestamp', 'timestamp');
db.createIndex('entityGuid', 'entityGuid');
db.createIndex('type', 'type'); -
Pagination for Large Event Sets
const result = await eventStore.getEventsSincePagination(
lastSyncTime,
100 // Page size
); -
Batch Event Processing
const events = collectEvents(); // Collect multiple events
await eventStore.saveEvents(events); // Save in batch
Memory Management
-
Streaming Event Processing
async function* streamEvents() {
let cursor = null;
do {
const batch = await eventStore.getEventsBatch(cursor, 1000);
yield* batch.events;
cursor = batch.nextCursor;
} while (cursor);
} -
Event Compaction
// Periodically compact events into snapshots
async function createSnapshot(entityId: string): Promise<void> {
const entity = await entityStore.getEntity(entityId);
await snapshotStore.saveSnapshot({
entityId,
state: entity.modified,
eventCount: entity.version,
timestamp: new Date().toISOString()
});
}
Security Considerations
1. Event Immutability
Events are write-once, read-many:
class ImmutableEventStore {
async saveEvent(event: FormSubmission): Promise<void> {
// Check if event already exists
if (await this.isEventExisted(event.guid)) {
throw new Error('Event already exists - cannot modify');
}
// Save with integrity hash
await this.storage.save({
...event,
hash: this.calculateHash(event)
});
}
}
2. Access Control
Implement role-based event access:
function canAccessEvent(user: User, event: FormSubmission): boolean {
// Admin can see all events
if (user.role === 'admin') return true;
// Users can only see their own events
if (user.id === event.userId) return true;
// Check entity-level permissions
return hasEntityPermission(user, event.entityGuid);
}
3. Event Encryption
Encrypt sensitive event data:
class EncryptedEventStore {
async saveEvent(event: FormSubmission): Promise<void> {
const encrypted = {
...event,
data: await this.encryptionAdapter.encrypt(
JSON.stringify(event.data)
)
};
await this.storage.save(encrypted);
}
}
Best Practices
1. Event Naming
Use clear, descriptive event names:
// Good
'beneficiary-registered'
'household-income-updated'
'emergency-assistance-provided'
// Avoid
'update'
'change'
'event-1'
2. Event Granularity
Balance between too many small events and large monolithic events:
// Too granular
{ type: 'name-changed', data: { name: 'John' } }
{ type: 'age-changed', data: { age: 30 } }
// Too coarse
{ type: 'entity-updated', data: { ...entireEntity } }
// Just right
{ type: 'personal-info-updated', data: { name: 'John', age: 30 } }
3. Event Context
Include sufficient context for event replay:
interface ContextualEvent extends FormSubmission {
context: {
reason: string;
source: 'mobile-app' | 'web-portal' | 'import';
sessionId: string;
correlationId: string;
};
}
Testing Event Sourcing
Unit Testing Events
describe('Event Application', () => {
it('should apply create-individual event', async () => {
const event: FormSubmission = {
guid: 'event-1',
entityGuid: 'person-1',
type: 'create-individual',
data: { name: 'John Doe', age: 30 },
timestamp: new Date().toISOString(),
userId: 'test-user',
syncLevel: SyncLevel.LOCAL
};
const result = await eventApplier.apply(null, event);
expect(result).toMatchObject({
id: 'person-1',
type: 'individual',
data: { name: 'John Doe', age: 30 }
});
});
});
Integration Testing
describe('Event Sourcing Integration', () => {
it('should maintain consistency through event replay', async () => {
// Create initial state
await submitEvents(testEvents);
const originalState = await getAllEntities();
// Clear and replay
await clearAllStores();
await replayEvents(testEvents);
const replayedState = await getAllEntities();
// States should match
expect(replayedState).toEqual(originalState);
});
});
Monitoring and Debugging
Event Metrics
Track key metrics for system health:
interface EventMetrics {
totalEvents: number;
eventsPerSecond: number;
averageEventSize: number;
eventTypeDistribution: Record<string, number>;
failedEvents: number;
merkleTreeDepth: number;
}
Debug Utilities
// Event inspector
async function inspectEvent(eventId: string): Promise<void> {
const event = await eventStore.getEvent(eventId);
console.log('Event:', event);
const proof = await eventStore.getProof(event);
console.log('Merkle Proof:', proof);
const isValid = eventStore.verifyEvent(event, proof);
console.log('Integrity Valid:', isValid);
}
// Event timeline
async function printEventTimeline(entityId: string): Promise<void> {
const events = await eventStore.getEventsByEntity(entityId);
events.forEach((event, index) => {
console.log(`${index + 1}. ${event.timestamp} - ${event.type}`);
console.log(` User: ${event.userId}`);
console.log(` Data: ${JSON.stringify(event.data, null, 2)}`);
});
}
Conclusion
Event sourcing in ID PASS DataCollect provides a robust foundation for:
- Reliability: Complete audit trails and data integrity
- Flexibility: Easy to add new event types and behaviors
- Debugging: Time-travel and event replay capabilities
- Security: Cryptographic verification and immutability
- Compliance: Full history for regulatory requirements
By following these patterns and best practices, you can build reliable, auditable systems that maintain complete history while supporting complex business requirements.