Files
AgenticCode/.planning/research/ARCHITECTURE.md
2026-03-27 00:59:24 +00:00

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
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:

  1. Shared ModelsConversation, ChatMessage (no deps, both projects need these)
  2. ConversationRepository — file I/O, no HTTP (testable in isolation)
  3. OpenAiService — OpenAI SDK calls, yields IAsyncEnumerable<string>
  4. Server Endpoints — wires services to HTTP (depends on 2 and 3)
  5. ChatApiClient — WASM HTTP client + SSE consumer (depends on server being up)
  6. ConversationStateService — in-memory state (depends on models, no HTTP)
  7. Leaf UI componentsMessageBubble, ChatInput, ConversationList (pure display)
  8. Container componentsMessageList, 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+)

  1. First bottleneck: JSON files don't support concurrent writes — add SQLite (EF Core migration is straightforward)
  2. 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


Architecture research for: Blazor WebAssembly AI Chat Application Researched: 2026-03-27