Skip to content

Event Processing

The EventProcessor is the central component of the Tech Strategy Tool’s domain logic. It maintains the in-memory strategy document and processes all events that modify it. This page explains the processor’s design, handler dispatch, and key patterns.

The EventProcessor is a sealed partial class split across 6 files for organization:

FileResponsibility
EventProcessor.csCore loop, dispatch table, shared helpers
EventProcessor.TeamHandlers.csupdate_team_color + hex color validation
EventProcessor.ObjectiveHandlers.cs4 objective relationship events + group slice helpers
EventProcessor.InitiativeHandlers.cs3 initiative data events
EventProcessor.GenericHandlers.cscreate_entity, delete_entity, update_name, update_description, reorder_entity — polymorphic handlers for all entity types
EventProcessor.SystemHandlers.csrestore_history, import_team

All files are in src/TechStrat.Core/Processing/.

// Process a new event
ProcessingResult ProcessEvent(EventEnvelope envelope)
// Restore from a checkpoint during cold start
void LoadFromCheckpoint(Strategy document, long sequenceNumber)
// Replay a single stored event (skips rejected events)
void ReplayEvent(StoredEvent storedEvent)
// Replace the document with a historical state
void RestoreToSequence(Strategy document)
// Reset the checkpoint counter after a checkpoint is saved
void ResetCheckpointCounter()
// Build a document at any historical sequence number (static)
static Strategy BuildDocumentAtSequence(
Strategy checkpoint, long checkpointSeq,
IEnumerable<StoredEvent> events, long targetSeq)
Diagram
  1. Dispatch: The core loop maps the eventType string to a handler method
  2. Validate: The handler checks entity existence, field validity, required data
  3. No-op check: For update events, compares new value against current value (exact match). Identical values return NoChange
  4. Apply: The handler mutates the in-memory document
  5. Decompose: If the handler returns DerivedCommands, each derived command is processed recursively
  6. Result: The processor builds a ProcessingResult with sequence number, status, notifications, and emitted events

All event handlers return a DispatchResult, an internal type that carries:

FieldDescription
StatusApplied, Rejected, or NoChange
RejectionReasonWhy the event was rejected (if applicable)
PreviousValueThe field’s value before the change — only set on applied events (for history)
ConflictingServerValueThe current server value — only set on conflict rejections (the value the client is stale against)
NotificationsList of SSE notifications to broadcast
AssociationsList of HistoryAssociation records mapping this event to affected entities
DerivedCommandsAdditional events to process (for decomposition)
EmitWithDerivedWhen true, the parent event is persisted alongside its derived commands

Handlers use convenience methods to create DispatchResult:

  • Applied(notifications, previousValue) — successful mutation (sets PreviousValue)
  • Applied(notifications, associations, previousValue) — successful mutation with history associations (sets PreviousValue)
  • Direct(entityId, eventSequence, previousSequence) — creates a direct HistoryAssociation
  • Transitive(entityId, eventSequence) — creates a transitive HistoryAssociation
  • Rejected(reason) — validation failure
  • RejectedConflict(reason, conflictingServerValue) — conflict detected (sets ConflictingServerValue)
  • NoChange() — update with same value

Each handler emits HistoryAssociation records to map events to affected entities:

FieldTypeDescription
EntityIdGuidThe entity whose history this entry belongs to
EventSequencelongThe event’s sequence number
PreviousSequencelong?For field edits: the sequence of the previous event that modified the same field
IsTransitiveboolfalse for direct actions, true for cascaded effects
CategoryEvents
Generic (5)create_entity, delete_entity, update_name, update_description, reorder_entity
Team (1)update_team_color
Objective (4)assign_objective_to_group, remove_objective_from_group, assign_principle_to_objective, remove_principle_from_objective
Initiative (3)update_initiative_progress, set_initiative_jira_key, remove_initiative_jira_key
System (2)restore_history, import_team

15 event types total. Entity creation, deletion, name/description updates, and reordering are handled polymorphically by the generic handlers. Entity-specific handlers cover operations that cannot be generalized (color, relationships, progress).

All handlers use TryGetValue and TryParse for data dictionary access. Create handlers validate required fields via ValidateRequired (rejects empty/whitespace values and enforces max length) and optional fields via ValidateOptional (enforces max length when present). The processor never throws exceptions on invalid input — malformed data results in rejection, not a crash. This is critical for safe cold start replay of potentially old or malformed persisted events.

Each handler emits appropriate notifications:

  • card-changed — data updates (title, description, progress changes)
  • view-reload — structural changes (create, delete, reorder, group assignment)

Cascade notifications are emitted when a change affects multiple entities. For example, deleting a principle emits card-changed for the principle and for every objective that referenced it.

Some events trigger multiple state changes. Two key examples:

Cross-team principle assignment (assign_principle_to_objective when principle is in a different team):

Entity creation (create_entity with optional fields):

The create_entity handler always decomposes into at minimum create_entity + update_name. Optional fields add further derived events: update_description, update_team_color (for teams), or assign_objective_to_group (for objectives with a groupId). All are stored as separate events, each with their own sequence number.

Each derived command is processed as a full event with its own sequence number, status, and notifications. All emitted events are collected in the ProcessingResult.EmittedEvents list.

The processor tracks applied events since the last checkpoint reset. When the count exceeds a configurable threshold (default: 100), ShouldCheckpoint returns true.

The API layer is responsible for:

  1. Serializing the current strategy document
  2. Persisting the checkpoint to the database
  3. Calling ResetCheckpointCounter() to reset the counter
AspectProcessEventReplayEvent
PurposeProcess new eventsReconstruct state from stored events
ValidationFull validationSkips rejected events
Sequence numbersAssigns new sequence numberUses stored sequence number
NotificationsGenerates SSE notificationsNo notifications
Used byAPI event submissionCold start, BuildDocumentAtSequence

The processor is not thread-safe. Concurrent access is prevented by the SemaphoreSlim(1,1) lock in the API layer. This is a deliberate choice — the simplicity of a single-writer model outweighs the complexity of concurrent processing for this use case.

The entire strategy document is held in memory. This makes reads extremely fast (no database query) and processing simple (direct mutation). The trade-off is that state must be reconstructed on startup via checkpoint + replay.

The EventProcessor lives in TechStrat.Core and has zero external dependencies. It knows nothing about HTTP, databases, or SSE. This makes it straightforward to test — all 268 unit tests run without any infrastructure.