22 KiB
Architecture Research
Domain: Blazor WebAssembly AI Chat Application Researched: 2026-03-27 Confidence: HIGH (Microsoft official docs + verified community patterns)
Standard Architecture
System Overview
┌──────────────────────────────────────────────────────────────────────┐
│ BROWSER (Blazor WASM) │
├──────────────────────────────────────────────────────────────────────┤
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────────┐ │
│ │ ChatPage │ │ ConvList │ │ MessageBubble │ │
│ │ (container) │ │ (sidebar) │ │ (leaf component) │ │
│ └───────┬────────┘ └───────┬────────┘ └────────────────────────┘ │
│ │ │ │
│ ┌───────▼──────────────────▼─────────────────────────────────────┐ │
│ │ ConversationStateService (Singleton DI) │ │
│ │ Holds: active conversation, message list, loading flag │ │
│ └───────┬────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────▼────────────────────────────────────────────────────────┐ │
│ │ ChatApiClient (HttpClient wrapper) │ │
│ │ POST /api/conversations, GET /api/stream?…, DELETE … │ │
│ └───────┬────────────────────────────────────────────────────────┘ │
└──────────┼─────────────────────────────────────────────────────────┘
│ HTTP / SSE (text/event-stream)
│
┌──────────▼─────────────────────────────────────────────────────────┐
│ ASP.NET Core Minimal API (Server) │
├─────────────────────────────────────────────────────────────────────┤
│ ┌──────────────────────┐ ┌──────────────────────────────────┐ │
│ │ ChatEndpoints │ │ ConversationEndpoints │ │
│ │ POST /api/chat │ │ GET/POST/DELETE /api/… │ │
│ │ GET /api/chat/… │ │ │ │
│ └──────────┬───────────┘ └───────────────┬──────────────────┘ │
│ │ │ │
│ ┌──────────▼───────────┐ ┌───────────────▼──────────────────┐ │
│ │ OpenAiService │ │ ConversationRepository │ │
│ │ (streams tokens via │ │ (reads/writes JSON files) │ │
│ │ openai-dotnet SDK) │ │ │ │
│ └──────────┬───────────┘ └───────────────┬──────────────────┘ │
│ │ │ │
├─────────────┼───────────────────────────────┼────────────────────────┤
│ │ HTTPS │ local disk │
│ ┌─────▼──────┐ ┌─────────▼──────────────────┐ │
│ │ OpenAI API │ │ ~/chat-data/ │ │
│ │ (GPT-4o) │ │ conversations/{id}.json │ │
│ └────────────┘ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Component Responsibilities
| Component | Responsibility | Typical Implementation |
|---|---|---|
| ChatPage | Top-level container — composes sidebar + chat panel, owns route | Blazor page component (@page "/chat/{id?}") |
| ConversationList | Lists saved conversations, triggers create/delete/switch | Child component with EventCallback to parent |
| MessageList | Renders all messages in active conversation | Child component, iterates message model |
| MessageBubble | Renders single message — user vs AI, markdown for AI | Leaf component, uses Markdig or similar |
| ChatInput | Text area + send button, raises OnSend event | Child component with EventCallback |
| ConversationStateService | Singleton in-memory state — active conversation, messages, streaming flag | C# service registered AddSingleton, raises OnChange events |
| ChatApiClient | Wraps HttpClient, handles streaming plumbing | Scoped service, uses SetBrowserResponseStreamingEnabled(true) |
| ChatEndpoints | Minimal API: accepts message, streams SSE response from OpenAI | Static endpoint methods wired in Program.cs |
| ConversationEndpoints | Minimal API: CRUD for conversations | Static endpoint methods |
| OpenAiService | Calls OpenAI SDK, returns IAsyncEnumerable<string> of tokens |
Scoped service on server |
| ConversationRepository | Read/write JSON files on disk | Singleton or Scoped service on server |
Recommended Project Structure
ChatAgentWebApp/
├── ChatAgentWebApp.Client/ # Blazor WASM project
│ ├── Components/
│ │ ├── Chat/
│ │ │ ├── ChatPage.razor # Page — route entry point
│ │ │ ├── MessageList.razor # Renders message history
│ │ │ ├── MessageBubble.razor # Single message (user/AI)
│ │ │ └── ChatInput.razor # Text input + send
│ │ └── Conversations/
│ │ └── ConversationList.razor # Sidebar conversation switcher
│ ├── Services/
│ │ ├── ConversationStateService.cs # In-memory singleton state
│ │ └── ChatApiClient.cs # HttpClient wrapper + SSE reading
│ └── Program.cs # DI registration, HttpClient base URL
│
├── ChatAgentWebApp.Server/ # ASP.NET Core Minimal API project
│ ├── Endpoints/
│ │ ├── ChatEndpoints.cs # POST /api/chat/stream (SSE)
│ │ └── ConversationEndpoints.cs # GET/POST/DELETE /api/conversations
│ ├── Services/
│ │ ├── OpenAiService.cs # Wraps openai-dotnet SDK, yields tokens
│ │ └── ConversationRepository.cs # JSON file read/write
│ ├── Models/ # Server-only models (request/response)
│ └── Program.cs # Minimal API wiring, CORS, DI
│
└── ChatAgentWebApp.Shared/ # Shared library (both projects reference)
└── Models/
├── Conversation.cs # Shared model — id, title, createdAt
└── ChatMessage.cs # Shared model — role, content, timestamp
Structure Rationale
- Client/Services/: All HttpClient wiring and streaming logic lives here, not in components. Components stay dumb (data in via parameters, actions out via EventCallback).
- Shared/Models/: Models used by both Client (display) and Server (serialization) live here. Eliminates duplicate DTOs.
- Server/Endpoints/: Minimal API endpoint registration separated by concern (chat streaming vs conversation CRUD). Keeps Program.cs clean.
- Server/Services/: OpenAI SDK calls and file I/O isolated from HTTP concerns. Enables testing without HTTP context.
Architectural Patterns
Pattern 1: SSE Streaming from Minimal API to WASM Client
What: The server endpoint writes text/event-stream frames to the response as OpenAI tokens arrive. The WASM client reads the response as a stream using SetBrowserResponseStreamingEnabled(true) and processes tokens without waiting for the full response.
When to use: Any time you need token-by-token streaming from an LLM. The alternative (wait for full response, then display) is noticeably worse UX for long answers.
Trade-offs: Slightly more plumbing than a simple JSON response. SSE is one-directional (server to client), which is fine here — the client sends the initial message as a POST, and the stream returns the reply.
Server endpoint pattern:
// Server: ChatEndpoints.cs
app.MapPost("/api/chat/stream", async (ChatRequest request, OpenAiService ai,
ConversationRepository repo, HttpContext http) =>
{
http.Response.Headers.ContentType = "text/event-stream";
http.Response.Headers.CacheControl = "no-cache";
await foreach (var token in ai.StreamResponseAsync(request))
{
await http.Response.WriteAsync($"data: {token}\n\n");
await http.Response.Body.FlushAsync();
}
await http.Response.WriteAsync("event: done\ndata: end\n\n");
await repo.AppendMessageAsync(request.ConversationId, assistantMessage);
});
Client consumption pattern:
// Client: ChatApiClient.cs
var req = new HttpRequestMessage(HttpMethod.Post, "/api/chat/stream");
req.SetBrowserResponseStreamingEnabled(true); // Critical for WASM
req.Content = JsonContent.Create(chatRequest);
var response = await _httpClient.SendAsync(req,
HttpCompletionOption.ResponseHeadersRead); // Don't buffer full body
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (line?.StartsWith("data: ") == true)
{
var token = line[6..];
_stateService.AppendToken(token);
await InvokeAsync(StateHasChanged); // Update UI per token
}
}
Pattern 2: Singleton State Service as Shared State
What: A ConversationStateService registered as a singleton (in WASM, scoped = singleton anyway) holds the active conversation, message list, and streaming state. Components subscribe to an OnChange event to re-render when state updates.
When to use: When multiple components need to reflect the same data — the sidebar list, the message pane, and the input box all depend on the same active conversation.
Trade-offs: Simple and explicit. Not as formal as Redux/Flux but appropriate for single-user personal tool. Avoids prop-drilling through component hierarchies.
Example:
// ConversationStateService.cs
public class ConversationStateService
{
public Conversation? ActiveConversation { get; private set; }
public List<ChatMessage> Messages { get; } = new();
public bool IsStreaming { get; private set; }
public event Action? OnChange; // Components subscribe to this
public void SetActive(Conversation conv)
{
ActiveConversation = conv;
Messages.Clear();
NotifyStateChanged();
}
public void AppendToken(string token)
{
// Append to last message (AI response being streamed)
Messages.Last().Content += token;
NotifyStateChanged();
}
private void NotifyStateChanged() => OnChange?.Invoke();
}
Pattern 3: Repository for JSON File Persistence
What: ConversationRepository encapsulates all file I/O. Each conversation is stored as {id}.json in a configured data directory. The repository loads/saves these files and maintains an in-memory index for listing.
When to use: Always — never write File.ReadAll... directly in endpoint handlers. Even for JSON files, the repository pattern keeps concerns separate and makes the storage medium swappable.
Trade-offs: Slight overhead vs inline file I/O. But it cleanly separates persistence from HTTP handling and makes unit testing possible without touching the filesystem.
// ConversationRepository.cs
public class ConversationRepository
{
private readonly string _dataDir;
public async Task<List<Conversation>> GetAllAsync() { ... }
public async Task<Conversation?> GetByIdAsync(string id) { ... }
public async Task SaveAsync(Conversation conv) { ... }
public async Task DeleteAsync(string id) { ... }
public async Task AppendMessageAsync(string id, ChatMessage msg) { ... }
}
Data Flow
Sending a Message and Receiving a Streaming Response
[User types message + clicks Send]
↓
[ChatInput.razor] → OnSend EventCallback
↓
[ChatPage.razor] calls ConversationStateService.BeginStreaming()
↓
[ChatApiClient.PostMessageStreamAsync()] — POST /api/chat/stream
↓
[Server: ChatEndpoints] receives request
↓
[OpenAiService.StreamResponseAsync()] → calls OpenAI GPT API
↓
[OpenAI returns streaming response] — tokens arrive incrementally
↓
[Server writes SSE frames] → "data: Hello\n\n", "data: world\n\n", ...
↓
[Client StreamReader] reads lines as they arrive (ResponseHeadersRead)
↓
[ConversationStateService.AppendToken()] mutates last message
↓
[OnChange event fires] → all subscribed components call StateHasChanged()
↓
[MessageList re-renders] — user sees tokens appearing in real time
↓
[Server sends "event: done"] → client marks IsStreaming = false
↓
[Server persists full response] → ConversationRepository.AppendMessageAsync()
Conversation Management Flow
[User clicks "New Conversation"]
↓
[ConversationList.razor] → EventCallback to ChatPage
↓
[ChatApiClient.CreateConversationAsync()] → POST /api/conversations
↓
[Server: ConversationEndpoints] → ConversationRepository.SaveAsync()
↓
[Returns new Conversation object]
↓
[ConversationStateService.SetActive(newConv)] → clears message list
↓
[All components re-render] — empty chat ready for input
State Management Summary
ConversationStateService (Singleton in WASM)
OnChange event
↓ (subscribed in OnInitialized, unsubscribed in Dispose)
[ChatPage] [MessageList] [ConversationList] [ChatInput]
↑
Mutations via method calls (SetActive, AppendToken, SetStreaming)
↑
Triggered by ChatApiClient responses
Build Order Implications
Components have clear dependency layers. Build from the bottom up:
- Shared Models —
Conversation,ChatMessage(no deps, both projects need these) - ConversationRepository — file I/O, no HTTP (testable in isolation)
- OpenAiService — OpenAI SDK calls, yields
IAsyncEnumerable<string> - Server Endpoints — wires services to HTTP (depends on 2 and 3)
- ChatApiClient — WASM HTTP client + SSE consumer (depends on server being up)
- ConversationStateService — in-memory state (depends on models, no HTTP)
- Leaf UI components —
MessageBubble,ChatInput,ConversationList(pure display) - Container components —
MessageList,ChatPage(compose leaves, use state service)
This order maps naturally to build phases: backend first (phases 1-3), then state layer, then UI.
Scaling Considerations
This is a single-user personal tool. Scaling is not a concern for v1.
| Scale | Architecture Adjustment |
|---|---|
| 1 user (current) | Monolith fine, JSON files fine, no auth needed |
| Multi-user | Add auth, move to SQLite or Postgres, scope state service per user |
| Cloud deploy | Externalize API key via Azure Key Vault, containerize server |
Scaling Priorities (if needed in v2+)
- First bottleneck: JSON files don't support concurrent writes — add SQLite (EF Core migration is straightforward)
- Second bottleneck: Single server process — Blazor WASM + separate API already decoupled; scale API independently
Anti-Patterns
Anti-Pattern 1: Calling OpenAI from the WASM Client
What people do: Register HttpClient in the Blazor WASM project and call api.openai.com directly.
Why it's wrong: The OpenAI API key is visible to anyone who opens browser DevTools. The key is in the Authorization header of every request.
Do this instead: All OpenAI calls go through the backend API. The server reads the key from appsettings.json or environment variables (server-side, never shipped to browser).
Anti-Pattern 2: Buffering the Streaming Response
What people do: Call await response.Content.ReadAsStringAsync() after SendAsync, then parse the complete response.
Why it's wrong: In Blazor WASM, without SetBrowserResponseStreamingEnabled(true) and ResponseHeadersRead, the browser buffers the entire response before making any of it available. The UI shows nothing until the full AI response is complete — defeating the entire point of streaming.
Do this instead: Set SetBrowserResponseStreamingEnabled(true) on the HttpRequestMessage and use HttpCompletionOption.ResponseHeadersRead in SendAsync. Read the response as a stream line by line.
Anti-Pattern 3: Calling StateHasChanged from a Background Thread
What people do: Mutate state and call StateHasChanged() directly inside async token-reading loops.
Why it's wrong: In Blazor WASM, this is mostly harmless today because WASM is single-threaded — but in .NET 10+, multi-threaded WASM is becoming a reality. The correct pattern also reads more clearly.
Do this instead: Use await InvokeAsync(StateHasChanged) when updating UI from within async callbacks or loops. This schedules re-render on the correct synchronization context and is safe across all hosting models.
Anti-Pattern 4: Fat Components (Logic in Razor Files)
What people do: Put API call logic, JSON deserialization, and state mutation directly in .razor component code blocks.
Why it's wrong: The tutorial nature of this project means code must be readable. Logic buried in components is hard to explain, hard to test, and violates single responsibility. The builder is also learning Blazor patterns — fat components teach bad habits.
Do this instead: Components only call services. Services own all logic. This also demonstrates the Blazor DI pattern explicitly, which is a key learning objective.
Integration Points
External Services
| Service | Integration Pattern | Notes |
|---|---|---|
| OpenAI GPT API | openai-dotnet SDK on server, IAsyncEnumerable<string> returned |
Never from WASM client. Key in appsettings.json on server. |
| Browser FileSystem | None — all persistence is server-side JSON files | WASM cannot write to local disk; server has full file access |
Internal Boundaries
| Boundary | Communication | Notes |
|---|---|---|
| WASM Client ↔ API Server | HTTP / SSE via HttpClient |
Configure base URL + CORS. During development, server serves client static files (hosted model). |
| ChatPage ↔ Child Components | Blazor parameters + EventCallback | Downward via [Parameter], upward via EventCallback<T> |
| Components ↔ State Service | Injected singleton, OnChange event subscription |
Components subscribe in OnInitialized, unsubscribe in IDisposable.Dispose |
| Endpoints ↔ Services | Constructor DI | Both OpenAiService and ConversationRepository injected into endpoint handlers |
| OpenAiService ↔ ConversationRepository | No direct coupling | Endpoint coordinates both — calls AI service, then persists to repo |
Sources
- Microsoft Docs: Call a web API from Blazor (aspnetcore-10.0)
- Meziantou: Streaming an HTTP response in Blazor WebAssembly
- Strathweb: Built-in support for Server Sent Events in .NET 9
- Petkir: Stream chat to your frontend with SSE in ASP.NET Core (.NET 10)
- openai/openai-dotnet issue #65: Streaming doesn't work properly in Blazor WASM
- Microsoft Docs: Blazor project structure
- Microsoft Docs: Blazor state management
- Syncfusion: MVVM Pattern in Blazor For State Management
- PalmHill.BlazorChat — reference implementation (WASM + WebAPI + real-time LLM)
Architecture research for: Blazor WebAssembly AI Chat Application Researched: 2026-03-27