Skip to content

Approach

The original client taught me what not to put in a domain object.

IAT Design grew for more than a decade as a WinForms application — custom paint, offscreen bitmaps, god objects that talked to the network and the disk. The WPF rewrite is how I keep the product without keeping the architecture that made change expensive. These are the rules I wrote down while doing the work.

Domain objects stay pure

The test model holds data and Guids. It does not touch the file system, the network, or the display. Side effects live in services. That boundary is the rewrite’s north star — learned the hard way from a WinForms client where god objects talked to everything.

Infrastructure is injectable

WebSockets, packaging, image generation, validation, and dialogs are interfaces registered in DI. ViewModels consume contracts, not implementations. MediatR carries transaction events across that boundary so a handler never needs a window.

Geometry is computed, not guessed

Instruction regions, continue strips, and keyed layouts are derived from the interior and key positions. Designers can override; the calculator still owns the defaults. Previews stay locked to the same aspect as the administered test — one math, two surfaces.

Production means the unhappy path

A socket that works once is a demo. Persistent connections, cancellation, exponential backoff, thread-safe send, and transaction completion without tearing down the pipe — that is production. UTF-8 text frames, not binary, because the Java handler is a TextWebSocketHandler.

DTOs stay DTOs

ServerReport is a serializable payload, not an ObservableObject. The ViewModel maps it on the dispatcher and ignores empty reports so a mid-transaction reset cannot blank the Deploy tab. XAML never binds to TransactionState.

Security is a contract, not theater

Handshake is a challenge/response around AES of a random string — ProductKey, nonce, challenge, tag. That cut a WebSocket round-trip that existed only as “security for the sake of security.” Results at rest are AES-GCM plus asymmetric encryption so a stolen database is unreadable.

What that looks like

Excerpts from the current WPF client and the Java server. Trimmed for reading; the trees are on GitHub under MIT.

The aggregate owns referential integrity

IAT.Core/Domain/IatTest.cs

public InstructionScreen? RemoveInstructionScreen(InstructionScreen screen)
{
    if (screen is null) return null;
    if (!InstructionScreens.Remove(screen))
        return null;

    _instructionCache.Remove(screen.Id);

    foreach (var block in Blocks)
        block.InstructionsIds.Remove(screen.Id);

    return screen;
}

/// Reset in place so child ViewModels that hold this
/// singleton stay valid; ObservableCollections raise
/// CollectionChanged as items are removed.
public void Reset()
{
    Id = Guid.NewGuid();
    Name = "New IAT Test";
    Stimuli.Clear();
    Blocks.Clear();
    Trials.Clear();
    Keys.Clear();
    InstructionScreens.Clear();
}

Removing an instruction screen is a domain operation: drop it from the collection, drop the cache entry, and strip the Guid from every block. Children hold Ids, not object graphs. No network, no file I/O, no window.

Composition root

IAT Design WPF/App.xaml.cs

protected override void OnStartup(StartupEventArgs e)
{
    var services = new ServiceCollection();
    services.AddSingleton<TransactionState>();
    services.AddMediatR(cfg =>
        cfg.RegisterServicesFromAssembly(
            typeof(TransactionSuccessHandler).Assembly));

    services.AddSingleton<IWebSocketService, WebSocketService>();
    services.AddSingleton<ILayoutCalculatorService, LayoutCalculatorService>();
    services.AddSingleton<IProjectPackageService, ProjectPackageService>();
    services.AddSingleton<ITestDeploymentService, TestDeploymentService>();
    services.AddSingleton<IValidator<IatTest>, IatTestValidator>();

    services.AddSingleton<IatTest>();
    services.AddSingleton<LayoutViewModel>();
    services.AddSingleton<InstructionManagerViewModel>();
    services.AddSingleton<DeployManagerViewModel>();
    services.AddSingleton<TestDesignerViewModel>();

    Services = services.BuildServiceProvider();
    new MainWindow().Show();
}

One place to wire the graph. Domain singleton shared by every designer tab. Validators, export processors, and the WebSocket client are injected. ViewModels never new up infrastructure.

Text frames, UTF-8, no BOM

IAT.Core/Services/Network/WebSocketService.cs

public async Task SendMessage(object message)
{
    await EnsureConnectedAndReceivingAsync();

    await using var memStream = new MemoryStream();
    var settings = new XmlWriterSettings
    {
        Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
        OmitXmlDeclaration = false,
        Indent = false
    };
    using (var writer = XmlWriter.Create(memStream, settings))
        new XmlSerializer(message.GetType()).Serialize(writer, message);

    await _sendLock.WaitAsync();
    try
    {
        await _socket!.SendAsync(
            new ArraySegment<byte>(memStream.ToArray()),
            WebSocketMessageType.Text,
            endOfMessage: true,
            timeoutCts.Token);
    }
    finally { _sendLock.Release(); }
}

private async Task<bool> TryReconnectAsync(CancellationToken ct)
{
    if (_intentionalClose) return false;
    _reconnectAttempt++;
    var delay = Math.Min(MaxBackoffSeconds,
        (int)Math.Pow(2, Math.Min(_reconnectAttempt, 5)));
    ConnectionState = WebSocketConnectionState.Reconnecting;
    await Task.Delay(TimeSpan.FromSeconds(delay), ct);
    await ConnectCoreAsync(ct);
    return true;
}

Java’s handler only implements handleTextMessage. Binary frames never arrive. XmlSerializer’s default UTF-16 is wrong for text frames. Send is semaphore-guarded; reconnect uses exponential backoff capped at 30s.

Derived layout geometry

IAT.Core/Services/LayoutCalculatorService.cs

public static Rect ComputeTextInstructionsRect(Rect interior)
{
    const double pad = 15;
    return new Rect(
        interior.X + pad, interior.Y + pad,
        Math.Max(0, interior.Width - 2 * pad),
        Math.Max(0, interior.Height - 2 * pad));
}

public static Rect ComputeKeyedInstructionsRect(
    Rect interior, Rect leftKey, Rect rightKey)
{
    const double pad = 15;
    var top = Math.Max(leftKey.Bottom, rightKey.Bottom) + pad;
    var bottom = interior.Bottom - pad;
    return new Rect(interior.X + pad, top,
        Math.Max(0, interior.Width - 2 * pad),
        Math.Max(0, bottom - top));
}

public static Rect ComputeMockItemInstructionsRect(
    Rect interior, Rect errorMark, Rect continueInstructions)
{
    var top = errorMark.Bottom;
    var bottom = Math.Max(top, continueInstructions.Top);
    return new Rect(0, top, Math.Max(0, interior.Width),
        Math.Max(0, bottom - top));
}

Text, keyed, mock-item, and continue regions are functions of the interior and key bottoms. The preview and the administered test share the same math. Designers may override; defaults still come from the calculator.

Map the DTO. Don’t bind it.

IAT.ViewModels/Controls/DeployManagerViewModel.cs

private void OnServerReportChanged(ServerReport report)
{
    Application.Current.Dispatcher.Invoke(() =>
    {
        if (!_isActive) return;
        ApplyServerReport(report);
        LastSyncText = "just now";
    });
}

private void ApplyServerReport(ServerReport report)
{
    var hasIats = report.IATReport is { Count: > 0 };
    var hasIdentity = !string.IsNullOrWhiteSpace(report.ContactFName)
                      || !string.IsNullOrWhiteSpace(report.Organization);

    // A mid-transaction reset must never blank the tab.
    if (!hasIats && !hasIdentity && DeployedTests.Count > 0)
        return;

    AccountName = $"{report.ContactFName} {report.ContactLName}".Trim();
    AdministrationsRemaining = report.NumAdministrations < 0
        ? "Unlimited"
        : report.NumAdministrations.ToString();
}

ServerReport is a serializable payload. The ViewModel applies it on the dispatcher and refuses to replace a populated list with an empty report. XAML never binds to TransactionState.

The constraints behind those excerpts are written out in What I would not port— what I refused to carry from the WinForms client into WPF.

IAT Design case study