Мы привыкли писать бэкенды на .NET и немного фронтенд на Typescript + React. Десктопные приложения всегда были где-то рядом, но не в нашей зоне ответственности. Поэтому когда внезапно понадобилось Windows-приложение для печати этикеток, мы ощутили себя новичками: интерфейсы, процессы, работа с ОС и железом — всё это выглядело как неизведанная территория.

Мы решили опираться на привычные практики — строгая структура кода, тесты, CI/CD — и попробовать перенести их на десктоп. В итоге получилось приложение, архитектура которого напоминает современные бэкенд-сервисы, но адаптирована под Windows Desktop.

Почему вообще десктоп?

Приложение печатает этикетки для пиццы и заготовок в холодном цехе — всего, что уходит в производство. У нас уже было Android-приложение, но оно упёрлось в ограничения:

  • поддерживалась только одна модель принтера (Gprinter);

  • Bluetooth-соединение постоянно разрывалось, особенно под нагрузкой.

Решение оказалось простым: перейти на проводное подключение. У принтеров есть USB и Ethernet, но у USB есть ограничение по длине, а активные повторители — сложно для пиццерий.

Ethernet дал стабильность. Список поддерживаемых моделей принтеров на десктопных устройствах мы расширили благодаря использованию разных драйверов.

В час пик на кухне пиццерии принтер мог перестать печатать — просто потому что Bluetooth «отвалился». После перехода на Ethernet таких историй больше не было.

Архитектура

В десктопных приложениях легко скатиться в «спагетти», где UI, бизнес-логика и работа с ОС перемешаны. Нам это не подходило. Мы взяли идеи из RIBs от Uber и построили архитектуру из модулей:

  • Activator — управляет жизненным циклом и инициализацией модуля;

  • Interactor — реализует бизнес-логику и правила работы модуля;

  • Presenter — отвечает за преобразования, которые не относятся к бизнес-логике (например, Interactor работает с числами, а TextBox ожидает строки);

  • View — «тупой» UI: только отрисовка и делегирование событий.

Главные плюсы:

  • чёткие границы ответственности;

  • модульность и простота тестирования;

  • переносимость привычных бэкенд-паттернов: DI, unit-тесты, feature-based архитектура.

Сравнение: ASP.NET MVC vs Desktop

Backend-паттерн

Desktop-аналог

View

WinForms / View (UI) — фреймворк для построения интерфейса, примерно как UI-библиотеки во фронтенде

Controller (обработка и подготовка к отображению данных)

Presenter — у нас он ближе всего именно к Controller: принимает данные от Interactor, преобразует/валидирует их и подготавливает для View.

Application Service

Interactor

Инфраструктурные адаптеры (ORM, HTTP)

Windows-адаптеры (PrintSpooler, SignalRClient)

DI/Composition Root

Activator

В итоге мы стали мыслить так: «внутри — сценарии и правила (Interactor + порты), а снаружи — адаптеры и UI».

Humble Objects и outproc-зависимости

Тестировать десктоп — боль. UI-контролы, драйвера, сетевые клиенты живут «снаружи» процесса, а тащить их в тесты сложно.

Мы использовали паттерн Humble Object:

  • UI и внешние адаптеры — максимально «тупые» объекты без логики;

  • бизнес-логика работает через интерфейсы;

  • для тестов реальные зависимости заменяем mock/fake.

Примеры:

  • SignalRClient → fake-имитация сообщений;

  • WinAPI вызовы → обёртки с подменами.

Как результат: всю бизнес-логику можно покрыть юнит-тестами без UI и без «железа».

Примеры кода

UI-слой. Максимально «тупой» View. Отвечает за отображение пин-кода при авторизации, а также за отображение её ошибок:

public partial class AuthForm : Form, IAuthView
{
    public string PinCode { set => codeLabel.Text = value; }
    public string Error   { set => errorLabel.Text = value; }

    ...
}

Юнит-тест Presenter'а. Проверяет бизнес-логику форматирования кода, UI заменён на fake:

[Test]
[TestCase(1, "0001")]
[TestCase(12, "0012")]
[TestCase(123, "0123")]
[TestCase(1234, "1234")]
public void ShouldFormatPinCode(int pinCode, string expected)
{
    var view = new FakeAuthView();
    var sut = new AuthPresenter(view, Mock.Of<IErrorLocalizer>());

    sut.ShowCode(pinCode);

    view.PinCode.Should().Be(expected);
}

public class FakeAuthView : IAuthView
{
    public string PinCode { get; set; } = "----";
    public string Error { get; set; } = string.Empty;
    public event EventHandler ServerSelected = delegate { };
}

Здесь довольно простое бизнес-правило (формат пин-кода) тестируется изолированно. UI подменён фейковой реализацией.

Interactor. Отвечает за бизнес-логику: правила авторизации, валидацию данных, вызовы сервисов:

public class AuthInteractor
{
    private readonly IAuthService _authService;
    private readonly IAuthView _view;
  ...

    public void Activate()
    {
        _view.ServerSelected += async (_, __) =>
        {
            var result = await _authService.LoginAsync(_view.PinCode);
            if (result.Success)
                _view.Complete();
            else
                _view.Error = result.ErrorMessage;
        };
    }
  ...
}

Здесь Interactor подписывается на события от UI, выполняет вызовы бизнес-сервисов и сообщает Presenter/View о результате.

Activator. Управляет жизненным циклом модуля:

public class SettingsAuthActivator : ISettingsAuthActivator
{
    private readonly IServiceProvider _serviceProvider;
  ...


    public IDisposable Activate(Action handleDeactivationRequested)
    {
        var scope = _serviceProvider.CreateScope();
        var form = scope.ServiceProvider.GetRequiredService<AuthForm>();
        var interactor = scope.ServiceProvider.GetRequiredService<AuthInteractor>();

        interactor.Activate();

        form.FormClosed += (_, _) => handleDeactivationRequested();
        form.Show();

        return new RunWhenDisposed(() => { form.Close(); scope.Dispose(); });
    }
}

Здесь видно: Activator создаёт scope зависимостей, инициализирует форму и interactor, а при закрытии корректно завершает работу модуля.

Модульная структура

Чтобы не утонуть в монолите, мы сделали древовидную структуру:

  • RootActivator — это точка входа, стартует всё приложение;

  • ниже — специализированные модули: Auth, Printing и др;

  • каждый получает свой scope зависимостей через DI.

Плюсы этого подхода:

  1. Изоляция — тестируем модули независимо.

  2. Управляемый жизненный цикл — Activator следит за включением/выключением.

  3. Масштабирование — новая фича = новый модуль.

Модули общаются через интерфейсы и C#-события: один компонент публикует события через контракт (например, ITrayViewPublisher), другой подписывается на них, реализуя этот интерфейс (например, RootChannel : ITrayViewPublisher). Такой подход убирает жёсткие зависимости и позволяет легко подменять реализации.

Наша пирамида тестирования

  • UnitTests — быстрые и массовые тесты бизнес-логики, полностью без UI и внешних сервисов.

  • InfrastructureTests проверяют инфраструктурные компоненты приложения. Например, корректность парсинга XML с обновлениями, генерацию HTML для печати, работу файловой системы при распаковке архивов.

  • ApplicationTests намеренно ограничились одним сквозным (end-to-end) смоук-сценарием — печатью тестовой этикетки на виртуальном принтере. Он даёт уверенность, что весь путь от запуска до печати работает.

Такое разделение позволяет двигаться от изолированных правил к полному сценарию, сохраняя баланс скорости и уверенности.

Конечно, такой подход не исключил необходимости ручного тестирования. Например, мы обязательно проверяем печать на реальном принтере, чтобы убедиться в корректной работе железа.

Особенности Desktop-разработки

О чём стоит помнить

  1. UI = «живой» пользователь. Всегда есть риск, что он нажмёт «не туда».

  2. Локальные ресурсы: драйверы, версии Windows и антивирусы могут влиять на работу.

  3. Жизненный цикл — спящие состояния, перезапуски после обновлений.

  4. Деплой и обновления — это не один сервер, а сотни машин.

  5. Тестирование. Приходится работать с реальными устройствами: принтеры, USB.

  6. Один экземпляр приложения — что делать, если пользователь запустит его дважды?

  7. Tray icon. Как приложение ведёт себя, если свернуть его в трей?

  8. Блокировка sleep-режима — не всегда очевидная, но важная деталь.

Автообновления

Автообновление десктопного приложения оказалось куда сложнее, чем в вебе. Готовые библиотеки вроде AutoUpdater.Net показались слишком ограниченными: нам нужна была бОльшая гибкость и контроль над процессом загрузки.

В итоге мы полностью реализовали собственную логику автообновлений:

  1. Периодический поллинг сервера и проверка актуальности текущей версии приложения.

  2. Скачивание и распаковка архива с новой версией.

  3. Перезапуск приложения — graceful shutdown и рестарт .exe.

  4. Очистка мусора — удаление старых папок, архива с обновлением и лишних скриптов.

Архивы и метаданные обновлений собираются и выкладываются через CI/CD-пайплайн.

Не забывайте про сертификацию приложения. Она нужна, чтобы пользователи доверяли вашему ПО, а операционная система запускала его без пугающих предупреждений о неподписанном коде. Подпись подтверждает подлинность сборки и снижает риск блокировок антивирусами.

Выводы

Для нашей команды этот проект стал настоящим погружением в неизведанное. Мы, бэкенд-разработчики, привыкшие к серверным сервисам и контролируемым окружениям, с нуля сделали рабочее десктопное приложение.

Что реально помогло нам двигаться вперёд:

  • привычные инженерные практики: строгая архитектура, DI, тесты и CI/CD;

  • выбранная архитектура — модульная, с идеями из RIBs (Uber) и собственными доработками;

  • использование Humble Objects, которые сильно упростили тестирование UI и внешних сервисов.

Что пришлось осваивать заново, сталкиваясь с особенностями десктопа:

  • жизненный цикл приложения и непредсказуемость действий пользователя;

  • взаимодействие с оборудованием и драйверами;

  • организация обновлений на клиентских машинах.

Даже на «чужой территории» привычные инженерные практики работают и дают результат. Сегодня наше приложение печатает этикетки в реальном производстве и реально экономит время коллег, а мы получили ценный опыт разработки для Windows.

На этом всё. Спасибо, что дочитали статью! Если вы когда-нибудь тоже занимались нетипичными для себя задачами, поделитесь вашим опытом в комментах.

Не забудьте поставить плюсик статье и скинуть её друзьям. А чтобы оставаться в курсе последних новостей нашей команды, подписывайтесь на Telegram-канал Dodo Engineering.

Комментарии (2)


  1. a-tk
    07.10.2025 13:15

    WinForms в 2025... Мазохисты...


    1. i_zver Автор
      07.10.2025 13:15

      Мы рассматривали альтернативы вроде WPF и MAUI, но, как я писал в статье, у команды был минимальный опыт работы с десктопными приложениями, поэтому решили не усложнять на первом шаге. К тому же WinForms предоставляет встроенную поддержку печати через классы пространства имён System.Drawing.Printing, что было для нас важным.
      При этом архитектура проекта изначально спроектирована так, чтобы в будущем можно было заменить UI-слой без серьёзных проблем.