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

csharp
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

csharp
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

csharp
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

csharp
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

csharp
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.

From the original client

The WinForms IAT Design repo — 600+ classes, custom paint, real multithreading. These excerpts are from github.com/mkjanda/IAT-Design.

The 7-block IAT, as code

IAT Design/C7BlockIATGenerator.cs

csharp
private void CopyItemsToBlock(CIATBlock dest, CIATBlock src, bool reverse)
{
    for (int ctr = 0; ctr < src.NumItems; ctr++)
    {
        if (src[ctr].GetKeyedDirection(src.URI) == KeyedDirection.None)
            dest.AddItem(src[ctr], KeyedDirection.None);
        else if (src[ctr].GetKeyedDirection(src.URI) == KeyedDirection.Left)
            dest.AddItem(src[ctr], reverse ? KeyedDirection.Right : KeyedDirection.Left);
        else if (src[ctr].GetKeyedDirection(src.URI) == KeyedDirection.Right)
            dest.AddItem(src[ctr], reverse ? KeyedDirection.Left : KeyedDirection.Right);
        else if (src[ctr].GetKeyedDirection(src.URI) == KeyedDirection.DynamicLeft)
            dest.AddItem(src[ctr], reverse ? KeyedDirection.DynamicRight : KeyedDirection.DynamicLeft);
        else if (src[ctr].GetKeyedDirection(src.URI) == KeyedDirection.DynamicRight)
            dest.AddItem(src[ctr], reverse ? KeyedDirection.DynamicLeft : KeyedDirection.DynamicRight);
    }
}

public bool Generate(bool bAlternate)
{
    CIATBlock b3 = new CIATBlock(IAT);
    b3.Key = GenerateResponseKeyForBlock(3);
    CopyItemsToBlock(b3, IAT.Blocks[0], false);
    CopyItemsToBlock(b3, IAT.Blocks[1], false);
    b3.AddToIAT(insertionNdx++);

    CIATBlock b5 = new CIATBlock(IAT);
    b5.Key = GenerateResponseKeyForBlock(5);
    CopyItemsToBlock(b5, IAT.Blocks[1], true);
    b5.AddToIAT(insertionNdx++);

    CIATBlock b6 = new CIATBlock(IAT);
    b6.Key = GenerateResponseKeyForBlock(6);
    CopyItemsToBlock(b6, IAT.Blocks[0], false);
    CopyItemsToBlock(b6, IAT.Blocks[1], true);
    b6.AddToIAT(insertionNdx++);

    if (bAlternate)
    {
        new AlternationGroup(b3, b6);
        new AlternationGroup(b4, b7);
    }
    return true;
}

Greenwald’s procedure is a generator, not a wizard. Blocks 3–4 combine the two practice keys; block 5 reverses the target; 6–7 recombine. CopyItemsToBlock flips keyed direction when the target is reversed. Optional AlternationGroup swaps compatible/incompatible blocks across participants.

Bitmaps that don’t own the process

IAT Design/ImageManager.cs

csharp
public void Start()
{
    CCompositeImageGenerator.StartGeneration();
    StartResizer();
    StartThumbnailGenerator();
    StartNonUserImageGenerator();
    this.Running = true;
}

public void Halt(bool bIsHaltingForSave)
{
    CCompositeImageGenerator.EndGeneration();
    var resizer = new ManualResetEvent(false);
    var thumbs = new ManualResetEvent(false);
    var generated = new ManualResetEvent(false);
    CIATImage.HaltResizer(resizer);
    CThumbnail.HaltThumbnailGenerator(thumbs);
    HaltNonUserImageGenerator(generated);
    resizer.WaitOne();
    thumbs.WaitOne();
    generated.WaitOne();
    this.Running = false;
}

private void CompactImageDictionary()
{
    lock (dictionaryLock)
    {
        foreach (var id in UserImages.Keys.ToList())
            if (UserImages[id]?.NumInstances == 0)
            {
                UserImages[id].Dispose();
                UserImages.Remove(id);
            }
        foreach (var id in NonUserImages.Keys.ToList())
            if (NonUserImages[id]?.NumInstances == 0)
            {
                NonUserImages[id].Dispose();
                NonUserImages.Remove(id);
            }
    }
}

Thumbnails, resizes, and generated stimuli run on timers off the UI thread. CompactImageDictionary drops images with zero remaining instances. This is how peak memory went from nearly 1 GB to 55 MB — reuse the bitmap, don’t keep every size around forever.

Generate off the UI thread, ref-count the result

IAT Design/CCompositeImageGenerator.cs

csharp
static public void StartGeneration()
{
    halted = new ManualResetEvent(false);
    halting = false;
    ImageGenerationTimer = new System.Threading.Timer((n) =>
    {
        if (!Monitor.TryEnter(generatorLock))
            return;
        try
        {
            List<CCompositeImage> stale;
            lock (listLockObj)
                stale = ImageDictionary.Keys
                    .Where(ci => !ci.IsValid).ToList();
            foreach (var ci in stale)
                ci.TryGenerate(false);
            if (halting)
                halted.Set();
        }
        finally { Monitor.Exit(generatorLock); }
    }, null, 0, 100);
}

static public void AddCompositeImage(CCompositeImage ci)
{
    lock (listLockObj)
    {
        ImageDictionary[ci] = ImageDictionary.ContainsKey(ci)
            ? ImageDictionary[ci] + 1 : 1;
        ci.Invalidate();
    }
}

Composite keys and instruction screens are regenerated on a 100 ms timer. TryEnter so a slow generate never piles up. AddCompositeImage increments a count; RemoveImage drops the entry at zero. The UI binds to the cached bitmap, not the generator.