
Мы привыкли писать бэкенды на .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.
Плюсы этого подхода:
Изоляция — тестируем модули независимо.
Управляемый жизненный цикл — Activator следит за включением/выключением.
Масштабирование — новая фича = новый модуль.
Модули общаются через интерфейсы и C#-события: один компонент публикует события через контракт (например, ITrayViewPublisher), другой подписывается на них, реализуя этот интерфейс (например, RootChannel : ITrayViewPublisher). Такой подход убирает жёсткие зависимости и позволяет легко подменять реализации.
Наша пирамида тестирования
UnitTests — быстрые и массовые тесты бизнес-логики, полностью без UI и внешних сервисов.
InfrastructureTests проверяют инфраструктурные компоненты приложения. Например, корректность парсинга XML с обновлениями, генерацию HTML для печати, работу файловой систем�� при распаковке архивов.
ApplicationTests намеренно ограничились одним сквозным (end-to-end) смоук-сценарием — печатью тестовой этикетки на виртуальном принтере. Он даёт уверенность, что весь путь от запуска до печати работает.
Такое разделение позволяет двигаться от изолированных правил к полному сценарию, сохраняя баланс скорости и уверенности.
Конечно, такой подход не исключил необходимости ручного тестирования. Например, мы обязательно проверяем печать на реальном принтере, чтобы убедиться в корректной работе железа.
Особенности Desktop-разработки
О чём стоит помнить
UI = «живой» пользователь. Всегда есть риск, что он нажмёт «не туда».
Локальные ресурсы: драйверы, версии Windows и антивирусы могут влиять на работу.
Жизненный цикл — спящие состояния, перезапуски после обновлений.
Деплой и обновления — это не один сервер, а сотни машин.
Тестирование. Приходится работать с реальными устройствами: принтеры, USB.
Один экземпляр приложения — что делать, если пользователь запустит его дважды?
Tray icon. Как приложение ведёт себя, если свернуть его в трей?
Блокировка sleep-режима — не всегда очевидная, но важная деталь.
Автообновления
Автообновление десктопного приложения оказалось куда сложнее, чем в вебе. Готовые библиотеки вроде AutoUpdater.Net показались слишком ограниченными: нам нужна была бОльшая гибкость и контроль над процессом загрузки.
В итоге мы полностью реализовали собственную логику автообновлений:
Периодический поллинг сервера и проверка актуальности текущей версии приложения.
Скачивание и распаковка архива с новой версией.
Перезапуск приложения — graceful shutdown и рестарт
.exe.Очистка мусора — удаление старых папок, архива с обновлением и лишних скриптов.
Архивы и метаданные обновлений собираются и выкладываются через CI/CD-пайплайн.
Не забывайте про сертификацию приложения. Она нужна, чтобы пользователи доверяли вашему ПО, а операционная система запускала его без пугающих предупреждений о неподписанном коде. Подпись подтверждает подлинность сборки и снижает риск блокировок антивирусами.
Выводы
Для нашей команды этот проект стал настоящим погружением в неизведанное. Мы, бэкенд-разработчики, привыкшие к серверным сервисам и контролируемым окружениям, с нуля сделали рабочее десктопное приложение.
Что реально помогло нам двигаться вперёд:
привычные инженерные практики: строгая архитектура, DI, тесты и CI/CD;
выбранная архитектура — модульная, с идеями из RIBs (Uber) и собственными доработками;
использование Humble Objects, которые сильно упростили тестирование UI и внешних сервисов.
Что пришлось осваивать заново, сталкиваясь с особенностями десктопа:
жизненный цикл приложения и непредсказуемость действий пользователя;
взаимодействие с оборудованием и драйверами;
организация обновлений на клиентских машинах.
Даже на «чужой территории» привычные инженерные практики работают и дают результат. Сегодня наше приложение печатает этикетки в реальном производстве и реально экономит время коллег, а мы получили ценный опыт разработки для Windows.
На этом всё. Спасибо, что дочитали статью! Если вы когда-нибудь тоже занимались нетипичными для себя задачами, поделитесь вашим опытом в комментах.
Не забудьте поставить плюсик статье и скинуть её друзьям. А чтобы оставаться в курсе последних новостей нашей команды, подписывайтесь на Telegram-канал Dodo Engineering.
