Architecture Overview¶
The Tech Strategy Tool is built on an event-sourced architecture with a single API host serving two Blazor WebAssembly frontends. This page describes the high-level system design and the reasoning behind key architectural decisions.
System Architecture¶
graph LR
subgraph Clients
SS[Strategy Site<br/>Blazor WASM<br/>Viewers & Editors]
AS[Admin Site<br/>Blazor WASM<br/>Administrators]
end
subgraph "API Host"
API[ASP.NET Core<br/>Minimal API]
EP[Event Processor<br/>Singleton · In-Memory]
SSE[SSE Connection<br/>Manager · Singleton]
end
subgraph Persistence
PG[(PostgreSQL 17<br/>Events · Checkpoints<br/>Users · Sessions)]
end
SS -->|"HTTP POST (events)"| API
AS -->|"HTTP POST (events)"| API
SS -->|"HTTP GET (reads)"| API
AS -->|"HTTP GET (reads)"| API
API -->|"Serialize writes"| EP
API -->|"Persist"| PG
EP -->|"Notifications"| SSE
SSE -.->|"SSE push"| SS
SSE -.->|"SSE push"| AS
All GET requests are served from the event processor's in-memory state — no database queries for current data. All writes are serialized through the processor via a semaphore lock, then persisted to PostgreSQL. SSE notifications flow back to all connected clients so they can re-fetch affected entities.
Design Principles¶
Event sourcing as a natural fit¶
Event sourcing was chosen because history, restore, and conflict detection are first-class product features — not infrastructure afterthoughts.
-
History is a product feature. The per-entity history view, the re-apply mechanism, and full-strategy restore are core user-facing capabilities. In a CRUD system, these would require a bolted-on audit log, diff tracking, and snapshot/restore logic. With event sourcing, the event log IS the history, previous values are derived by replaying to any point, and restore is "rebuild the document at sequence N and make it current."
-
Conflict detection for collaborative editing. The per-field sequence tracking (
lastSeenSequence) that enables safe concurrent editing is a natural extension of the sequential event model. Each field knows which event last modified it, and stale edits are rejected with the current server value. -
The domain is naturally event-shaped. Strategy changes are discrete, attributable actions: "Alice changed the title," "Bob assigned a principle," "Carol created an initiative." These map directly to events. Cascading operations (like deleting a principle referenced by multiple objectives) are recorded as a single event with the processor handling all side effects — the event captures intent, the processor handles mechanics.
Not about scalability
This is not event sourcing for CQRS read/write separation or horizontal scaling. The single-writer, single-reader, in-memory model is intentionally simple. Event sourcing was chosen for domain fit, not infrastructure reasons.
See Event Sourcing for the full event flow and processing model.
Simplicity at the right scale¶
The system targets approximately 20 concurrent editors and 50 viewers. The architecture embraces this constraint:
- A single in-memory document — the entire strategy is held in memory by the event processor
- A single-writer processor — all events are serialized through a
SemaphoreSlim(1,1)lock, eliminating distributed locking and transaction coordination - Deterministic event ordering — a single monotonic sequence number for all events
- Fast reads — all GET requests served from in-memory state, no database round-trip
The trade-off is that the system runs as a single API server instance. This is a deliberate design choice — the tool is correct and simple at this scale, and complexity would only be added when a concrete need materializes.
Two frontends, one host¶
The Strategy Site and Admin Site are separate Blazor WebAssembly applications with distinct concerns:
| Site | URL | Audience | Purpose |
|---|---|---|---|
| Strategy Site | /strategy |
Viewers, Editors | Day-to-day editing of principles, objectives, initiatives |
| Admin Site | /admin |
Administrators | User management, team setup, event log, restore |
Both are served by the same ASP.NET Core host and share the same API, cookie-based authentication, and SSE infrastructure. Keeping them separate means:
- The Strategy Site is optimized for the editing workflow (low friction, meeting-friendly)
- The Admin Site is a management console with different interaction patterns
- The Strategy Site does not ship admin UI code to non-admin users
- Both apps can evolve independently
They share a common foundation:
graph TD
UI[TechStrat.UI<br/>Shared Components]
Shared[TechStrat.Shared<br/>DTOs & Contracts]
SS[Strategy Site] --> UI
SS --> Shared
AS[Admin Site] --> UI
AS --> Shared
UI --> Shared
Real-time updates via SSE¶
Server-Sent Events push lightweight notifications to all connected clients. When an event is processed, the server broadcasts which entities were affected — entity type and ID only, no payloads. Clients then fetch fresh data for only the changed entities.
This approach:
- Avoids pushing full document state on every change
- Keeps SSE messages minimal (no data synchronization complexity)
- Lets clients decide how to refresh (single card vs. full view reload)
- Uses the browser's native
EventSourceAPI with automatic reconnection
See Real-Time Updates (SSE) for protocol details.
Request Flow¶
A typical user action follows this path:
sequenceDiagram
participant Browser as Blazor Client
participant API as API Host
participant EP as Event Processor
participant DB as PostgreSQL
participant SSE as SSE Manager
Browser->>API: POST /api/events
API->>API: Acquire SemaphoreSlim lock
API->>EP: ProcessEvent(envelope)
EP->>EP: Validate & apply
EP-->>API: ProcessingResult
API->>DB: Append event(s)
API->>SSE: Broadcast notifications
API-->>Browser: SubmitEventResponse
SSE-->>Browser: SSE: card-changed / view-reload
Browser->>API: GET /api/... (refresh data)
API->>EP: Read from in-memory state
EP-->>API: Current data
API-->>Browser: JSON response
- The Blazor client submits an event via
POST /api/events - The API acquires the single-writer lock
- The event processor validates and applies the change in memory
- The resulting event(s) are persisted to PostgreSQL
- SSE notifications are broadcast to all connected clients (including the submitter)
- The submitting client receives the processing result
- All clients receive SSE notifications and re-fetch the affected entity from the API
- The API serves GET requests from the processor's in-memory state (no database query)
Cold Start and Recovery¶
On startup, the server reconstructs its in-memory state before accepting any requests:
sequenceDiagram
participant Init as ProcessorInitializer
participant DB as PostgreSQL
participant EP as Event Processor
Init->>DB: Load latest checkpoint
DB-->>Init: Document snapshot + sequence number
Init->>EP: LoadFromCheckpoint(document, seq)
Init->>DB: Get events after checkpoint
DB-->>Init: Events to replay
loop Each event
Init->>EP: ReplayEvent(event)
end
Init->>DB: Save new checkpoint
Note over Init: Ready to accept requests
- Load the latest checkpoint from the database (a serialized snapshot of the strategy document)
- Replay all events after the checkpoint's sequence number
- Save a new checkpoint (ensures old non-decomposed events are never replayed again)
- Begin accepting requests
This process is handled by ProcessorInitializer, an IHostedService that runs before the app starts serving traffic.
Key Files¶
| File | Purpose |
|---|---|
src/TechStrat.Core/Processing/EventProcessor.cs |
Core event processing loop |
src/TechStrat.Api/Endpoints/EventEndpoints.cs |
Event submission endpoint with single-writer lock |
src/TechStrat.Api/Sse/SseConnectionManager.cs |
SSE connection tracking and broadcast |
src/TechStrat.Infrastructure/Persistence/EventStore.cs |
Event persistence |
src/TechStrat.Api/Auth/SessionAuthenticationHandler.cs |
Cookie-based session auth |
Further Reading¶
- Event Sourcing — Event flow, checkpointing, replay, and decomposition
- Real-Time Updates (SSE) — SSE protocol, heartbeat, and reconnection
- Authentication & Security — Session management, CSRF, rate limiting
- Data Model — Entity hierarchy and relationships