← All work CODE · 2026

Enterprise-grade IoT environmental control system in .NET.

The Professional Refactor of Abandoned System That Actually Has Architecture

The old version before I touched it had too many lines lines. One class. One static method. No tests, no interfaces, no structure - just a while(true) loop and undisguised hope. BeautifulClient is what happened next.

Enterprise-grade IoT environmental control system in .NET.

There is a kind of software almost every developer has written at least once. It works. It does what it was meant to do. But when you open the file and look at the structure, you immediately know it needs help.

OldProgram.cs was that file. It was a single static class with one OldMain method, six switch cases, and a while(true) loop that trapped you permanently once you selected option five unless you killed the process. It talked to a remote simulation API that controlled a room's fans, heaters, and temperature sensors. There were no interfaces, no real error model, and no separation of concerns. Everything was inlined. The "display all device state" block existed three times as copy-paste. There was even a Console.Write prompt that was never followed by a Console.ReadLine().

The most honest summary from the architecture notes written before the refactor started was this: "User interface code, transport code, parsing code, and control logic all live in one file and frequently in the same method. There is no abstraction, no injection point for testing."

This post is about what came after that. BeautifulClient is a full rewrite of the system: a terminal-based IoT environmental control dashboard built on .NET 10, with layered architecture, proper tests, and a design that is much easier to maintain. The biggest improvement is not any single feature. It is that the codebase was built with maintenance in mind from the start.

Console Demo

The Damage Report

Before writing any refactored code, I documented the old system properly. That included C4 diagrams, UML class diagrams, an activity diagram for the menu loop, and a state diagram for the four-phase temperature control algorithm. The point was simple: understand exactly what was broken before trying to improve it.

The findings were not great.

The three temperature sensor methods each returned a different type: string, int, and decimal. There was no clear reason for that. The GetAverageTemperature method had to manually reconcile all three: double.Parse() for one, implicit widening for another, and an explicit cast for the third. Adding a new sensor meant adding another special case.

// OldProgram.cs - three sensors, three types, one method doing all the cleanup
static async Task<string> GetSensor1Temperature(HttpClient client) { ... }
static async Task<int> GetSensor2Temperature(HttpClient client) { ... }
static async Task<decimal> GetSensor3Temperature(HttpClient client) { ... }

static async Task<double> GetAverageTemperature(HttpClient client)
{
    var sensor1 = double.Parse(await GetSensor1Temperature(client));
    var sensor2 = await GetSensor2Temperature(client);
    var sensor3 = (double)await GetSensor3Temperature(client);

    return (sensor1 + sensor2 + sensor3) / 3;
}

The automatic control algorithm, which was option five, had a while(true) at the top and then called HoldTemperature with duration = int.MaxValue in phase four. In practice, it never ended. There was no exit path. Once you selected that option, the program was no longer really yours.

The API key was hard-coded in source. The device count, which was 3, appeared in six different places with comments like "Assuming 3...". Endpoint strings were scattered across the file as literals.

That was the baseline. The refactor had a very obvious target.

The Architecture

BeautifulClient is split into four layers, each with a clear job.

┌─────────────────────────────────────┐
│         Presentation Layer          │
│         (Views, Layouts)            │
├─────────────────────────────────────┤
│         Application Layer           │
│    (Controllers, Command Handlers)  │
├─────────────────────────────────────┤
│          Service Layer              │
│   (API Facade, Adapters, Pipelines) │
├─────────────────────────────────────┤
│           Data Layer                │
│       (DTOs, Models, Structs)       │
└─────────────────────────────────────┘

The entry point is Program.cs, and it only wires up dependencies and starts the host. No business logic. No HTTP calls. Just startup configuration.

From there, ConsoleHost runs a controller loop. Each controller executes one use case and returns a NavigationResult that points to the next controller. The host resolves that controller from dependency injection and continues. The loop ends when a controller returns null as the next route.

public sealed class ConsoleHost(IServiceProvider serviceProvider)
{
    public async Task Host<TController>(object? settings = null) where TController : Controller
    {
        Type? currentRouteType = typeof(TController);
        object? payload = null;

        while (currentRouteType != null)
        {
            IRouter controller = (IRouter)serviceProvider.GetRequiredService(currentRouteType);
            NavigationResult result = await controller.ExecuteAsync(payload);
            currentRouteType = result.NextRoute;
            payload = result.Payload;
        }
    }
}

That is a big difference from the original loop, which was one while(true), one switch statement, and a lot of mixed responsibilities all in the same place.

The Facade Pattern

One of the biggest problems in the original version was that OldMain owned the HttpClient, knew all the endpoints, and made every HTTP call itself. If the API changed, you had to search through Main and patch things manually.

The fix was to put a facade in front of that. IApiService exposes the contract in domain terms:

public interface IApiService
{
    Task<ApiResult<SensorData>> GetSensorTemperatureAsync(int sensorId);
    Task<ApiResult> SetHeaterLevelAsync(int heaterId, int level);
    Task<ApiResult<HeaterData>> GetHeaterDataAsync(int heaterId);
    Task<ApiResult> SetFanStateAsync(int fanId, bool isOn);
    Task<ApiResult<FanData>> GetFanDataAsync(int fanId);
    Task<ApiResult> ResetAsync();
}

Controllers and command handlers talk to IApiService and do not care what sits underneath it. That might be HTTP, local hardware, or a mock in a test. UniversalApiFacade implements the contract with a local-first fallback approach:

public class UniversalApiFacade(LocalAdapter localService, RemoteAdapter remoteService) : IApiService
{
    public async Task<ApiResult<SensorData>> GetSensorTemperatureAsync(int sensorId)
    {
        var localResult = await localService.GetSensorTemperatureAsync(sensorId);
        if (localResult.IsSuccess) return localResult;
        return await remoteService.GetSensorTemperatureAsync(sensorId);
    }
    // ... same pattern for every operation
}

The system checks locally attached hardware first, then falls back to the remote simulation API. The caller does not have to care which one was used.

The Adapter Pattern

The inconsistent sensor return types were one of the clearest design problems in the original code. In BeautifulClient, every sensor read follows the same path and returns the same shape.

Temperature is wrapped in a value object:

public readonly record struct Celcius(double Value) : IComparable<Celcius>, IFormattable, ITemperature
{
    public static implicit operator Celcius(double d) => new(d);
    public static explicit operator double(Celcius t) => t.Value;
    public override string ToString() => $"{Value} °C";

    public static bool operator <(Celcius left, Celcius right) => left.Value < right.Value;
    public static bool operator >(Celcius left, Celcius right) => left.Value > right.Value;
    public static Celcius operator +(Celcius left, Celcius right) => new(left.Value + right.Value);
}

It is also aliased globally so the code reads more cleanly:

global using celc = BeautifulClient.Data.Structs.Temperature.Celcius;

RemoteAdapter handles the HTTP side and inherits from ApiActions, which provides shared helpers like GetAsync<T>, SetAsync, and PostEmptyAsync. That means there is no repeated manual JSON handling in every single method.

public async Task<ApiResult<SensorData>> GetSensorTemperatureAsync(int sensorId)
{
    return await apiResultPipeline.ExecuteAsync(() => GetAsync<SensorData>(
        $"api/sensor/{sensorId}",
        json => new(objectSetterPipeline)
        {
            Id = sensorId,
            Temperature = json.GetDouble(),
            RawJson = json.GetRawText(),
            SaveAction = sensor => throw new NotImplementedException("WIP")
        }
    ));
}

So instead of juggling three unrelated types, every sensor returns ApiResult<SensorData> with a Celcius temperature. That entire class of mess disappeared.

The Command Pattern

The old switch statement was doing too much. Every case handled prompting, parsing, HTTP calls, response handling, and console output. Adding a new menu option meant editing Main directly. Testing anything meant dragging the whole method along with it.

BeautifulClient breaks that apart. CommandParser turns raw input into a typed ParsedCommand record:

public sealed record ParsedCommand(
    DashboardCommandType Type,
    int? DeviceId = null,
    object? Value = null,
    string? RawInput = null);

The parser supports normal command syntax, fan shorthand like 1on 2off 3on, and heater shorthand like 2:5, depending on the active DashboardInputMode:

public ParsedCommand Parse(string input, DashboardInputMode mode = DashboardInputMode.Command)
{
    var trimmed = input.Trim();
    if (string.IsNullOrWhiteSpace(trimmed))
        return new ParsedCommand(DashboardCommandType.Refresh, RawInput: input);

    return mode switch
    {
        DashboardInputMode.Fan    => ParseFanShorthand(trimmed),
        DashboardInputMode.Heater => ParseHeaterShorthand(trimmed),
        _                         => ParseCommandSyntax(trimmed)
    };
}

CommandExecutor then receives the parsed command and decides what to do with it:

public Task<CommandResult> ExecuteAsync(ParsedCommand command) => command.Type switch
{
    DashboardCommandType.SetFan        => SetFanAsync(command),
    DashboardCommandType.SetAllFans    => SetAllFansAsync(command),
    DashboardCommandType.SetHeater     => SetHeaterAsync(command),
    DashboardCommandType.SetAllHeaters => SetAllHeatersAsync(command),
    DashboardCommandType.Reset         => ResetAsync(),
    DashboardCommandType.ApplyPreset   => ApplyPresetAsync(command),
    _ => Task.FromResult(new CommandResult(false, $"Unrecognised command: '{command.RawInput}'."))
};

DashboardController sits in the middle and passes commands through without knowing their internal details. The parser and executor can both be tested on their own, which is exactly what the test suite does.

The Result Pattern

The original code did not have a proper failure model. Some things failed silently. Some threw exceptions straight into the console. Some were caught and reduced to a vague message. There was no single definition of what a failed operation looked like.

In BeautifulClient, operations return ApiResult or ApiResult<T>:

public sealed class ApiResult<T> : ApiResult
{
    public T Value { get; }

    public static ApiResult<T> Success(T value) => new(value, true, Error.None);
    public new static ApiResult<T> Failure(Error error) => new(default, false, error);

    public static explicit operator ApiResult<T>(Error error) => Failure(error);
    public static implicit operator ApiResult<T>(T value) => Success(value);
}

Errors are strongly typed value objects instead of random strings or raw exception text:

public static Error Timeout => new("Network.Timeout", "The request timed out.");
public static Error NetworkFailure => new("Network.Failure", "A network error occurred.");
public static Error NotFound404 => new("Http.404", "The requested resource could not be found.");
public static Error LocalApiFail => new("LocalApi.Fail", "Local API call failed. Check hardware connection.");

The controller checks IsSuccess, builds user feedback, and keeps the dashboard stable. A failed sensor fetch does not crash the whole interface. It just shows that one slot as failed.

Sensors = sensorTasks
    .Select(t => t.Result)
    .Where(r => r.IsSuccess)
    .Select(r => r.Value)
    .ToList(),

The Decorator Pattern

The Decorator pattern shows up in two places, both registered through Scrutor.

The first is MainLayout<TModel>, which wraps each page with the shared terminal frame like headers, borders, and navigation. That means page views do not have to redraw the same outer shell themselves.

builder.AddPageDecorator<DashboardModel, DashboardPage, MainLayout<DashboardModel>>(null);

The second is ApiResultPipeline, which wraps service calls with timing and structured logging. That keeps telemetry out of the core service methods:

public async Task<ApiResult<TE>> ExecuteAsync<TE>(Func<Task<ApiResult<TE>>> apiCall)
{
    Stopwatch stopwatch = Stopwatch.StartNew();
    ApiResult<TE> result = await apiCall();
    stopwatch.Stop();

    if (result.IsFailure)
        logger.LogWarning("Operation failed after {ElapsedMs}ms | {ErrorCode} | {ErrorMessage}",
            stopwatch.Elapsed.TotalMilliseconds, result.Error.Code, result.Error.Message);
    else
        logger.LogInformation("Operation succeeded in {ElapsedMs}ms", stopwatch.Elapsed.TotalMilliseconds);

    return result;
}

The MVC Navigation System

The old structure was basically one infinite loop and a switch statement. The refactored version uses an MVC-style navigation model adapted for a console app.

Controllers inherit from an abstract Controller base class that provides navigation helpers, access to IApiService, and strongly typed payload handling:

protected static TPayload PayloadAs<TPayload>(object? payload, TPayload defaultValue)
{
    if (payload is null) return defaultValue;
    if (payload is TPayload typedPayload) return typedPayload;

    throw new InvalidCastException(
        $"Invalid payload type. Expected {typeof(TPayload).Name}, got {payload.GetType().Name}.");
}

Views implement IView<TModel> and return a NavigationResult. The controller does not render. The view does not fetch data. DashboardController owns the use case and keeps the flow small:

public override async Task<NavigationResult> ExecuteAsync(object? payload = null)
{
    var command = PayloadAs<ParsedCommand?>(payload, defaultValue: null);

    if (command?.Type is DashboardCommandType.Quit)
        return new NavigationResult(null);

    CommandResult? lastResult = await TryExecuteCommandAsync(command);
    var model = await FetchDashboardModelAsync(lastResult);
    return await view.ReturnAsync(model);
}

That method stays short because the work is separated properly. Parsing lives in DashboardPage, execution in CommandExecutor, and rendering in DashboardRenderer.

The TUI

The terminal interface is built with Spectre.Console. It gives the project structured tables, panels, and layout tools without fighting the terminal.

The dashboard shows a three-column grid for sensors, heaters, and fans, with per-device status indicators. Temperature values map to color-coded labels:

private static (string color, string label) TemperatureCategory(double celsius) => celsius switch
{
    < 10  => ("blue",     "COLD"),
    < 18  => ("cyan",     "COOL"),
    < 28  => ("green",    "NORM"),
    < 36  => ("yellow",   "WARM"),
    _     => ("red bold", "HOT!")
};

Heater intensity is rendered as a block bar:

private static string BuildHeaterBar(int level)
{
    var filled = new string('█', level);
    var empty  = new string('░', MaxHeaterLevel - level);
    var pct    = level == 0 ? "OFF" : level == MaxHeaterLevel ? "MAX" : $"{level * 20}%";
    return $"{filled}{empty} {pct}";
}

Keyboard shortcuts are handled through a key-dispatch model in DashboardPage. Ctrl+F enters fan mode, Ctrl+H enters heater mode, Ctrl+A opens the full command prompt, Ctrl+R triggers reset confirmation, and Ctrl+L opens the live log overlay.

Logging

The application uses Serilog with three sinks: console for warnings and above, a daily rolling file, and a custom in-memory queue sink that drives the live log overlay.

SerilogQueSink stores formatted entries in a static thread-safe queue capped at ten logs. The overlay reads directly from that queue:

public void Emit(LogEvent logEvent)
{
    var colour = logEvent.Level switch
    {
        LogEventLevel.Warning               => "yellow",
        LogEventLevel.Error or Fatal        => "red",
        LogEventLevel.Debug or Verbose      => "grey",
        _                                   => "white"
    };

    string entry = $"[{colour}]{timestamp} {logEvent.Level,-7:G}[/] {Markup.Escape(message)}";

    lock (LogQueue)
    {
        if (LogQueue.Count >= MaxLogs) LogQueue.Dequeue();
        LogQueue.Enqueue(entry);
    }
}

That makes logs visible in real time inside the dashboard without wrecking the TUI layout with ordinary console output.

Resilience

HTTP calls from RemoteAdapter run through a typed HttpClient with a Polly retry policy: three retries with exponential backoff at 2, 4, and 8 seconds. The logger records each retry so you can see what happened in the activity log.

HttpPolicyExtensions
    .HandleTransientHttpError()
    .WaitAndRetryAsync(
        retryCount: 3,
        sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
        onRetry: (outcome, timespan, attempt, context) =>
            logger.LogWarning("Retry #{Attempt} in {Delay}s", attempt, timespan.TotalSeconds));

Preset System

The command system also supports named presets that set all heaters and fans together. Presets are immutable records and are looked up case-insensitively:

private static readonly TemperaturePreset Warm =
    new("warm", [3, 3, 3], [false, false, false]);

private static readonly TemperaturePreset Cool =
    new("cool", [0, 0, 0], [true, true, true]);

private static readonly TemperaturePreset Balanced =
    new("balanced", [2, 1, 2], [true, false, true]);

public static bool TryGet(string name, out TemperaturePreset? preset)
    => Catalogue.TryGetValue(name, out preset);

Applying a preset runs all heater and fan changes in parallel and returns a CommandResult that distinguishes full success from partial failure:

var allResults = await Task.WhenAll(heaterTasks.Concat(fanTasks));
int failures = allResults.Count(r => r.IsFailure);

return failures == 0
    ? new CommandResult(true,  $"Preset '{preset.Name}' applied.")
    : new CommandResult(false, $"Preset '{preset.Name}' partially applied ({failures} failure(s)).");

The Test Suite

The project currently runs 86 xUnit tests. Eighty-two pass. Four fail, and those failures are useful because they expose known gaps rather than random breakage.

Failing Test What It Exposes
ConsoleHostTests.Host_PassesInitialSettingsPayload Settings parameter is accepted but dropped before reaching the first controller
ConsoleHostTests.Host_FollowsRoutesAndPropagatesPayload Related payload propagation between routes is not working correctly
MainLayoutTests.ReturnAsync_RendersLayout The layout changed and the test no longer matches it
DashboardRendererTests.RenderHelp_DisplaysPresets The help text in the renderer has drifted from the assertion

The 82 passing tests cover controller routing, command parsing across input modes, local and remote adapter paths, the render pipeline, overlay behavior, and the preset catalog. The point is not just that tests exist. It is that the architecture made them possible.

A representative parser test for inline multi-fan shorthand looks like this:

[Fact]
public void Parse_FanMode_ReturnsSetFanBulk_ForInlineMultiSyntax()
{
    ParsedCommand result = parser.Parse("1on 2off 3on", DashboardInputMode.Fan);

    Assert.Equal(DashboardCommandType.SetFan, result.Type);
    Assert.NotNull(result.Value);

    var bulk = Assert.IsAssignableFrom<IReadOnlyList<(int id, bool state)>>(result.Value);
    Assert.Equal([(1, true), (2, false), (3, true)], bulk);
}

That test runs independently of the executor, the controller, the dashboard, and any HTTP client. That separation was the whole point of the refactor.

The Technology Stack

Concern Library / Framework
Runtime .NET 8 / .NET 10, C# 13
Terminal UI Spectre.Console 0.54
Dependency Injection Microsoft.Extensions.Hosting + Scrutor 7.0
HTTP Resilience Microsoft.Extensions.Http.Polly 10.0
Structured Logging Serilog 10.0 (Console + File + custom in-memory sink)
Testing xUnit 2.9
Mock API Server ASP.NET Core (SensorServer project, same solution)

The mock server ships in the same solution. It implements the full API surface for fans, heaters, sensors, and reset, with per-client state isolation through a ConcurrentDictionary<string, ClientState> keyed by API key. Running both projects together gives a full local development environment without any outside dependency.

Known Gaps

BeautifulClient is in a good place architecturally, but it is not finished everywhere.

LocalAdapter only handles sensor reads for IDs 4 to 6. All other operations return Error.LocalApiFail and fall through to the remote adapter. The pattern is there, but the coverage is not complete yet. StatefulDto.SaveOnChangesAsync has a working success path, but Revert() is still not implemented. MenuRouteAttribute exists on controllers, but the router does not actually use it at runtime. The device count of 3 still appears in several places instead of coming from configuration.

Those are follow-up tasks on top of a solid base. They do not require the architecture to be rethought. They are implementation gaps, not structural problems.

The Source

The full project, including BeautifulClient, the tests, and the SensorServer mock, is available on GitHub at github.com/MateuszPodeszwa/UglyClient. The ARCHITECTURE.md and FAIRAIUSAGE.md files document the design decisions and where external tooling was used.

The original code is still in the repository as OldProgram.cs. Reading the old file first and then comparing it to the refactored version shows the whole point of the project much better than any summary could.