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.
Architecture
Section titled “Architecture”The EventProcessor is a sealed partial class split across 6 files for organization:
| File | Responsibility |
|---|---|
EventProcessor.cs | Core loop, dispatch table, shared helpers |
EventProcessor.TeamHandlers.cs | update_team_color + hex color validation |
EventProcessor.ObjectiveHandlers.cs | 4 objective relationship events + group slice helpers |
EventProcessor.InitiativeHandlers.cs | 3 initiative data events |
EventProcessor.GenericHandlers.cs | create_entity, delete_entity, update_name, update_description, reorder_entity — polymorphic handlers for all entity types |
EventProcessor.SystemHandlers.cs | restore_history, import_team |
All files are in src/TechStrat.Core/Processing/.
Public API
Section titled “Public API”// Process a new eventProcessingResult ProcessEvent(EventEnvelope envelope)
// Restore from a checkpoint during cold startvoid LoadFromCheckpoint(Strategy document, long sequenceNumber)
// Replay a single stored event (skips rejected events)void ReplayEvent(StoredEvent storedEvent)
// Replace the document with a historical statevoid RestoreToSequence(Strategy document)
// Reset the checkpoint counter after a checkpoint is savedvoid ResetCheckpointCounter()
// Build a document at any historical sequence number (static)static Strategy BuildDocumentAtSequence( Strategy checkpoint, long checkpointSeq, IEnumerable<StoredEvent> events, long targetSeq)Processing Flow
Section titled “Processing Flow”Step by step
Section titled “Step by step”- Dispatch: The core loop maps the
eventTypestring to a handler method - Validate: The handler checks entity existence, field validity, required data
- No-op check: For update events, compares new value against current value (exact match). Identical values return
NoChange - Apply: The handler mutates the in-memory document
- Decompose: If the handler returns
DerivedCommands, each derived command is processed recursively - Result: The processor builds a
ProcessingResultwith sequence number, status, notifications, and emitted events
DispatchResult
Section titled “DispatchResult”All event handlers return a DispatchResult, an internal type that carries:
| Field | Description |
|---|---|
Status | Applied, Rejected, or NoChange |
RejectionReason | Why the event was rejected (if applicable) |
PreviousValue | The field’s value before the change — only set on applied events (for history) |
ConflictingServerValue | The current server value — only set on conflict rejections (the value the client is stale against) |
Notifications | List of SSE notifications to broadcast |
Associations | List of HistoryAssociation records mapping this event to affected entities |
DerivedCommands | Additional events to process (for decomposition) |
EmitWithDerived | When true, the parent event is persisted alongside its derived commands |
Helper methods
Section titled “Helper methods”Handlers use convenience methods to create DispatchResult:
Applied(notifications, previousValue)— successful mutation (setsPreviousValue)Applied(notifications, associations, previousValue)— successful mutation with history associations (setsPreviousValue)Direct(entityId, eventSequence, previousSequence)— creates a directHistoryAssociationTransitive(entityId, eventSequence)— creates a transitiveHistoryAssociationRejected(reason)— validation failureRejectedConflict(reason, conflictingServerValue)— conflict detected (setsConflictingServerValue)NoChange()— update with same value
HistoryAssociation
Section titled “HistoryAssociation”Each handler emits HistoryAssociation records to map events to affected entities:
| Field | Type | Description |
|---|---|---|
EntityId | Guid | The entity whose history this entry belongs to |
EventSequence | long | The event’s sequence number |
PreviousSequence | long? | For field edits: the sequence of the previous event that modified the same field |
IsTransitive | bool | false for direct actions, true for cascaded effects |
Event Handlers
Section titled “Event Handlers”Event types by category
Section titled “Event types by category”| Category | Events |
|---|---|
| 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).
Defensive parsing
Section titled “Defensive parsing”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.
Notifications
Section titled “Notifications”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.
Event Decomposition
Section titled “Event Decomposition”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.
Checkpointing
Section titled “Checkpointing”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:
- Serializing the current strategy document
- Persisting the checkpoint to the database
- Calling
ResetCheckpointCounter()to reset the counter
Replay vs. Process
Section titled “Replay vs. Process”| Aspect | ProcessEvent | ReplayEvent |
|---|---|---|
| Purpose | Process new events | Reconstruct state from stored events |
| Validation | Full validation | Skips rejected events |
| Sequence numbers | Assigns new sequence number | Uses stored sequence number |
| Notifications | Generates SSE notifications | No notifications |
| Used by | API event submission | Cold start, BuildDocumentAtSequence |
Key Design Decisions
Section titled “Key Design Decisions”Single-writer model
Section titled “Single-writer model”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.
In-memory state
Section titled “In-memory state”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.
Pure domain logic
Section titled “Pure domain logic”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.