Як я будую workflow-платформу на .NET 10 з нуля: архiтектура, плагiни та Blazor-дизайнер
Мене звати Микола, я .NET-розробник (наразі Team Lead, отже часу на розробку дуже не дуже) з досвiдом у побудовi бекенд-систем та розподiлених сервiсiв. Останнiй рiк я працюю над open-source проєктом Vyshyvanka — платформою автоматизацiї воркфлоу, яка дозволяє вiзуально проєктувати та виконувати автоматизованi процеси.
Стаття буде цiкава .NET-розробникам, якi хочуть побачити, як виглядає архiтектура реального проєкту на .NET 10 з Blazor WebAssembly, плагiнною системою на NuGet та execution engine з топологiчним сортуванням.
Чому я почав цей проєкт
Коли менi потрiбна була автоматизацiя процесiв у .NET-проєктi, я подивився на ринок. n8n — чудовий, але це Node.js. Temporal — потужний, але це скорiше оркестратор для коду, а не вiзуальний конструктор. Apache Airflow — Python. Для .NET-свiту повноцiнного рiшення з вiзуальним редактором я не знайшов.
Я хотiв iнструмент, де:
- Воркфлоу проєктується вiзуально, а не пишеться кодом.
- Все працює на одному стеку без зоопарку технологiй.
- Плагiни розширюють систему без перекомпiляцiї.
- Безпека не додається потiм, а закладена з першого дня.
Так з’явилася Vyshyvanka. Назва — вiд вишиванки: орнамент, де кожен стiбок має значення, а нитки з’єднують елементи в цiлiсний патерн. Воркфлоу — це теж патерн з нод та з’єднань.
Архiтектурнi рiшення, якi я прийняв на старті
Перше, що я зробив — визначив межi вiдповiдальностi. Проєкт складається з шести частин:
Designer (Blazor WASM) --HTTP--> API (ASP.NET Core) --> Engine --> Core
Core — домен без жодних залежностей. Моделi, iнтерфейси, enum-и, виключення. Це фундамент, на який спираються всi iншi проєкти.
Engine — серце системи. Тут живе WorkflowEngine, який виконує воркфлоу за топологiчним порядком. Тут же EF Core persistence, реєстр нод, плагiнна система, expression evaluator та валiдацiя.
Api — REST API на ASP.NET Core. Контролери, middleware, автентифiкацiя, DTO.
Designer — Blazor WebAssembly SPA. Вiзуальний редактор з SVG-канвасом.
AppHost — .NET Aspire для локальної оркестрацiї.
ServiceDefaults — спiльна конфiгурацiя (OpenTelemetry, resilience).
Ключове архiтектурне правило: залежностi течуть тiльки вниз. Designer нiколи не посилається на Engine чи Api — вся комунiкацiя через HTTP. Це не просто красива дiаграма, а реальне обмеження, яке перевiряється при бiлдi: якщо хтось додасть зворотну залежнiсть, проєкт не скомпiлюється.
Чому це важливо? Бо дозволяє деплоїти фронтенд окремо вiд бекенду, тестувати шари iзольовано, i замiнювати реалiзацiї без каскадних змiн.
Як працює execution engine
Коли трiгер спрацьовує (webhook прийшов, cron-час настав, або користувач натиснув кнопку), створюється Execution у статусi Pending. Далi:
- Engine бере граф воркфлоу i робить топологiчне сортування — визначає порядок виконання нод.
- Статус змiнюється на Running.
- Кожна нода виконується послiдовно (або паралельно для незалежних гiлок).
- Вихiднi данi кожної ноди зберiгаються для використання downstream-нодами через вирази.
- Фiнальний статус: Completed, Failed або Cancelled.
Стейт-машина проста, але з жорстким iнварiантом: термiнальнi стани (Completed, Failed, Cancelled) — фiнальнi. Нiяких мутацiй пiсля завершення. Це спрощує reasoning про систему i робить execution log надiйним джерелом правди.
Ось як виглядає передача даних мiж нодами через вирази:
{{ nodes.a3f1b2c4-d5e6-7890-abcd-ef1234567890.response.body.users[0].email }}
{{ variables.executionId }}
{{ variables.workflowId }}
Тут a3f1b2c4-d5e6-7890-abcd-ef1234567890 — це ID ноди (GUID). В UI автокомплiт показує людське iм’я ноди, але вставляє її ID. Expression evaluator парсить цi шаблони i резолвить їх у runtime, пiдставляючи реальнi данi з output-iв попереднiх нод.
Система нод: як я проєктував розширюванiсть
Ноди — атомарнi одиницi роботи. Три категорiї з рiзними базовими класами:
public abstract class BaseTriggerNode : BaseNode
{
public override NodeCategory Category => NodeCategory.Trigger;
public abstract Task ShouldTriggerAsync(TriggerContext context, CancellationToken ct);
}
public abstract class BaseActionNode : BaseNode
{
public override NodeCategory Category => NodeCategory.Action;
}
public abstract class BaseLogicNode : BaseNode
{
public override NodeCategory Category => NodeCategory.Logic;
}
Кожна нода декларує свої порти та конфiгурацiю через атрибути:
[NodeDefinition("HTTP Request", "Makes HTTP requests to external APIs", "http")]
[NodeInput("input", "Input", PortType.Object, required: true)]
[NodeOutput("response", "Response", PortType.Object)]
[ConfigurationProperty("url", "string", Description = "Request URL", IsRequired = true)]
[ConfigurationProperty("method", "string", Description = "HTTP method", IsRequired = true, Options = "GET,POST,PUT,DELETE,PATCH")]
[RequiresCredential(CredentialType.ApiKey)]
public class HttpRequestNode : BaseActionNode
{
public override async Task ExecuteAsync(
NodeInput input, IExecutionContext context, CancellationToken ct)
{
// ...
}
}
NodeRegistry сканує збiрки, читає атрибути i будує каталог доступних нод. Цей каталог використовується i API (для вiддачi на фронтенд), i Engine (для iнстанцiювання нод при виконаннi).
Типiзованi порти дають валiдацiю з’єднань ще на етапi дизайну: не можна з’єднати String-порт з Boolean-портом. Але є тип Any, який сумiсний з усiма — для унiверсальних нод типу Merge.
Плагiнна система: NuGet як package manager для нод
Це була найскладнiша частина з iнженерної точки зору. Я хотiв, щоб плагiни:
- Розповсюджувались як звичайнi NuGet-пакети.
- Завантажувались в iзольований контекст (падiння плагiна не крашить хост).
- Могли hot-unload без перезапуску.
- Мали свої залежностi без конфлiктiв з хостом.
Рiшення — AssemblyLoadContext. Кожен плагiн живе у своєму контекстi:
public class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
return assemblyPath != null ? LoadFromAssemblyPath(assemblyPath) : null;
}
}
isCollectible: true — ключовий момент. Це дозволяє вивантажити збiрку з пам’ятi, коли плагiн видаляється.
PluginHost обгортає виконання плагiнних нод у timeout та exception handling:
public async Task ExecuteNodeInIsolationAsync(
INode node, NodeInput input, IExecutionContext context,
TimeSpan timeout, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
try
{
return await node.ExecuteAsync(input, context, cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
return NodeOutput.Error("Plugin execution timed out");
}
catch (Exception ex)
{
return NodeOutput.Error($"Plugin error: {ex.Message}");
}
}
Вже є кiлька готових плагiнiв: AdvancedHttp (retry з полiтиками, polling, batch, GraphQL), GitLab (issues, MR, pipelines), Jira (issues, JQL). Плюс шаблон для створення власних.
Менеджер пакетiв вбудований прямо в UI дизайнера: пошук по NuGet-фiдах, iнсталяцiя з резолвiнгом транзитивних залежностей, оновлення, видалення. Можна пiдключати приватнi фiди з автентифiкацiєю.
Blazor-дизайнер: SVG-канвас та JavaScript interop
Designer — це повноцiнний SPA на Blazor WebAssembly. Головний виклик — перформанс. Blazor render cycle занадто повiльний для плавного drag-and-drop та pan/zoom на канвасi з десятками нод.
Рiшення: SVG-канвас з JavaScript interop для performance-critical операцiй. Blazor вiдповiдає за стан (яка нода де стоїть, якi з’єднання iснують), а JS — за низькорiвневу взаємодiю (mouse events, coordinate transformations, smooth animations).
Кожен компонент слiдує патерну трьох файлiв:
Component.razor— тiльки розмiтка, нiяких @code блокiвComponent.razor.cs— code-behind як partial class з [Inject] для DIComponent.razor.css— scoped стилi
Це не просто конвенцiя, а жорстке правило проєкту. Чому? Бо змiшування логiки з розмiткою у Blazor швидко перетворює компоненти на нечитабельну кашу, особливо коли компонент росте.
Цiкава фiча — autocomplete для виразiв. Коли користувач набирає {{ у полi конфiгурацiї, з’являється dropdown з пiдказками: доступнi ноди, їх output-поля, системнi змiннi. ExpressionAutocompleteService читає поточний стан воркфлоу i генерує контекстнi пiдказки.
Безпека як first-class concern
Я свiдомо заклав безпеку з першого дня, а не додавав її потiм. Ось ключовi рiшення:
Кредiв нiколи не повертаються в API-вiдповiдях. Навiть адмiн не може побачити значення збереженого API-ключа через UI. Тiльки створити, оновити або видалити.
Три бекенди для зберiгання секретiв: вбудоване AES-256 шифрування в БД, HashiCorp Vault (KV v2), або OpenBao. Вибирається через конфiгурацiю без змiн коду.
Чотири провайдери автентифiкацiї: вбудований JWT, Keycloak, Authentik, LDAP. Всi конфiгуруються через appsettings.json. OIDC-провайдери автоматично провiзiонують локальних користувачiв при першому логiнi.
Webhook trigger пiдтримує HMAC-SHA256 верифiкацiю та IP allowlist — щоб нiхто стороннiй не мiг запустити ваш воркфлоу.
Перевiрка ownership перед кожною операцiєю. ICurrentUserService — обов’язковий учасник кожного контролера.
Що я зрозумiв пiд час розробки
Кiлька практичних висновкiв, якi можуть бути кориснi iншим:
Records для domain models — це правильний вибiр. Iммутабельнiсть за замовчуванням, value equality, лаконiчний синтаксис з with-виразами. Але є нюанс: EF Core з records працює не iдеально, потрiбнi окремi entity-класи для persistence layer.
AssemblyLoadContext з isCollectible: true має свої обмеження. Не всi типи коректно вивантажуються, якщо є витоки посилань. Довелося ретельно контролювати лайфтайм об’єктiв з плагiнних збiрок.
Blazor WASM для складного UI — це компромiс. Для форм та CRUD — iдеально. Для iнтерактивного канвасу з drag-and-drop — потрiбен JavaScript interop. Але перевага single-stack (C# скрiзь) переважує цей недолiк.
Topological sort для execution order — елегантне рiшення, але потрiбна окрема обробка для циклiв (Loop-нода створює пiдграф, який виконується iтеративно).
.NET Aspire реально спрощує локальну розробку. Service discovery, health checks, OpenTelemetry — все з коробки. Але для продакшену потрiбна окрема iнфраструктура.
Як спробувати
Проєкт open-source, MIT лiцензiя. Для запуску потрiбен .NET 10 SDK та Aspire workload:
dotnet workload install aspire git clone cd Vyshyvanka dotnet run --project src/Vyshyvanka.AppHost
Це пiднiме API та Designer з SQLite. В dev-режимi автоматично створюються три користувачi (admin, editor, viewer) для тестування рольової моделi.
Для PostgreSQL (потрiбен Docker):
USE_POSTGRES=true dotnet run --project src/Vyshyvanka.AppHost
Поточний стан та плани
Проєкт у стадiї активної розробки. Працює: вiзуальний дизайнер, execution engine, плагiнна система, автентифiкацiя, credential management. Є готовi плагiни для GitLab, Jira, розширеного HTTP.
Чого ще немає: версiонування воркфлоу, колаборативне редагування, marketplace плагiнiв, повноцiнна документацiя для розробникiв плагiнiв.
API та формати даних можуть змiнюватися — це поки не production-ready рiшення.
Висновок
Vyshyvanka — це мiй досвiд побудови повноцiнної платформи на сучасному .NET-стеку. Головний урок: навiть складну систему можна зробити зрозумiлою, якщо з першого дня закласти чiткi межi вiдповiдальностi мiж шарами та дотримуватися їх.
Якщо ви .NET-розробник i вам цiкава тема workflow automation, execution engines або плагiнних систем — подивiться на проєкт, спробуйте, дайте фiдбек. Контриб’юшени вiтаються, особливо у частинi нових нод та плагiнiв.
3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарівДякую за статтю. Також зараз в процесі написання повноцінної bpms/dms. В основі зоопарк технологій як Ви кажете(але загалом це temporal + js). Однією із моїх вимог це була побудова flow на bpmn 2.0 в камундовському дизайнері. Шукав щось на .net, так як вже 10 років працюю з K2(.Net), але не знайшов. Мав досвід з Elma,Scriptum(український продукт), Camunda.
Дякую за розповідь про свій досвід. Два роки тому мав задачу по цій темі на робочому проекті (треба було замінити сторонній комерційний workflow engine). Врешті через доволі специфічні вимоги довелося майструвати свій заточений під сценарії рушій, але в той же час розглядали можливість використання www.elsaworkflows.io тож ділюсь як мінімум якщо треба буде натхнення/подивитися на інші реалізації
Дякую. Так, в дечому я надихався і цим проектом теж, але мені не вистачає модульності в ньому. Тобто я можу написати будь які ноди там, обробку або запити які мені треба — збілдити і використовувати, але коли мені треба щось додати нове — мені треба знову доробляти і збирати проект. Якщо хтось інший зробить ноду яка вже робить те, що мені треба я не маю змоги її підключити і використовувати без перезборки проекту. У Вишиванці — нові ноди, то просто NuGet пакети — додав в рантаймі, та використовуй.