
Мы привыкли писать бэкенды на .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.
a-tk
WinForms в 2025... Мазохисты...
i_zver Автор
Мы рассматривали альтернативы вроде WPF и MAUI, но, как я писал в статье, у команды был минимальный опыт работы с десктопными приложениями, поэтому решили не усложнять на первом шаге. К тому же WinForms предоставляет встроенную поддержку печати через классы пространства имён System.Drawing.Printing, что было для нас важным.
При этом архитектура проекта изначально спроектирована так, чтобы в будущем можно было заменить UI-слой без серьёзных проблем.