Enterprise & Integration · سازمانی و یکپارچهسازی سنیورSenior ~59 دقیقه مطالعه~50 min read
Camunda، BPMN و موتور فرایندCamunda, BPMN & Workflow Engines
از «چرا اصلاً موتور فرایند لازم است» تا BPMN 2.0 نماد به نماد، مدل اجرایی موتور (token، job، incident، history)، مقایسهٔ صادقانهٔ Camunda 7 و Camunda 8/Zeebe، یکپارچهسازی با Java و Spring Boot (JavaDelegate، external task، job worker)، DMN، انتخاب بین orchestration و choreography، و عملیات واقعی شامل incident، migration و رشد دیتابیس.From why a workflow engine exists at all to BPMN 2.0 symbol by symbol, the engine's execution model of tokens, jobs, incidents and history, an honest Camunda 7 versus Camunda 8/Zeebe comparison, Java and Spring Boot integration with JavaDelegate, external tasks and job workers, DMN decision tables, the orchestration-versus-choreography decision, and real operations including incidents, instance migration and database growth.
پیشنیاز:Prerequisites: مدیریتِ داده: Database-per-Service، Saga، Outbox و CQRSData Management: DB-per-Service, Saga, Outbox, CQRS
در آگهیهای بکاند، کنار Spring Boot و Kafka، یک خط کوتاه میبینی: «آشنایی با Camunda / BPMN مزیت محسوب میشود». اکثر کاندیداها از این خط رد میشوند چون فکر میکنند یک ابزار گرافیکی برای مدیرهاست. اما دقیقاً همینجاست که مصاحبهگر میفهمد تو تفاوت بین «یک request کوتاه» و «یک فرایند کسبوکاری که سه هفته زنده میماند» را میفهمی یا نه.
این فصل از صفر شروع میکند: اول میسازیم که چرا چیزی به اسم موتور فرایند (workflow engine) وجود دارد، بعد BPMN 2.0 را نماد به نماد یاد میگیریم، بعد داخل موتور را باز میکنیم، بعد کد واقعی Java مینویسیم، و در آخر به سؤالی میرسیم که مصاحبهٔ سنیور دربارهٔ آن است: کِی orchestration و کِی choreography؟
چرا موتور فرایند وجود دارد و چرا دستساز نوشتنش خراب میشود ← BPMN 2.0 نماد به نماد (pool/lane، event، task، gateway، boundary event، subprocess، multi-instance) ← مدل اجرایی موتور (definition/instance، token، variable، job executor، incident، history) ← Camunda 7 در برابر Camunda 8/Zeebe بهصورت صادقانه ← یکپارچهسازی Java: embedded engine، JavaDelegate، external task worker، Zeebe job worker، deploy و versioning، correlation، timer و retry، مرز تراکنش و async continuation، تست ← DMN و جدول تصمیم ← orchestration در برابر choreography و BPMN در برابر saga ← عملیات: Cockpit/Operate، incident، migration نمونههای در حال اجرا، کارایی و رشد دیتابیس.
۱. چرا اصلاً موتور فرایند وجود دارد؟
مشتری سفارش میدهد؛ گارسون یک برگهٔ کاغذی مینویسد و روی ریل آشپزخانه میزند. آن برگه چند دقیقه — گاهی یک ساعت — روی ریل میماند: آشپز سرد کارش را میکند و برگه را جابهجا میکند، بعد آشپز گرم، بعد کنترل کیفیت، بعد گارسون. اگر آشپز مریض شود برگه گم نمیشود؛ اگر بیست دقیقه کسی برنداشته باشد سرآشپز میبیند؛ آخر شب میشود شمرد چند سفارش در کدام ایستگاه گیر کردهاند.
آن ریل و آن برگهها یک موتور فرایند هستند: حالتِ کار بیرون از مغزِ آدمها ذخیره شده، قابل مشاهده است، و مستقل از اینکه چه کسی سرِ شیفت است ادامه پیدا میکند.
۱.۱ فرایند بلندمدت یعنی چه؟
اکثر کدی که مینویسی request-scoped است: یک HTTP request میآید، چند تابع صدا زده میشود، یک تراکنش commit میشود و همهچیز از حافظه پاک میشود. حالتِ کار = همان stack.
اما «صدور بیمهنامه» یا «افتتاح حساب» اینطور نیست: بین مرحلهٔ سوم و چهارم یک انسان باید تأیید کند (شاید فردا صبح)؛ اگر سه روز تأیید نشد باید به مدیر escalate شود؛ تماس با سامانهٔ استعلام ممکن است ده بار fail شود و با backoff دوباره تلاش شود؛ اگر مشتری انصراف داد کارهای انجامشده باید جبران (compensate) شوند؛ و شش ماه بعد حسابرس میپرسد «چه کسی، کِی، این را تأیید کرد؟».
به این میگویند long-running, stateful business process: فرایندی که عمرش از عمر یک request بیشتر است، حالت دارد، منتظر میماند، و باید ردپا (audit trail) از خودش بگذارد.
نمودار: تفاوت عمر یک request با عمر یک process instance — request lifetime vs process instance lifetime.
flowchart LR
subgraph R["Request-scoped service"]
A1["HTTP in"] --> A2["validate"] --> A3["DB tx commit"] --> A4["HTTP out"]
end
subgraph P["Long-running process"]
B1["Start: application received"] --> B2["Automated credit check"]
B2 --> B3["Human approval - hours or days"]
B3 --> B4["Timer: 3 days -> escalate"]
B4 --> B5["Issue policy"]
B5 --> B6["End + audit trail"]
end
۱.۲ چرا دستساز نوشتنش خراب میشود؟
مسیر همیشه یکسان است: اول یک ستون status (NEW, APPROVED, …)، بعد یک @Scheduled که هر دقیقه ردیفهای NEW را برمیدارد، بعد retry_count و next_retry_at و error_message، بعد escalated_at و یک جدول *_history، بعد یک صفحهٔ ادمین برای دیدن اینکه چه چیزی گیر کرده. و روز پنجم کسی میپرسد «اگر شرط را عوض کنیم، نمونههایی که الان وسط کارند چه میشوند؟» و کسی جواب ندارد.
این الگو ماه اول کار میکند و ماه ششم بدهی فنی میشود:
- منطق فرایند پخش شده است — هیچجا یک تصویر واحد از مسیر کامل نیست؛ باید سه سرویس را بخوانی تا بفهمی بعد از
APPROVEDچه میشود. - حالتهای نامعتبر ممکناند — هیچچیز جلوی رفتن از
NEWبهISSUEDرا نمیگیرد. - همزمانی دستساز — باید خودت
SELECT ... FOR UPDATE SKIP LOCKEDو lease و heartbeat بنویسی. - audit ناقص است —
updated_atمیگوید آخرین تغییر کِی بود، نه اینکه فرایند از چه مسیری رد شد. - تغییر نسخه فاجعه است — ۴۰ هزار نمونهٔ در حال اجرا با منطق قدیمی، و کد جدید فقط یک نسخه دارد.
موتور فرایند دقیقاً همینها را بهعنوان زیرساخت آماده میدهد.
۱.۳ موتور فرایند چه میدهد؟
یک موتور فرایند (Camunda، Flowable، Zeebe، Temporal…) اینها را آماده دارد: مدل اجرایی صریح (فایلی که هم دیاگرام است و هم کد)، پایداری حالت، wait state (ایستادن بدون اشغال thread)، timer، retry با backoff و تبدیل شکست دائمی به incident قابل مشاهده، task انسانی با صف و claim، history/audit، و versioning و migration نمونههای در حال اجرا.
اگر فرایند تو در یک request تمام میشود، انتظار انسانی و تایمر ندارد و مسیرش سه مرحلهٔ ثابت است، موتور فرایند اضافهکاری است: یک دیتابیس یا کلاستر جدید، یک مدل ذهنی جدید برای تیم، و یک لایهٔ عملیاتی جدید. قانون سرانگشتی: اگر فرایند wait state ندارد، موتور فرایند نمیخواهی. اگر دارد و بیش از دو تاست، دستساز نوشتنش گرانتر از یاد گرفتن BPMN است.
۲. BPMN 2.0 نماد به نماد
BPMN مخفف Business Process Model and Notation است؛ نسخهٔ 2.0 یک استاندارد OMG است که همزمان دو چیز تعریف میکند: یک نمادگذاری گرافیکی و یک فرمت XML اجرایی. نکتهٔ کلیدی همین است: فایل .bpmn که مدلساز تولید میکند همان چیزی است که موتور اجرا میکند. دیاگرام مستندات نیست؛ خودِ برنامه است. مثل نقشهٔ مترو بخوانش: ایستگاهها (task)، خطها (sequence flow)، تقاطعها (gateway)، و اتفاقهایی که وسط راه میافتند (event) — و یک قطار خیالی به نام token روی خط حرکت میکند.
۲.۱ Pool و Lane
Pool یک شرکتکنندهٔ مستقل است: سازمان تو، بانک طرف مقابل، مشتری. دو pool با message flow (خطچین) حرف میزنند، نه با sequence flow. Lane تقسیمبندی داخل یک pool است: «کارشناس»، «مدیر»، «سیستم». موتور فقط یک pool را اجرا میکند — همان pool داخلی تو؛ pool طرف مقابل را black box بکش. Lane معنای اجرایی مستقیم ندارد ولی معمولاً به assignment یا candidate group نگاشت میشود.
۲.۲ Event — شروع، پایان و وسط راه
شکل حاشیهٔ دایره جایگاه را میگوید: دایرهٔ نازک = start (فرایند از اینجا شروع میشود)، دایرهٔ دوجداره = intermediate (وسط مسیر؛ یا منتظر میماند یعنی catch، یا پرتاب میکند یعنی throw)، دایرهٔ ضخیم = end (این شاخه تمام شد). آیکون داخل دایره نوع را میگوید:
| آیکون | نوع | کاربرد واقعی |
|---|---|---|
| خالی | None | شروع/پایان ساده — با API آغاز میشود |
| پاکت | Message | پیام از بیرون: «پرداخت تأیید شد» |
| ساعت | Timer | «۳ روز صبر کن»، «هر روز ۲ بامداد» |
| مثلث | Signal | پخش همگانی به همهٔ مشترکان |
| صاعقه | Error | خطای کسبوکاری، نه خطای فنی |
| فلش رو به بالا | Escalation | ارجاع به سطح بالاتر، بدون قطع مسیر |
| فلش برگشتی | Compensation | «کاری که قبلاً انجام شد را برگردان» |
| دایرهٔ توپر با ضربدر | Terminate | کل نمونه را همینجا تمام کن |
BpmnError یعنی «اتفاق کسبوکاری که مسیر جایگزین دارد» — مثل «اعتبار کافی نیست». یک NullPointerException این نیست؛ آن یک خطای فنی است و باید به incident تبدیل شود تا آدمِ عملیات ببیندش، نه اینکه در یک شاخهٔ BPMN بلعیده شود. اگر خطاهای فنی را به BPMN error تبدیل کنی، باگها را زیر فرش میکنی و مسیر خطای کسبوکاری را با نویز پر میکنی.
۲.۳ Task — کار کجا انجام میشود؟
| نوع Task | آیکون | چه کسی انجام میدهد | مثال |
|---|---|---|---|
| User Task | آدمک | انسان، از طریق Tasklist یا UI خودت | «بررسی و تأیید درخواست وام» |
| Service Task | چرخدنده | کد (delegate یا worker) | «فراخوانی سامانهٔ استعلام اعتبار» |
| Script Task | طومار | اسکریپت داخل موتور | «محاسبهٔ کارمزد» |
| Send Task | پاکت توپر | ارسال پیام به بیرون | «ارسال SMS تأیید» |
| Receive Task | پاکت توخالی | انتظار برای پیام | «منتظر تأیید پرداخت» |
| Business Rule Task | جدول | ارزیابی یک DMN | «تعیین نرخ بیمه» |
| Manual Task | دست | کار انسانی خارج از سیستم | «بایگانی فیزیکی پرونده» |
| Call Activity | حاشیهٔ ضخیم | فراخوانی یک فرایند دیگر | «اجرای فرایند KYC» |
script task را در تولید دوست نداشته باش: نسخهبندی و تست و code review را از دست میدهی، خطای runtime داخل موتور میافتد، و در Camunda 7 اجرای Groovy/JS یک سطح حملهٔ واقعی باز میکند (کسی که بتواند مدل deploy کند، کد اجرا میکند). قاعده: منطق در کد، تصمیم در DMN، مدل فقط جریان.
۲.۴ Gateway — تصمیم و انشعاب
Gateway لوزی است و هیچ کاری انجام نمیدهد؛ فقط مسیر token را کنترل میکند.
Exclusive (XOR) با علامت ✕: دقیقاً یکی از مسیرها؛ اولین شرطی که true شود برنده است. حتماً یک default flow بگذار وگرنه اگر هیچ شرطی برقرار نشد موتور خطا میدهد.
نمودار: تصمیم انحصاری بر اساس مبلغ سفارش — exclusive gateway on order amount.
flowchart LR
S(("Start")) --> T1["Check order"]
T1 --> G{"amount > 10000?"}
G -- "yes" --> A["Manual approval"]
G -- "no (default)" --> B["Auto approve"]
A --> E(("End"))
B --> E
Parallel (AND) با علامت +: همهٔ شاخهها فعال میشوند (fork) و gateway بستن منتظر همهٔ آنها میماند (join). «همزمان» یعنی چند token فعال، نه لزوماً چند thread.
Inclusive (OR) با علامت ○: هر شاخهای که شرطش true باشد. Join آن باید بفهمد چند token در راه است و همین گرانترین gateway میکندش.
Event-based: مسیر را «اولین رویدادی که برسد» انتخاب میکند — الگوی استاندارد «race بین پیام و timeout».
نمودار: مسابقهٔ بین پیام پرداخت و timeout — event-based gateway race.
flowchart LR
T["Await payment"] --> EG{"Event-based gateway"}
EG --> M["Message: payment received"]
EG --> TI["Timer: PT30M"]
M --> OK["Ship order"]
TI --> CX["Cancel order"]
قاعدهٔ ساده برای حفظکردن: XOR = «یکی از»، AND = «همه»، OR = «هرکدام که شرط دارد»، event-based = «هرکدام که زودتر برسد». در عمل ۹۰٪ مدلهای سالم فقط XOR و AND دارند؛ مدلی پر از inclusive gateway معمولاً یعنی تحلیل ناقص.
اگر با parallel gateway دو شاخه باز کنی و با exclusive ببندی، اولین token رد میشود و token دوم هم جداگانه رد میشود — یعنی ادامهٔ فرایند دو بار اجرا میشود. این یکی از رایجترین باگهای تولید در BPMN است و در مصاحبه هم میپرسند. قاعده: هر gateway بازکننده باید با همنوع خودش بسته شود.
۲.۵ Sequence Flow و شرطها
پیکانهای ممتد sequence flow هستند و مسیر token را میسازند. روی هر flow خروجی از gateway میتوانی شرط بنویسی: در Camunda 7 با JUEL (${amount > 10000}) و در Camunda 8 با FEEL (=amount > 10000؛ علامت = یعنی «این یک expression است»). یک flow میتواند default باشد: شرط ندارد و فقط وقتی هیچ شرط دیگری برقرار نشد انتخاب میشود.
۲.۶ Boundary Event — واکنش به اتفاق وسط کار
Boundary event روی لبهٔ یک task یا subprocess مینشیند و به اتفاقی که در حین اجرای آن رخ میدهد واکنش نشان میدهد. Interrupting (خط ممتد) فعالیت را لغو میکند؛ non-interrupting (خطچین) فعالیت را دستنخورده میگذارد و یک token موازی جدید میسازد.
مثالهای واقعی: timer قطعکننده روی «تماس با مشتری» (۴۸ ساعت شد، پرونده را ببند)؛ timer غیرقطعکننده روی «بررسی مدیر» (هر ۲۴ ساعت یادآوری، task باز بماند)؛ error boundary روی service task (کد CREDIT_DENIED → مسیر رد درخواست)؛ message boundary (مشتری «انصراف» فرستاد → همهچیز لغو)؛ escalation غیرقطعکننده روی subprocess (مدیر خبردار شود ولی کار متوقف نشود)؛ و compensation boundary که handler برگرداندن کار را ثبت میکند.
نمودار: تایمر یادآوری غیرقطعکننده در کنار تایمر قطعکننده — non-interrupting reminder next to an interrupting deadline.
flowchart LR
U["User task: manager review"] --> D["Decision recorded"]
U -. "non-interrupting timer PT24H" .-> R["Send reminder"]
R -. "loop back" .-> U
U == "interrupting timer P3D" ==> ESC["Escalate to director"]
اگر برای یادآوری از تایمر interrupting استفاده کنی، user task از بین میرود و کاربر کارش را از دست میدهد. برعکس، برای مهلت واقعی باید interrupting باشد وگرنه task تا ابد باز میماند و صف کار میترکد. این دقیقاً همان سؤالی است که در مصاحبه با یک مثال میپرسند.
۲.۷ Subprocess و Call Activity
- Embedded subprocess: چند المان را در یک مستطیل جمع میکند. ارزش واقعیاش زیبایی نیست؛ این است که میتوانی یک boundary event روی کل گروه بگذاری («اگر در طول این پنج مرحله مشتری انصراف داد، همه لغو») و scope متغیرها را محدود کنی.
- Event subprocess: حاشیهٔ نقطهچین، بدون sequence flow ورودی؛ با یک start event از نوع message/timer/error/escalation فعال میشود. برای «هر وقت این اتفاق افتاد، این کار را بکن» بدون شلوغکردن مسیر اصلی.
- Call activity: فرایند دیگری را با
processIdصدا میزند و منتظر پایانش میماند — ابزار اصلی استفادهٔ مجدد. (نکته: transaction subprocess در Camunda 8 برای اجرا پشتیبانی نمیشود؛ همان رفتار را با subprocess معمولی + compensation بساز.)
انتخاب ساده است: اگر آن تکه فقط داخل همین فرایند معنا دارد → embedded؛ اگر مستقل معنا دارد یا مالکش تیم دیگری است → call activity، با این هزینه که یک نمونهٔ فرایند و تاریخچهٔ جداگانه میسازد.
۲.۸ Multi-instance — یک فعالیت، چند بار
سه خط عمودی (موازی) یا افقی (ترتیبی) زیر یک task یعنی همان فعالیت برای هر عضو یک مجموعه یک بار اجرا میشود: «هر سه عضو هیئتمدیره همزمان رأی بدهند» یا «سه سطح تأیید بهترتیب». با completion condition میتوانی زودتر تمامش کنی («دو رأی مثبت کافی است»). در Camunda 8 با FEEL تنظیم میشود: inputCollection = =approvers، inputElement = approver، و نتایج در outputCollection.
یک parallel multi-instance روی لیستی با ۵۰ هزار عضو یعنی ۵۰ هزار token و ۵۰ هزار job، و در Camunda 7 یک تراکنش عظیم با OptimisticLockingException پشت سر هم. اگر مجموعه بزرگ است: batch کن (هر instance صد آیتم)، یا کار را به یک worker بده که خودش حلقه بزند، یا sequential استفاده کن. multi-instance برای دَهها آیتم است، نه دهها هزار.
۳. مدل اجرایی موتور — داخل جعبه چه خبر است؟
Process definition همان فایل BPMN دیپلویشده است (مثل یک class) و process instance یک اجرای مشخص از آن برای «سفارش ۱۲۳۴» (مثل یک object). هر definition یک key پایدار دارد (مثل order-fulfillment) و یک version که با هر deploy زیاد میشود. نمونههای قدیمی روی نسخهٔ خودشان میمانند — این ویژگی است، نه باگ؛ به همین دلیل بعد از یک سال ممکن است ۹ نسخه همزمان زنده باشند.
key را هرگز عوض نکن؛ عوضکردنش یعنی یک فرایند کاملاً جدید و شکستن همهٔ correlationها و گزارشها. برای تغییرات ناسازگار یا نسخهٔ جدید بساز و بگذار نمونههای قدیمی بهمرور تمام شوند (drain)، یا آگاهانه migration اجرا کن. و از روز اول شناسههای المانها را از Activity_1a2b3c به نامهای معنادار مثل Task_CheckCredit تغییر بده — روزی که بخواهی migration بنویسی، همین یک کار ساعتها وقتت را نجات میدهد.
Token یک نشانگر خیالی است که میگوید «فرایند الان اینجاست» و همهچیز با آن توضیح داده میشود: start event یک token میسازد؛ parallel gateway یکی را به N تبدیل میکند و join دوباره N را به یک؛ end event یک token را مصرف میکند و وقتی آخرین token مصرف شد نمونه تمام است؛ terminate end event همهٔ tokenهای آن scope را یکجا نابود میکند. در Camunda 7 هر token یک ردیف در ACT_RU_EXECUTION است؛ در Camunda 8 حالت در state داخلی موتور است ولی مدل ذهنی همان است.
هر نمونه یک نقشهٔ name -> value از متغیرها دارد و متغیرها scope دارند: متغیری که داخل یک subprocess ساخته شود با پایان آن از بین میرود، مگر صریحاً روی scope والد نوشته شود.
هر بار که فرایند به یک wait state میرسد متغیرها serialize و ذخیره میشوند. یک JSON دو مگابایتی (مثلاً کل پاسخ یک API) در هر نقطهٔ ذخیره نوشته میشود و در history هم کپی میشود؛ در Camunda 8 محدودیت اندازهٔ رکورد حتی سختگیرانهتر است. قاعدهٔ سنیور: در متغیر فقط شناسه و تصمیم بگذار، نه payload. customerId, creditScore, approved بله؛ fullCustomerProfileJson نه.
Job یعنی «کاری که موتور باید بعداً و غیرهمزمان انجام دهد»: یک تایمر که باید بسوزد، یک service task که async علامت خورده، یک پیام که باید پرتاب شود. Job executor یک thread pool است که این کارها را برمیدارد: (۱) acquisition — jobهای سررسیدشده از دیتابیس query میشوند؛ (۲) locking — با نوشتن LOCK_OWNER_ و LOCK_EXP_TIME_ و کنترل همزمانی خوشبینانه قفل میشوند تا در کلاستر دو node یک job را برندارند؛ (۳) execution — به thread poolها سپرده میشوند؛ (۴) failure — با exception، retries یکی کم میشود و job دوباره صف میشود؛ (۵) incident — وقتی retries صفر شد یک incident ساخته میشود و job تا دخالت انسان اجرا نمیشود.
نمودار: چرخهٔ عمر یک job از ساخت تا incident — job lifecycle from creation to incident.
stateDiagram-v2
[*] --> Created
Created --> Acquired: due date reached
Acquired --> Executing: locked by worker
Executing --> Done: success
Executing --> Failed: exception
Failed --> Created: retries > 0
Failed --> Incident: retries = 0
Incident --> Created: retries reset by operator
Done --> [*]
سیستم سالم صفر incident ندارد؛ سیستم سالم incidentهایش را میبیند و در SLA مشخصی رسیدگی میکند. اولین کاری که موقع بردن Camunda به تولید باید بکنی این است: تعداد incidentهای باز را بهعنوان متریک به مانیتورینگ ببر و رویش alert بگذار (به observability نگاه کن). یک صف incident که کسی نمیبیندش، دقیقاً همان cron خاموشی است که از آن فرار کردی.
در کنار جدولهای runtime، جدولهای history هم هستند: کدام activity کِی شروع و تمام شد، متغیر چه بود و چه شد، چه کسی task را claim کرد. Camunda 7 چهار سطح دارد — none، activity، audit (پیشفرض) و full — و full هر بروزرسانی متغیر را هم ثبت میکند و سریعترین راه برای ترکاندن دیتابیس است.
از Camunda 7.20 موتور تو را مجبور میکند برای هر definition یک history time to live تعریف کنی (در مدل با camunda:historyTimeToLive="P30D"، یا یک پیشفرض در تنظیمات موتور، یا صراحتاً غیرفعالکردن این بررسی). دلیلش تجربهٔ سالها پشتیبانی است: جدولهای ACT_HI_* بدون cleanup به دهها گیگابایت میرسند و بعد حتی deploy هم کند میشود. history cleanup را با batch window شبانه فعال کن.
۴. Camunda 7 در برابر Camunda 8/Zeebe — صادقانه
این بخش را دقیق بخوان، چون در مصاحبه دقیقاً همینجا معلوم میشود کسی واقعاً کار کرده یا فقط اسم شنیده.
Camunda 7 یک موتور تعبیهشده (embedded engine) است: کتابخانهای Java که داخل همان اپلیکیشن Spring Boot تو اجرا میشود و حالتش را در یک دیتابیس رابطهای نگه میدارد — همان DataSource و همان تراکنش را با کد تو share میکند.
Camunda 8 معماری متفاوتی دارد: موتور Zeebe یک بروکر توزیعشده است که مثل Kafka روی partitionهای replicateشده کار میکند، حالت را در یک log و RocksDB محلی نگه میدارد و دیتابیس رابطهای برای اجرا لازم ندارد. داده برای گزارش و مانیتورینگ از طریق exporter به یک secondary storage (Elasticsearch، OpenSearch یا از 8.8 به بعد یک RDBMS) میرود و Operate و Tasklist از آنجا میخوانند. اپلیکیشن تو کلاینت است و با gRPC/REST حرف میزند.
نمودار: دو معماری کنار هم — embedded engine vs distributed broker.
flowchart TB
subgraph C7["Camunda 7 - embedded"]
APP["Spring Boot app<br/>engine as a library"] --> DB[("Relational DB<br/>ACT_RU_* / ACT_HI_*")]
APP --- COCK["Cockpit / Tasklist webapps"]
end
subgraph C8["Camunda 8 - orchestration cluster"]
W["Your Spring Boot app<br/>job workers - client only"] -- "gRPC / REST" --> Z["Zeebe brokers<br/>partitions + replicas"]
Z -- "exporter" --> SS[("Secondary storage<br/>ES / OpenSearch / RDBMS")]
SS --> OP["Operate / Tasklist"]
end
نسخهٔ 7.24 آخرین انتشار ویژگیدار Camunda 7 است و بهعنوان LTS پشتیبانی میشود؛ خط Community Edition آن ادامه پیدا نمیکند و ادامهٔ رسمی محصول روی خط Camunda 8 است. در Camunda 8 از نسخهٔ 8.8 معماری سادهتر شد: Zeebe، Operate، Tasklist و Identity در یک اپلیکیشن به نام Orchestration Cluster یکی شدند و کلاینت Java از zeebe-client-java به camunda-client-java با کلاس CamundaClient مهاجرت کرد. پیش از شروع پروژه ماتریس پشتیبانی رسمی را چک کن.
Camunda 7 سالها یک هستهٔ Apache-2.0 داشت با افزونههای تجاری. اجزای Camunda 8 Self-Managed تحت Camunda License 1.0 منتشر میشوند: سورس در دسترس است و استفاده برای توسعه و تست آزاد است، اما استفادهٔ تولیدی نیاز به لایسنس تجاری دارد. این «open source به معنای OSI» نیست. اگر معماریای طراحی کنی که این را در نظر نگرفته باشد، شش ماه بعد یک مکالمهٔ ناخوشایند با واحد حقوقی خواهی داشت. برای پروژههای کاملاً باز، forkهای جامعهمحورِ Camunda 7 یا موتورهایی مثل Flowable را هم بررسی کن.
| معیار | Camunda 7 | Camunda 8 / Zeebe |
|---|---|---|
| مدل استقرار | کتابخانه داخل اپلیکیشن | کلاستر مستقل؛ اپ تو کلاینت است |
| ذخیرهسازی حالت | دیتابیس رابطهای | log + RocksDB روی بروکر |
| گزارش/مانیتورینگ | همان RDBMS (ACT_HI_*) |
secondary storage از طریق exporter |
| تراکنش مشترک با کد تو | بله | خیر |
| مقیاسپذیری | تا سقف دیتابیس؛ عمودی | افقی با partition |
| اجرای کد Java | JavaDelegate داخل JVM + external task |
فقط job worker از بیرون |
| زبان expression | JUEL (${...}) |
FEEL (=...) |
| رشتهٔ اتصال | REST /engine-rest |
gRPC :26500 و REST :8080 |
| مناسب وقتی | مونولیت Java، تراکنش مشترک، تیم کوچک | حجم بالا، چندزبانه، cloud-native |
| ریسک اصلی | دیتابیس گلوگاه میشود | پیچیدگی عملیاتی + لایسنس |
برای یک توسعهدهندهٔ Java سه چیز عملاً عوض میشود و هر سه دردناکاند: تراکنش مشترک از بین میرود (در Camunda 7 میشد در یک JavaDelegate هم دیتابیس خودت را بنویسی و هم متغیر فرایند را و هر دو با هم commit شوند؛ حالا باید idempotency بنویسی و سراغ الگوهایی مثل outbox بروی — به ms-data نگاه کن)؛ کوئری مستقیم به جدولهای موتور تمام میشود و هر گزارشی باید به API تبدیل شود؛ و مدل خطا عوض میشود، از exception در همان تراکنش به fail کردن job با retry و backoff صریح.
۵. یکپارچهسازی با Java — Camunda 7
۵.۱ تعبیه در Spring Boot
<dependency>
<groupId>org.camunda.bpm.springboot</groupId>
<artifactId>camunda-bpm-spring-boot-starter</artifactId>
<version>7.24.0</version>
</dependency>
<!-- برای Cockpit/Tasklist آرتیفکت ...-starter-webapp و برای REST آرتیفکت ...-starter-rest را اضافه کن -->
هر فایل .bpmn یا .dmn در src/main/resources هنگام استارتاپ خودکار deploy میشود.
camunda:
bpm:
database:
schema-update: false # در تولید false؛ اسکریپتهای رسمی را با Flyway اجرا کن
history-level: audit
job-execution:
enabled: true
core-pool-size: 5
max-pool-size: 20
queue-capacity: 10
lock-time-in-millis: 300000
max-jobs-per-acquisition: 3
schema-update: true فقط برای توسعه است: در یک استقرار rolling، دو نسخهٔ اپ همزمان سعی میکنند schema را عوض کنند. در تولید اسکریپتهای SQL رسمی نسخهبهنسخه را با ابزار مهاجرت خودت اجرا کن — همان انضباطی که در spring-data-tx و cicd-pipelines داشتیم.
۵.۲ Service Task: سه راه برای وصلکردن کد
راه اول — JavaDelegate (کلاسیک، داخل همان JVM):
import org.camunda.bpm.engine.delegate.DelegateExecution;
import org.camunda.bpm.engine.delegate.JavaDelegate;
import org.camunda.bpm.engine.delegate.BpmnError;
@Component("checkCreditDelegate") // نام bean در EL استفاده میشود
public class CheckCreditDelegate implements JavaDelegate {
private final CreditClient creditClient;
public CheckCreditDelegate(CreditClient creditClient) { this.creditClient = creditClient; }
@Override
public void execute(DelegateExecution execution) {
String customerId = (String) execution.getVariable("customerId");
CreditResult result = creditClient.score(customerId);
if (result.score() < 500) {
// خطای کسبوکاری: با error boundary event گرفته میشود
throw new BpmnError("CREDIT_DENIED", "score=" + result.score());
}
execution.setVariable("creditScore", result.score());
}
}
در XML با camunda:delegateExpression="${checkCreditDelegate}" به task وصل میشود، معمولاً بههمراه camunda:asyncBefore="true" و camunda:retryTimeCycle="R5/PT30S".
راه دوم — expression مستقیم روی یک bean، بدون کلاس delegate: camunda:expression="${notificationService.notifyCustomer(execution.getVariable('customerId'))}" بههمراه camunda:resultVariable="notificationId" برای ذخیرهٔ مقدار بازگشتی.
راه سوم — External Task (توصیهشده برای تولید):
<bpmn:serviceTask id="Task_CheckCredit" name="Check credit"
camunda:type="external"
camunda:topic="credit-check" />
۵.۳ چرا external task بهتر مقیاس میگیرد؟
در حالت JavaDelegate کد تو داخل تراکنش موتور اجرا میشود: یک thread از job executor گرفته میشود و تا پایان فراخوانی HTTP آزاد نمیشود؛ اگر آن سرویس ۳۰ ثانیه طول بکشد، thread pool موتور خشک میشود و کل فرایندها میایستند. علاوه بر آن، کد و موتور باید در یک JVM و یک deploy باشند.
External task مدل را برعکس میکند: موتور فقط میگوید «کاری با موضوع credit-check هست» و worker از بیرون آن را pull میکند، قفل موقت میگیرد، انجام میدهد و نتیجه را برمیگرداند. مزیتها: جداسازی استقرار (worker میتواند سرویس دیگری با زبان دیگر باشد و مستقل مقیاس بگیرد)، backpressure طبیعی (اگر worker کند است jobها در صف میمانند، موتور نمیترکد)، fault isolation (کرش worker موتور را زمین نمیزند؛ lock منقضی میشود و کار دوباره در دسترس قرار میگیرد) و امکان تنظیم lockDuration برای کارهای طولانی.
نمودار: چرخهٔ fetch-and-lock در external task — external task fetch, lock, complete cycle.
sequenceDiagram
participant W as External worker
participant E as Camunda 7 engine
W->>E: POST /external-task/fetchAndLock (topic, lockDuration)
E-->>W: list of locked tasks + variables
W->>W: call downstream system
alt success
W->>E: POST /external-task/{id}/complete (variables)
else technical failure
W->>E: POST /external-task/{id}/failure (retries, retryTimeout)
else business error
W->>E: POST /external-task/{id}/bpmnError (errorCode)
end
با Spring Boot starter رسمی (org.camunda.bpm.springboot:camunda-bpm-spring-boot-starter-external-task-client):
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
import org.camunda.bpm.client.task.*;
@Component
@ExternalTaskSubscription(topicName = "credit-check", lockDuration = 20000)
public class CreditCheckHandler implements ExternalTaskHandler {
private final CreditClient creditClient;
public CreditCheckHandler(CreditClient creditClient) { this.creditClient = creditClient; }
@Override
public void execute(ExternalTask task, ExternalTaskService service) {
String customerId = task.getVariable("customerId");
try {
CreditResult r = creditClient.score(customerId);
if (r.score() < 500) {
service.handleBpmnError(task, "CREDIT_DENIED", "score too low");
return;
}
service.complete(task, Map.of("creditScore", r.score()));
} catch (TransientException e) {
int retriesLeft = task.getRetries() == null ? 3 : task.getRetries() - 1;
long backoffMs = (long) Math.pow(2, 3 - retriesLeft) * 10_000L; // 10s, 20s, 40s
service.handleFailure(task, e.getMessage(), stackTraceOf(e), retriesLeft, backoffMs);
}
}
}
camunda:
bpm:
client:
base-url: http://camunda:8080/engine-rest
worker-id: credit-worker-1
max-tasks: 10
lock-duration: 20000
async-response-timeout: 20000 # long polling
async-response-timeout یعنی long polling: worker یک درخواست میفرستد و سرور تا رسیدن کار یا انقضای این مهلت آن را باز نگه میدارد. بدون آن، workerها هر چند صد میلیثانیه یک بار polling میکنند و روی ۲۰ worker و ۵ topic دیتابیس موتور را با کوئریهای بیفایده اشباع میکنند — اولین چیزی که در تیونینگ Camunda 7 باید چک کنی.
اگر lockDuration کوتاهتر از زمان واقعی کار باشد، قفل وسط کار منقضی میشود، worker دوم همان task را برمیدارد و کار دو بار انجام میشود (مثلاً دو بار پول منتقل میشود). اگر خیلی بلند باشد، بعد از کرش worker کار ساعتها بلوکه میماند. راهحل درست: lockDuration کمی بیشتر از P99 زمان کار، بهعلاوهٔ idempotency در سمت سرویس مقصد، بهعلاوهٔ تمدید قفل (extendLock) برای کارهای طولانی.
۵.۴ مرزهای تراکنش و async continuation — مهمترین بخش Camunda 7
موتور Camunda 7 در یک تراکنش، از یک wait state تا wait state بعدی پیش میرود. wait state جایی است که موتور حالت را ذخیره میکند و کنترل را برمیگرداند: user task، receive task، timer، event-based gateway، external task.
یعنی اگر بین دو user task ده service task باشد، هر ده در همان تراکنشِ فراخوانی startProcessInstance اجرا میشوند و اگر دهمی خطا بدهد همهٔ ده rollback میشوند — ولی APIهای خارجی که صدا زده شدهاند برنمیگردند و ناسازگاری داری. راهحل asynchronous continuation است: با camunda:asyncBefore="true" یا camunda:asyncAfter="true" به موتور میگویی «اینجا تراکنش را ببند، یک job بساز و بقیه را job executor بعداً انجام دهد».
نمودار: مرزهای تراکنش با و بدون async continuation — transaction boundaries with and without async.
flowchart LR
subgraph TX1["One transaction - risky"]
S1(("Start")) --> A1["Charge card"] --> A2["Reserve stock"] --> A3["Send email"] --> U1["User task"]
end
subgraph TX2["Split with asyncBefore"]
S2(("Start")) --> B1["Charge card"]
B1 -.->|"commit / job"| B2["Reserve stock"]
B2 -.->|"commit / job"| B3["Send email"]
B3 --> U2["User task"]
end
قواعد عملی: بعد از هر فراخوانی خارجیِ غیرقابل rollback یک asyncAfter بگذار؛ بعد از هر parallel gateway روی شاخهها asyncBefore بگذار وگرنه هر دو شاخه در یک thread و یک تراکنش پشت سر هم اجرا میشوند و موازیسازی واقعی نداری؛ و برای فرایندهایی که با یک HTTP request شروع میشوند روی اولین activity asyncBefore بگذار تا کاربر سریع پاسخ بگیرد.
jobها بهصورت پیشفرض exclusive هستند: دو job از یک نمونه همزمان اجرا نمیشوند، چون هر دو روی همان ردیفهای execution مینویسند و برخورد نسخه (REV_) پیش میآید. اگر camunda:exclusive="false" بگذاری تا «سریعتر» شود، به OptimisticLockingException پشت سر هم میرسی. موتور خودش این exception را بدون کمکردن retries دوباره تلاش میکند، ولی توان مفیدت را میسوزاند. exclusive را دست نزن مگر واقعاً بدانی چه میکنی.
۵.۵ پیام، correlation، تایمر و retry
Correlation یعنی «این پیامی که از بیرون آمد به کدام نمونه تعلق دارد؟»:
runtimeService.createMessageCorrelation("PaymentConfirmed")
.processInstanceBusinessKey(orderId) // یا .processInstanceVariableEquals("orderId", orderId)
.setVariable("paidAt", Instant.now().toString())
.correlateWithResult();
اگر معیار correlation به بیش از یک نمونهٔ فعال بخورد، Camunda 7 خطای MismatchingMessageCorrelationException میدهد؛ اگر به هیچکدام نخورد هم همینطور. بدترین حالت وقتی است که پیام زودتر از رسیدن فرایند به نقطهٔ انتظار برسد — که در سیستمهای event-driven کاملاً عادی است. راهحل: یا پیام را در یک صف با retry نگه دار (به rabbitmq-amqp و messaging نگاه کن)، یا در مدل یک event-based gateway بگذار که پیام را پیش از کارهای طولانی بگیرد. همیشه یک business key یکتا و پایدار انتخاب کن.
تایمرها با ISO-8601 تعریف میشوند: timeDuration (مثل PT30M یا P3D)، timeDate (یک لحظهٔ مشخص مثل 2026-09-01T09:00:00Z) و timeCycle (تکرارشونده مثل R3/PT1H یا یک عبارت cron). Retry با backoff روی هر activity اعلانی تنظیم میشود و میتوانی چند بازه بدهی:
<bpmn:serviceTask id="Task_CallProvider" camunda:type="external" camunda:topic="provider"
camunda:asyncBefore="true">
<bpmn:extensionElements>
<camunda:failedJobRetryTimeCycle>R3/PT10S,R3/PT5M,R2/PT1H</camunda:failedJobRetryTimeCycle>
</bpmn:extensionElements>
</bpmn:serviceTask>
یعنی سه بار با فاصلهٔ ۱۰ ثانیه، بعد سه بار با فاصلهٔ ۵ دقیقه، بعد دو بار با فاصلهٔ یک ساعت — و بعد incident. همان backoff نمایی که در resilience ساختیم، منتها اعلانی و بدون یک خط کد.
۵.۶ تست فرایند
با org.camunda.bpm.assert:camunda-bpm-assert فرایند را مثل یک ماشین حالت تست میکنی:
import static org.camunda.bpm.engine.test.assertions.bpmn.BpmnAwareTests.*;
@Test
void shouldRejectWhenCreditDenied() {
when(creditClient.score(any())).thenReturn(new CreditResult(300));
ProcessInstance pi = runtimeService.startProcessInstanceByKey(
"order-fulfillment", Map.of("customerId", "C-1", "amount", 5000));
assertThat(pi).isWaitingAt("Task_CheckCredit");
complete(externalTask()); // شبیهسازی worker
assertThat(pi).hasPassed("EndEvent_Rejected").isEnded();
}
برای هر مسیر تصمیم یک تست بنویس (تأیید، رد، timeout، انصراف) و خودِ delegate/worker را جدا با unit test پوشش بده. تستی که کل مسیر خوشبخت را با سرویس واقعی اجرا میکند هم کند است هم شکننده — هرم تست در BPMN هم برقرار است (به testing نگاه کن).
۶. یکپارچهسازی با Java — Camunda 8
در Camunda 8 اپلیکیشن تو کلاینت است، نه میزبان موتور. کلاینت خالص io.camunda:camunda-client-java با کلاس CamundaClient است:
try (CamundaClient client = CamundaClient.newClientBuilder()
.grpcAddress(URI.create("http://localhost:26500"))
.restAddress(URI.create("http://localhost:8080"))
.build()) {
client.newDeployResourceCommand().addResourceFromClasspath("order-fulfillment.bpmn").execute();
client.newCreateInstanceCommand()
.bpmnProcessId("order-fulfillment").latestVersion()
.variables(Map.of("orderId", "O-1001", "amount", 12_500))
.execute();
}
با Spring Boot تمیزتر است:
<dependency>
<groupId>io.camunda</groupId>
<artifactId>camunda-spring-boot-starter</artifactId>
<version>8.8.9</version>
</dependency>
camunda:
client:
mode: self-managed
auth:
method: none
grpc-address: http://localhost:26500
rest-address: http://localhost:8080
worker:
defaults:
max-jobs-active: 32
timeout: PT1M
retry-backoff: PT10S
deployment:
enabled: true
import io.camunda.spring.client.annotation.JobWorker;
import io.camunda.spring.client.annotation.Variable;
import io.camunda.client.api.response.ActivatedJob;
import io.camunda.client.api.worker.JobClient;
@Component
public class CreditWorker {
// تزریق creditClient و providerClient از طریق constructor حذف شده است
// نوع job برابر نام متد است مگر صریحاً type بدهی؛ مقدار بازگشتی به متغیر فرایند تبدیل میشود
@JobWorker(type = "credit-check")
public Map<String, Object> checkCredit(@Variable String customerId) {
return Map.of("creditScore", creditClient.score(customerId).score());
}
// کنترل کامل روی خطا و backoff
@JobWorker(type = "call-provider", autoComplete = false)
public void callProvider(JobClient jobClient, ActivatedJob job) {
try {
providerClient.send(job.getVariablesAsMap());
jobClient.newCompleteCommand(job).execute();
} catch (BusinessRejection e) {
jobClient.newThrowErrorCommand(job) // به error boundary event میرسد
.errorCode("PROVIDER_REJECTED")
.errorMessage(e.getMessage())
.execute();
} catch (TransientException e) {
jobClient.newFailCommand(job)
.retries(job.getRetries() - 1)
.retryBackoff(Duration.ofSeconds(30))
.errorMessage(e.getMessage())
.execute();
}
}
}
سه پاسخ ممکن یک worker همیشه همین سه تاست: complete (انجام شد)، fail (خطای فنی با retry و backoff؛ اگر retries صفر شود incident ساخته میشود)، و throwError (خطای کسبوکاری که در سطح BPMN مدیریت میشود).
اگر worker کارش را انجام دهد و درست قبل از complete کرش کند، timeout منقضی میشود و job دوباره به worker دیگری داده میشود — یعنی همان side effect دوباره اجرا میشود. در Camunda 7 با تراکنش مشترک میشد از این فرار کرد؛ اینجا نمیشود. هر service task باید idempotent باشد: از job.getKey() یا یک business key بهعنوان کلید یکتا در سیستم مقصد استفاده کن. این همان «exactly-once وجود ندارد» است که در messaging و distributed-systems-theory ساختیم.
Correlation پیام با نام پیام و correlationKey انجام میشود و پیامها تا timeToLive روی بروکر بافر میشوند — یعنی برخلاف Camunda 7، پیامی که زودتر از فرایند برسد لزوماً گم نمیشود:
client.newPublishMessageCommand()
.messageName("PaymentConfirmed")
.correlationKey("O-1001") // با متغیر correlation در مدل تطبیق داده میشود
.messageId("payment-evt-77") // برای deduplication
.timeToLive(Duration.ofMinutes(30))
.variables(Map.of("paidAt", "2026-08-13T10:00:00Z"))
.execute();
تست با کتابخانهٔ رسمی Camunda Process Test انجام میشود (io.camunda:camunda-process-test-spring برای Spring یا camunda-process-test-java برای کلاینت خالص): کلاس تست را با @CamundaSpringProcessTest علامت میزنی و با CamundaAssert.assertThat(instance).isActive().hasActiveElements("Task_ManualApproval") وضعیت نمونه را میسنجی. این کتابخانه جانشین رسمی Zeebe Process Test قدیمی است.
برای اجرای محلی، توزیع Camunda 8 Run همهٔ اجزا را با یک فرمان بالا میآورد (./c8run start)؛ Operate روی http://localhost:8080/operate و gateway روی پورت 26500.
۷. DMN — تصمیم را از جریان جدا کن
DMN (Decision Model and Notation) استاندارد خواهرِ BPMN برای مدلکردن قواعد کسبوکار است و هستهٔ کاربردیاش decision table است: ستونهای ورودی، ستونهای خروجی، و هر سطر یک قاعده.
جدول تصمیم مثل جدول تعرفهٔ بیمه روی دیوار دفتر است: «سن ۱۸ تا ۳۰ و ریسک پایین ← ضریب ۱٫۰». وقتی مدیر تعرفه را عوض میکند، دیوار عوض میشود، نه ساختمان. DMN همین را میدهد: تغییر قاعده بدون تغییر کد و بدون deploy مجدد اپلیکیشن.
Hit policy میگوید وقتی چند سطر همزمان برقرار شدند چه شود:
| Hit policy | نماد | رفتار | کاربرد |
|---|---|---|---|
| Unique | U | فقط یک سطر باید برقرار باشد وگرنه خطا | جدولهای قطعی — پیشفرض امن |
| First | F | اولین سطر برقرار برنده است | قواعد اولویتدار با ترتیب معنادار |
| Any | A | چند سطر مجازند اما همه باید یک خروجی بدهند | جدولهای افزونهدار |
| Priority | P | خروجی با بالاترین اولویت | سطحبندی ریسک |
| Collect | C | همهٔ نتایج بهصورت لیست (با گزینههای تجمیع sum/min/max/count) | «کدام تخفیفها اعمال میشوند» |
| Rule Order | R | همه، به ترتیب سطرها | فهرست اقدامات |
در Camunda 7 عبارتهای جدول میتوانند JUEL یا FEEL باشند؛ در Camunda 8 فقط FEEL. یک عبارت ورودی FEEL شبیه این است: [18..30]، >= 1000، not("VIP")، "GOLD","SILVER".
در Camunda 7 میتوانی جدول را مستقیماً با decisionService.evaluateDecisionTableByKey("insurance-rate").variables(...).evaluate() صدا بزنی، یا در مدل با یک business rule task به آن وصل شوی:
<bpmn:businessRuleTask id="Task_Rate" name="Determine rate"
camunda:decisionRef="insurance-rate"
camunda:resultVariable="rateResult"
camunda:mapDecisionResult="singleEntry" />
در Camunda 8 همان task با extension مخصوص Zeebe به یک decision وصل میشود (decisionId و resultVariable) و فایل .dmn مثل فایل BPMN با همان newDeployResourceCommand دیپلوی میشود.
قاعدهٔ سنیور: DMN برای تصمیم است، نه برای جریان. اگر جدول شروع کرد به برگرداندن «نام مرحلهٔ بعدی»، فرایند را در جدول پنهان کردهای و مدل BPMN دروغ میگوید. جدول باید یک مقدار کسبوکاری برگرداند (نرخ، سطح ریسک، گروه تأییدکننده) و gateway بر اساس آن تصمیم بگیرد.
۸. Orchestration یا Choreography؟ BPMN یا Saga؟
- Orchestration: یک مؤلفهٔ مرکزی میداند مرحلهٔ بعدی چیست و به سرویسها دستور میدهد — مثل رهبر ارکستر.
- Choreography: مرکزی وجود ندارد؛ هر سرویس یک event منتشر میکند و بقیه واکنش نشان میدهند — مثل رقصندههایی که موسیقی را میشنوند.
نمودار: دستور مرکزی در برابر واکنش به رویداد — orchestration versus choreography.
flowchart TB
subgraph O["Orchestration"]
ENG["Workflow engine"] --> S1["Payment"]
ENG --> S2["Inventory"]
ENG --> S3["Shipping"]
end
subgraph CH["Choreography"]
P["Payment"] -- "PaymentCaptured" --> BUS[["Event bus"]]
BUS --> I["Inventory"]
I -- "StockReserved" --> BUS
BUS --> SH["Shipping"]
end
| معیار | Orchestration با موتور فرایند | Choreography با رویداد |
|---|---|---|
| جریان کجاست؟ | صریح، در یک مدل قابل مشاهده | ضمنی، پخششده در subscriptionها |
| افزودن مرحلهٔ جدید | تغییر مدل در یک جا | تغییر در چند سرویس + هماهنگی |
| «الان کجاییم؟» | فوری در Cockpit/Operate | نیازمند distributed tracing |
| جبران خطا | مدلشده و صریح | دستی در هر مصرفکننده |
| اتصال (coupling) | سرویسها به موتور وابستهاند | سرویسها به قرارداد event وابستهاند |
| مناسب برای | فرایند با task انسانی، SLA، حسابرسی | جریان ساده، پرحجم، fire-and-forget |
جواب «هر دو، در لایههای مختلف» است — و باید مرز را بکشی. بین bounded contextها از رویداد استفاده کن تا اتصال شل بماند. داخل یک context، وقتی یک فرایند چندمرحلهای با SLA و مسئولیت مشخص هست، orchestration کن. اشتباه رایج، choreography کردن یک فرایند ۱۲ مرحلهای است: شش ماه بعد هیچکس نمیداند سفارش کجا گیر کرده و «توزیع مسئولیت» به «هیچکس مسئول نیست» تبدیل میشود. عکسش هم بد است: یک موتور مرکزی که به هر تغییر کوچک هر سرویس گره خورده، همان ESB قدیمی است با اسم جدید.
BPMN رقیب saga نیست؛ یکی از راههای پیادهسازی آن است. الگوی saga (که در ms-data ساختیم) میگوید تراکنش توزیعشده را به تراکنشهای محلی بشکن و برای هرکدام یک عملیات جبرانی تعریف کن. نسخهٔ orchestration-based آن در BPMN دقیقاً همین است: compensation boundary event روی هر task و یک compensation throw event که موتور با آن، فقط فعالیتهای واقعاً اجراشده را به ترتیب معکوس جبران میکند.
نمودار: saga با compensation در BPMN — orchestrated saga with compensation handlers.
flowchart LR
A["Reserve stock"] --> B["Charge card"] --> C["Book courier"] --> D{"All ok?"}
D -- "no" --> X(("Throw compensation"))
A -. "compensate: release stock" .-> CA["Release stock"]
B -. "compensate: refund" .-> CB["Refund payment"]
C -. "compensate: cancel booking" .-> CC["Cancel courier"]
D -- "yes" --> E(("End"))
۹. عملیات — جایی که پروژهها میمیرند
۹.۱ مانیتورینگ و incident
ابزار عملیاتی در Camunda 7 Cockpit است و در Camunda 8 Operate؛ هر دو نمونهها را روی دیاگرام نشان میدهند، token فعال را هایلایت میکنند و incidentها را فهرست میکنند. کارهای رایج: retry مجدد یک job، تغییر یک متغیر، لغو نمونه، و modification یعنی جابهجاکردن دستی token به activity دیگر.
اما داشبورد کافی نیست. حداقل این پنج متریک باید به مانیتورینگ برود و alert داشته باشد: تعداد incidentهای باز (هر incident بالای ۱۵ دقیقه = هشدار)، عمر قدیمیترین job سررسیدشده (بیش از ۵ دقیقه یعنی job executor عقب افتاده)، تعداد نمونههای فعال هر definition (رشد یکنواخت یعنی نشت نمونههایی که تمام نمیشوند)، مدت اجرای فرایند در صدک ۹۵ (نقض SLA کسبوکاری) و اندازهٔ جدولهای history.
برای Camunda 7 این کوئری را میشود مستقیماً به مانیتورینگ وصل کرد — jobهایی که به incident رسیدهاند:
SELECT j.id_ AS job_id,
j.process_instance_id_,
j.retries_,
j.duedate_,
left(j.exception_msg_, 200) AS error
FROM act_ru_job j
WHERE j.retries_ = 0
ORDER BY j.duedate_ NULLS LAST
LIMIT 50;SELECT j.id_ AS job_id,
j.process_instance_id_,
j.retries_,
j.duedate_,
SUBSTR(j.exception_msg_, 1, 200) AS error
FROM act_ru_job j
WHERE j.retries_ = 0
ORDER BY j.duedate_ NULLS LAST
FETCH FIRST 50 ROWS ONLY;و برای دیدن اینکه چند نسخهٔ قدیمی هنوز زندهاند، همین کوئری را روی act_ru_execution با JOIN به act_re_procdef و WHERE e.parent_id_ IS NULL بنویس و بر اساس key_ و version_ گروهبندی کن.
خواندن جدولهای ACT_RU_* برای پایش سبک اشکالی ندارد، ولی گزارش تحلیلی روی همان دیتابیسی که موتور در آن مینویسد مستقیماً روی latency فرایندها اثر میگذارد. برای BI داده را به یک انبار جدا منتقل کن (به nosql-specialized نگاه کن). ضمناً schema داخلی موتور قرارداد عمومی نیست و بین نسخهها تغییر میکند؛ هرچه روی این جدولها بسازی بدهی مهاجرت است.
۹.۲ Migration نمونههای در حال اجرا
سه گزینه داری: drain (بگذاری تمام شوند — سادهترین و امنترین)، لغو و شروع مجدد (فقط اگر side effect ندارد)، یا migration.
// Camunda 7
MigrationPlan plan = runtimeService
.createMigrationPlan(sourceDefinitionId, targetDefinitionId)
.mapEqualActivities() // المانهای همشناسه خودکار نگاشت میشوند
.mapActivities("Task_OldName", "Task_NewName")
.updateEventTriggers() // تایمرها و subscriptionها بازسازی شوند
.build();
runtimeService.newMigration(plan)
.processInstanceQuery(runtimeService.createProcessInstanceQuery()
.processDefinitionId(sourceDefinitionId))
.executeAsync(); // بهصورت batch، نه یک تراکنش
معادلش در Camunda 8 یک فرمان تکخطی است: client.newMigrateProcessInstanceCommand(key).migrationPlan(targetProcessDefinitionKey).addMappingInstruction("Task_OldName", "Task_NewName").execute().
mapEqualActivities() فقط وقتی کار میکند که شناسهٔ المانها ثابت مانده باشد — به همین دلیل بود که گفتیم از روز اول شناسههای معنادار بگذار. و migration را همیشه اول روی کپی دیتابیس تولید تمرین کن؛ migration اشتباه نمونهها را در حالتهای غیرممکن رها میکند.
۹.۳ کارایی و رشد دیتابیس — برگهٔ تقلب
| علامت | علت محتمل | اقدام |
|---|---|---|
| jobها با تأخیر اجرا میشوند | pool کوچک یا acquisition کم | افزایش core-pool-size و max-jobs-per-acquisition |
OptimisticLockingException زیاد |
job غیرexclusive یا multi-instance بزرگ | برگرداندن exclusive، شکستن multi-instance |
| CPU دیتابیس بالا بدون بار کاربر | polling پرتکرار external task | فعالکردن long polling |
| کندی تدریجی همهچیز | رشد ACT_HI_* |
history cleanup + کاهش history level |
| نمونههایی که هرگز تمام نمیشوند | نبود مسیر پایان یا timeout | افزودن boundary timer و مسیر لغو |
| افت throughput در Zeebe | partition کم یا exporter کند | افزایش partition، تیونینگ secondary storage |
# Camunda 7 — history cleanup شبانه
camunda:
bpm:
generic-properties:
properties:
historyCleanupBatchWindowStartTime: "02:00"
historyCleanupBatchWindowEndTime: "05:00"
historyCleanupDegreeOfParallelism: 2
historyTimeToLive: P30D
در Camunda 8 معادل این کار مدیریت secondary storage است: سیاست نگهداری روی ایندکسهای Elasticsearch/OpenSearch یا جدولهای RDBMS، بههمراه پایش اندازهٔ آنها. قاعده یکی است: هیچ دادهای برای همیشه نمیماند مگر اینکه صریحاً تصمیم گرفته باشی.
۱۰. سؤالهای مصاحبه
چون شش قابلیت را آماده میدهد که در حالت دستساز باید یکییکی و بد بازسازی شوند: پایداری حالت با مدل صریح، wait state بدون اشغال thread، تایمر و escalation، retry با backoff و تبدیل شکست دائمی به incident قابل مشاهده، صف کار انسانی، و تاریخچهٔ حسابرسی. مهمتر از همه، نسخهبندی و migration نمونههای در حال اجرا — که با ستون status عملاً حلنشدنی است. اضافه میکنم که این تصمیم رایگان نیست: اگر فرایند wait state ندارد، موتور فرایند فقط پیچیدگی اضافه میکند.
token یک نشانگر منطقی است که نشان میدهد اجرا در کدام نقطه از مدل است. start event یکی میسازد، parallel gateway آن را تقسیم میکند و join دوباره یکی میکند، end event مصرفش میکند و با مصرف آخرین token نمونه تمام میشود. با همین یک مفهوم میشود توضیح داد چرا AND-join منتظر میماند، چرا بستن parallel با exclusive باعث اجرای دوباره میشود، و چرا terminate کل شاخهها را میکشد. در Camunda 7 هر token یک ردیف در ACT_RU_EXECUTION است.
JavaDelegate داخل JVM موتور و داخل تراکنش موتور اجرا میشود: ساده است و میتواند با کد تو در یک تراکنش باشد، ولی thread موتور را اشغال میکند و استقرار را به موتور میچسباند. external task مدل pull است: worker از بیرون کار را fetch و lock میکند. برای هر چیزی که I/O خارجی دارد external task انتخاب میکنم، چون backpressure طبیعی دارد، thread pool موتور را نمیسوزاند، مستقل مقیاس میگیرد و کرش worker موتور را زمین نمیزند. delegate را فقط برای منطق سبک درونحافظهای نگه میدارم.
Camunda 7 از یک wait state تا wait state بعدی همهچیز را در یک تراکنش اجرا میکند. اگر بین دو wait state پنج service task باشد و پنجمی شکست بخورد، هر پنج rollback میشوند — ولی فراخوانیهای خارجیِ انجامشده برنمیگردند. با asyncBefore/asyncAfter یک مرز تراکنشی صریح میسازی: موتور تا آنجا commit میکند، یک job میسازد و بقیه را job executor بعداً اجرا میکند. کاربردها: بعد از هر side effect غیرقابلبازگشت، بعد از parallel gateway برای موازیسازی واقعی، و در ابتدای فرایند برای پاسخ سریع به کاربر.
incident رکوردی است که موتور میسازد وقتی یک job همهٔ retryهایش را مصرف کرده و دیگر خودکار اجرا نمیشود، یا وقتی خطایی مثل عدم تطابق correlation رخ داده. یعنی «این کار به دخالت انسان نیاز دارد»؛ بدون آن شکستها خاموش گم میشدند. در عمل: تعداد incidentهای باز را بهعنوان متریک به مانیتورینگ میبرم و alert میگذارم، در Cockpit/Operate علت را میبینم، ریشه را درست میکنم و بعد retry میزنم. سیستمی که incident دارد ولی کسی نمیبیندش، همان cron خاموشی است که از آن فرار کرده بودیم.
Camunda 7 موتوری تعبیهشده است که در همان JVM و روی همان دیتابیس رابطهای تو اجرا میشود؛ Camunda 8 یک کلاستر مستقل با موتور Zeebe است و اپلیکیشن تو فقط کلاینت آن است. سه پیامد عملی: تراکنش مشترک از بین میرود و باید idempotency بنویسی؛ کوئری مستقیم به جدولهای موتور جای خود را به API و secondary storage میدهد؛ و expression از JUEL به FEEL تغییر میکند. در عوض مقیاس افقی واقعی و استقرار چندزبانه میگیری. لایسنس را هم باید جدا بررسی کرد، چون اجزای Camunda 8 Self-Managed تحت لایسنس source-available منتشر میشوند و استفادهٔ تولیدی مجوز میخواهد.
تضمین نمیکنم — طراحی میکنم که اگر دو بار اجرا شد اشکالی نداشته باشد. در هر موتور توزیعشده تحویل «حداقل یک بار» است: اگر worker کار را انجام دهد و پیش از complete کرش کند، lock منقضی میشود و job دوباره تحویل داده میشود. راهحل idempotency است: یک کلید یکتای پایدار به سیستم مقصد بفرست و آنجا با unique constraint یا جدول dedup تکرار را رد کن. کمکی که موتور میکند این است که lockDuration/timeout را کمی بیشتر از P99 زمان کار بگذاری و برای کارهای طولانی lock را تمدید کنی.
بین bounded contextها choreography با رویداد، چون اتصال شل میماند و هر تیم مستقل حرکت میکند. داخل یک context، وقتی فرایند چندمرحلهای با SLA، task انسانی، جبران خطا و نیاز حسابرسی هست، orchestration، چون جریان صریح و قابل مشاهده میشود. علامت هشدار برای choreography بیشازحد این است که برای پاسخ به «سفارش کجا گیر کرده؟» باید لاگ پنج سرویس را کنار هم بگذاری. علامت هشدار برای orchestration بیشازحد این است که هر تغییر کوچک در هر سرویس نیازمند تغییر مدل مرکزی است — آن دیگر ESB است، نه orchestration.
اول میپرسم آیا لازم است. سه گزینه دارم: drain — نسخهٔ جدید را deploy کن و بگذار نمونههای قدیمی روی نسخهٔ خودشان تمام شوند (امنترین و در اکثر موارد کافی)؛ لغو و شروع مجدد — فقط وقتی هیچ side effect غیرقابلبازگشتی رخ نداده؛ migration — با migration plan و نگاشت المانها، بهصورت batch و ناهمگام. شرط لازم گزینهٔ سوم پایداری شناسهٔ المانهاست، و همیشه اول روی کپی دیتابیس تولید تمرین میکنم و تعداد کمی نمونه را canary میکنم.
وقتی قاعدهای تعریفشده اما پرتغییر است و صاحبش کسبوکار است: نرخ، سطح ریسک، شرایط واجد شرایط بودن، گروه تأییدکننده. جدول تصمیم با hit policy درست، قاعده را از کد جدا میکند و تغییرش دیگر deploy اپلیکیشن نمیخواهد. سوءاستفاده وقتی است که جدول بهجای یک مقدار کسبوکاری، مرحلهٔ بعدی فرایند را برگرداند؛ آنوقت جریان در جدول پنهان میشود و مدل BPMN دروغ میگوید. hit policy پیشفرض من Unique است چون هر تداخل قاعده را فوراً بهصورت خطا نشان میدهد بهجای اینکه بیسروصدا یک سطر را انتخاب کند.
موتور فرایند برای مسئلهٔ حالتِ بلندمدت ساخته شده: کاری که از یک request عمر بیشتری دارد، منتظر انسان و زمان و پیام میماند، باید retry و escalate شود و ردپای حسابرسی بگذارد. BPMN 2.0 زبان مشترک این کار است — بهویژه چهار gateway، تفاوت boundary event قطعکننده و غیرقطعکننده، compensation و multi-instance. مدل ذهنی درست token است و مدل عملیاتی، job و incident. Camunda 7 موتوری تعبیهشده روی دیتابیس رابطهای است با مزیت تراکنش مشترک و محدودیت مقیاس؛ Camunda 8/Zeebe کلاستری توزیعشده است که مقیاس افقی میدهد ولی تراکنش مشترک را میگیرد و idempotency را اجباری میکند. در سطح کد، external task و job worker تقریباً همیشه انتخاب بهتری از delegate درونفرایندی هستند و مرزهای تراکنش با async continuation تفاوت بین فرایند پایدار و شکننده را میسازند. در سطح معماری: بین contextها رویداد، داخل context orchestration، و BPMN را تمیزترین پیادهسازی saga با compensation ببین. و آنچه پروژهها را میکشد کد نیست، عملیات است: incidentهای دیدهنشده، متغیرهای غولپیکر، تاریخچهای که پاک نمیشود و migrationی که تمرین نشده.
Backend job posts list it next to Spring Boot and Kafka in one short line: "familiarity with Camunda / BPMN is a plus". Most candidates skim past it, assuming it is a drawing tool for managers. That line is exactly where a senior interviewer finds out whether you know the difference between "a short request" and "a business process that lives for three weeks".
This chapter starts from zero: why a workflow engine exists at all, then BPMN 2.0 symbol by symbol, then the inside of the engine, then real Java code, and finally the question a senior interview is really about: when do you orchestrate, and when do you choreograph?
Why a workflow engine exists and why hand-rolling one goes wrong → BPMN 2.0 symbol by symbol (pools/lanes, events, tasks, gateways, boundary events, subprocesses, multi-instance) → the engine's execution model (definition/instance, tokens, variables, job executor, incidents, history) → Camunda 7 versus Camunda 8/Zeebe, honestly → Java integration: embedded engine, JavaDelegate, external task workers, Zeebe job workers, deployment and versioning, correlation, timers and retries, transaction boundaries, testing → DMN decision tables → orchestration versus choreography, BPMN versus saga → operations: Cockpit/Operate, incidents, instance migration, performance and database growth.
1. Why does a workflow engine exist at all?
A customer orders; the waiter clips a paper ticket to the kitchen rail. It sits there for minutes — sometimes an hour: the cold station works it and moves it along, then the hot station, then quality check, then the waiter. If a cook calls in sick the ticket is not lost. If nobody picks it up for twenty minutes, the head chef notices. At the end of the night you can count how many orders stalled where.
That rail and those tickets are a workflow engine: the state of the work lives outside anyone's head, it is visible, and it survives whoever happens to be on shift.
1.1 What is a long-running process?
Most code you write is request-scoped: a request arrives, a few functions run, one transaction commits, and everything vanishes from memory. The state of the work is the stack.
"Issue an insurance policy" is not like that. Between step three and four a human has to approve — maybe tomorrow morning. If nobody approves within three days it must escalate. The call to the credit bureau may fail ten times and must be retried with backoff. If the customer withdraws, the work already done must be compensated. And six months later an auditor asks "who approved this, and when?".
That is a long-running, stateful business process: it outlives a request, it has state, it waits, and it must leave an audit trail.
Diagram: the lifetime of a request versus the lifetime of a process instance — عمر یک request در برابر عمر یک process instance.
flowchart LR
subgraph R["Request-scoped service"]
A1["HTTP in"] --> A2["validate"] --> A3["DB tx commit"] --> A4["HTTP out"]
end
subgraph P["Long-running process"]
B1["Start: application received"] --> B2["Automated credit check"]
B2 --> B3["Human approval - hours or days"]
B3 --> B4["Timer: 3 days -> escalate"]
B4 --> B5["Issue policy"]
B5 --> B6["End + audit trail"]
end
1.2 Why does hand-rolling it go wrong?
The path is always the same: a status column (NEW, APPROVED, …), a @Scheduled job picking up NEW rows every minute, then retry_count, next_retry_at, error_message, then escalated_at and a *_history table, then an admin page to see what is stuck. And on day five someone asks "if we change that condition, what happens to the instances mid-flight right now?" — and nobody has an answer.
It works in month one and becomes technical debt by month six:
- Logic is scattered — no single picture of the whole path; you read three services to learn what happens after
APPROVED. - Invalid states are reachable — nothing stops a jump from
NEWstraight toISSUED. - Hand-rolled concurrency — you end up writing
SELECT ... FOR UPDATE SKIP LOCKED, leases and heartbeats yourself. - The audit trail is incomplete —
updated_atsays when the last change happened, not which path the process took. - Version changes are a disaster — 40,000 in-flight instances follow the old logic; your new code has one version.
A workflow engine gives you exactly these as infrastructure.
1.3 What does an engine actually give you?
A workflow engine (Camunda, Flowable, Zeebe, Temporal…) ships with: an explicit executable model (one file that is both diagram and code), state persistence, wait states (standing still without holding a thread), timers, retries with backoff plus permanent failures turned into visible incidents, human tasks with queues and claiming, history and audit, and versioning and migration of running instances.
If your process finishes inside one request, has no human waits and no timers, and the path is three fixed steps, an engine is over-engineering: a new database or cluster, a new mental model, a new operational layer. Rule of thumb: no wait states, no workflow engine. If you have more than two of them, hand-rolling costs more than learning BPMN.
2. BPMN 2.0 symbol by symbol
BPMN stands for Business Process Model and Notation; version 2.0 is an OMG standard defining a graphical notation and an executable XML format at once. That is the key point: the .bpmn file your modeler produces is what the engine runs — the diagram is not documentation, it is the program. Read it like a metro map: stations (tasks), lines (sequence flows), junctions (gateways) and mid-journey events, with an imaginary train called a token moving along.
2.1 Pools and lanes
A pool is an independent participant: your organisation, the partner bank, the customer. Pools talk through message flows (dashed arrows), never sequence flows. A lane subdivides one pool: "clerk", "manager", "system".
The engine executes exactly one pool — yours; draw the counterparty as a black box. Lanes have no execution semantics but usually map to task assignment or candidate groups.
2.2 Events — start, end, and mid-flight
The circle's border gives the position: thin = start, double = intermediate (mid-path; it waits, i.e. catch, or fires, i.e. throw), thick = end of this branch. The icon inside gives the type:
| Icon | Type | Real use |
|---|---|---|
| empty | None | plain start/end — started via API |
| envelope | Message | something from outside: "payment confirmed" |
| clock | Timer | "wait 3 days", "every day at 02:00" |
| triangle | Signal | broadcast to every subscriber |
| lightning | Error | a business error, not a technical one |
| up arrow | Escalation | raise it to a higher level without cancelling |
| back arrow | Compensation | "undo what was already done" |
| filled circle with X | Terminate | end the whole instance right here |
BpmnError means "a business outcome that has an alternative path" — like "insufficient credit". A NullPointerException is not that; it is a technical failure and should become an incident that an operator sees, not something swallowed by a BPMN branch. Turning technical errors into BPMN errors sweeps bugs under the rug and fills the business-error path with noise.
2.3 Tasks — where the work happens
| Task type | Icon | Who does it | Example |
|---|---|---|---|
| User Task | person | a human, via Tasklist or your own UI | "review and approve the loan request" |
| Service Task | gear | code (a delegate or a worker) | "call the credit bureau" |
| Script Task | scroll | a script inside the engine | "compute the fee" |
| Send Task | filled envelope | send a message outward | "send confirmation SMS" |
| Receive Task | hollow envelope | wait for a message | "await payment confirmation" |
| Business Rule Task | table | evaluate a DMN decision | "determine the insurance rate" |
| Manual Task | hand | human work outside the system | "file the paper dossier" |
| Call Activity | thick border | invoke another process | "run the KYC process" |
Do not fall in love with script tasks in production: you lose versioning, tests and code review, runtime errors land inside the engine, and in Camunda 7 running Groovy/JS opens a real attack surface — whoever can deploy a model can execute code. The rule: logic in code, decisions in DMN, the model is flow only.
2.4 Gateways — decisions and branching
A gateway is a diamond and it does no work; it only routes tokens.
Exclusive (XOR), marked ✕: exactly one path; the first condition that evaluates to true wins. Always add a default flow, otherwise the engine throws when nothing matches.
Diagram: an exclusive decision on order amount — تصمیم انحصاری بر اساس مبلغ سفارش.
flowchart LR
S(("Start")) --> T1["Check order"]
T1 --> G{"amount > 10000?"}
G -- "yes" --> A["Manual approval"]
G -- "no (default)" --> B["Auto approve"]
A --> E(("End"))
B --> E
Parallel (AND), marked +: all branches activate (fork) and the closing gateway waits for all of them (join). "Parallel" means several active tokens, not necessarily several threads.
Inclusive (OR), marked ○: every branch whose condition is true. Its join has to work out how many tokens are still coming, which makes it the most expensive gateway.
Event-based gateway: the path is chosen by whichever event arrives first — the standard "race between a message and a timeout" pattern.
Diagram: a race between the payment message and a timeout — مسابقهٔ بین پیام پرداخت و timeout.
flowchart LR
T["Await payment"] --> EG{"Event-based gateway"}
EG --> M["Message: payment received"]
EG --> TI["Timer: PT30M"]
M --> OK["Ship order"]
TI --> CX["Cancel order"]
A mnemonic: XOR = "one of", AND = "all of", OR = "whichever qualifies", event-based = "whichever arrives first". In practice 90% of healthy models use only XOR and AND; a model full of inclusive gateways usually means unfinished analysis.
If you fork with a parallel gateway and merge with an exclusive one, the first token passes through and the second token passes through separately — so the rest of the process runs twice. This is one of the most common BPMN production bugs and a favourite interview question. The rule: every splitting gateway is merged by a gateway of the same type.
2.5 Sequence flows and conditions
Solid arrows are sequence flows and they define where tokens travel. Every outgoing flow of a gateway can carry a condition: in Camunda 7 written in JUEL (${amount > 10000}), in Camunda 8 in FEEL (=amount > 10000; the leading = means "this is an expression"). One flow may be the default: it carries no condition and is taken only when nothing else matched.
2.6 Boundary events — reacting to something mid-activity
A boundary event sits on the edge of a task or subprocess and reacts to something that happens while it runs. Interrupting (solid border) cancels the activity; non-interrupting (dashed border) leaves the activity untouched and spawns a new parallel token.
Real examples: an interrupting timer on "call the customer" (48 hours passed, close the case); a non-interrupting timer on "manager review" (remind every 24 hours, keep the task open); an error boundary on a service task (CREDIT_DENIED → rejection path); a message boundary ("cancel" arrived → tear everything down); a non-interrupting escalation on a subprocess (notify the manager, keep working); a compensation boundary registering the undo handler.
Diagram: a non-interrupting reminder next to an interrupting deadline — تایمر یادآوری غیرقطعکننده در کنار تایمر قطعکننده.
flowchart LR
U["User task: manager review"] --> D["Decision recorded"]
U -. "non-interrupting timer PT24H" .-> R["Send reminder"]
R -. "loop back" .-> U
U == "interrupting timer P3D" ==> ESC["Escalate to director"]
If you use an interrupting timer to send a reminder, the user task disappears and the user loses their work. Conversely, a real deadline must be interrupting or the task stays open forever and the work queue explodes. This is exactly the question an interviewer asks with a concrete example.
2.7 Subprocesses and call activities
- Embedded subprocess: groups elements into one rectangle. The value is not tidiness; it is that you can attach one boundary event to the whole group ("if the customer cancels during any of these five steps, cancel all of them") and scope variables.
- Event subprocess: dashed border, no incoming sequence flow; triggered by a message/timer/error/escalation start event. For "whenever this happens, do that" without cluttering the main path.
- Call activity: invokes another process by
processIdand waits for it — the main reuse mechanism. (Transaction subprocesses are not supported for execution in Camunda 8; build the same behaviour with a normal subprocess plus compensation.)
The choice is simple: if the fragment only makes sense inside this process → embedded; if it stands on its own or is owned by another team → call activity, at the cost of a separate process instance with a separate history.
2.8 Multi-instance — one activity, many times
Three vertical bars (parallel) or horizontal bars (sequential) under a task mean the activity runs once per element of a collection: "all three board members vote at once", or "three approval levels in order". A completion condition finishes early ("two approvals are enough"). In Camunda 8 it is configured with FEEL: inputCollection = =approvers, inputElement = approver, results in outputCollection.
A parallel multi-instance over a 50,000-element list means 50,000 tokens and 50,000 jobs, and in Camunda 7 one enormous transaction with OptimisticLockingException after OptimisticLockingException. If the collection is large: batch it (a hundred items per instance), push the loop into a worker, or go sequential. Multi-instance is for dozens of items, not tens of thousands.
3. The execution model — what is inside the box?
A process definition is the deployed BPMN file (like a class); a process instance is one execution of it (like an object). Every definition has a stable key (e.g. order-fulfillment) and a version incremented on each deployment. Old instances stay on their own version — a feature, not a bug, which is why after a year nine versions may be alive at once.
Never change the key; changing it means a brand new process and breaks every correlation and report. For incompatible changes either deploy a new version and let old instances drain, or run a deliberate migration. And from day one rename element ids from Activity_1a2b3c to meaningful ones like Task_CheckCredit — the day you have to write a migration, that single habit saves you hours.
A token is the imaginary marker saying "the process is here", and it explains everything: a start event creates one; a parallel gateway turns one into N and the join turns N back into one; an end event consumes one, and when the last one is gone the instance is finished; a terminate end event destroys all tokens in its scope. In Camunda 7 each token is a row in ACT_RU_EXECUTION; in Camunda 8 state lives in the engine's internal store, but the model is identical.
Each instance carries a name -> value map of variables, and variables have scope: a variable created inside a subprocess disappears when that subprocess ends, unless you explicitly write it to the parent scope.
Every time the process reaches a wait state, variables are serialised and persisted. A two-megabyte JSON (an entire API response, say) is written at every save point and copied into history; in Camunda 8 record-size limits are stricter still. The senior rule: identifiers and decisions in variables, never payloads. customerId, creditScore, approved — yes; fullCustomerProfileJson — no.
A job is "work the engine must do later, asynchronously": a timer that must fire, an async service task, a message to throw. The job executor is a thread pool that picks it up: (1) acquisition — due jobs are queried; (2) locking — locked by writing LOCK_OWNER_ and LOCK_EXP_TIME_ under optimistic locking, so two cluster nodes never grab the same job; (3) execution — handed to worker threads; (4) failure — on exception retries is decremented and the job re-queued; (5) incident — at zero retries an incident is created and the job stops until a human intervenes.
Diagram: the lifecycle of a job from creation to incident — چرخهٔ عمر یک job از ساخت تا incident.
stateDiagram-v2
[*] --> Created
Created --> Acquired: due date reached
Acquired --> Executing: locked by worker
Executing --> Done: success
Executing --> Failed: exception
Failed --> Created: retries > 0
Failed --> Incident: retries = 0
Incident --> Created: retries reset by operator
Done --> [*]
A healthy system does not have zero incidents; a healthy system sees its incidents and works them within a stated SLA. The first thing to do when taking Camunda to production: export the number of open incidents as a metric and alert on it (see observability). An incident queue nobody looks at is exactly the silent cron job you were running away from.
Alongside the runtime tables there are history tables: which activity started and finished when, what a variable became, who claimed a task. Camunda 7 offers four levels — none, activity, audit (default) and full — and full also records every variable update, the fastest way to blow up your database.
Since Camunda 7.20 the engine forces you to define a history time to live per definition (in the model via camunda:historyTimeToLive="P30D", or a global default in the engine configuration, or by explicitly disabling the check). The reason is years of painful support experience: without cleanup the ACT_HI_* tables reach tens of gigabytes and eventually even deployments get slow. Enable history cleanup with a nightly batch window.
4. Camunda 7 versus Camunda 8/Zeebe — honestly
Read this carefully: in an interview it shows whether someone has worked with it or only heard the name.
Camunda 7 is an embedded engine: a Java library that runs inside your own Spring Boot application and keeps its state in a relational database — sharing the same DataSource and the same transaction as your code.
Camunda 8 differs: the Zeebe engine is a distributed broker working on replicated partitions like Kafka, keeping state in a log plus a local RocksDB, and needing no relational database to execute. An exporter pushes reporting data into a secondary storage (Elasticsearch, OpenSearch, or from 8.8 an RDBMS) that Operate and Tasklist read. Your application is a client speaking gRPC/REST.
Diagram: the two architectures side by side — دو معماری کنار هم.
flowchart TB
subgraph C7["Camunda 7 - embedded"]
APP["Spring Boot app<br/>engine as a library"] --> DB[("Relational DB<br/>ACT_RU_* / ACT_HI_*")]
APP --- COCK["Cockpit / Tasklist webapps"]
end
subgraph C8["Camunda 8 - orchestration cluster"]
W["Your Spring Boot app<br/>job workers - client only"] -- "gRPC / REST" --> Z["Zeebe brokers<br/>partitions + replicas"]
Z -- "exporter" --> SS[("Secondary storage<br/>ES / OpenSearch / RDBMS")]
SS --> OP["Operate / Tasklist"]
end
7.24 is the final feature release of Camunda 7, supported as an LTS line; its Community Edition line is not continued and the official product line forward is Camunda 8. On the Camunda 8 side, 8.8 simplified the architecture: Zeebe, Operate, Tasklist and Identity merged into one application called the Orchestration Cluster, and the Java client moved from zeebe-client-java to camunda-client-java with the CamundaClient class. Check the official support matrix before starting a project.
Camunda 7 had an Apache-2.0 core for years with commercial add-ons. The components of Camunda 8 Self-Managed ship under the Camunda License 1.0: source is available and development and testing are free, but production use requires a commercial licence. This is not "open source" in the OSI sense, and an architecture that ignores it earns you an unpleasant conversation with legal six months later. For fully open projects, also evaluate community forks of Camunda 7 or engines such as Flowable.
| Criterion | Camunda 7 | Camunda 8 / Zeebe |
|---|---|---|
| Deployment model | library inside your app | separate cluster; your app is a client |
| State storage | relational database | log + RocksDB on the broker |
| Reporting/monitoring | the same RDBMS (ACT_HI_*) |
secondary storage via exporter |
| Shared transaction with your code | yes | no |
| Scalability | bounded by the database; vertical | horizontal via partitions |
| Running Java code | JavaDelegate in-JVM + external tasks |
job workers only, from outside |
| Expression language | JUEL (${...}) |
FEEL (=...) |
| Connection | REST /engine-rest |
gRPC :26500 and REST :8080 |
| Fits when | Java monolith, shared transaction, small team | high volume, polyglot, cloud-native |
| Main risk | the database becomes the bottleneck | operational complexity + licensing |
Three things really change for a Java developer, and all three hurt. The shared transaction disappears: a Camunda 7 JavaDelegate could write your database and a process variable and commit both together; now you write idempotency and reach for patterns like the outbox (see ms-data). Direct queries against engine tables end, so every report becomes an API call. And the error model changes, from an exception in the same transaction to explicitly failing a job with retries and backoff.
5. Java integration — Camunda 7
5.1 Embedding in Spring Boot
<dependency>
<groupId>org.camunda.bpm.springboot</groupId>
<artifactId>camunda-bpm-spring-boot-starter</artifactId>
<version>7.24.0</version>
</dependency>
<!-- add ...-starter-webapp for Cockpit/Tasklist, ...-starter-rest for the REST API -->
Every .bpmn or .dmn file under src/main/resources is auto-deployed at startup.
camunda:
bpm:
database:
schema-update: false # false in production; run the official scripts via Flyway
history-level: audit
job-execution:
enabled: true
core-pool-size: 5
max-pool-size: 20
queue-capacity: 10
lock-time-in-millis: 300000
max-jobs-per-acquisition: 3
schema-update: true is development-only: in a rolling deployment two app versions would change the schema at once. In production run the official per-version SQL scripts through your migration tool — the discipline of spring-data-tx and cicd-pipelines.
5.2 Service tasks: three ways to attach code
Option one — JavaDelegate (classic, inside the same JVM):
import org.camunda.bpm.engine.delegate.DelegateExecution;
import org.camunda.bpm.engine.delegate.JavaDelegate;
import org.camunda.bpm.engine.delegate.BpmnError;
@Component("checkCreditDelegate") // the bean name is what the EL expression resolves
public class CheckCreditDelegate implements JavaDelegate {
private final CreditClient creditClient;
public CheckCreditDelegate(CreditClient creditClient) { this.creditClient = creditClient; }
@Override
public void execute(DelegateExecution execution) {
String customerId = (String) execution.getVariable("customerId");
CreditResult result = creditClient.score(customerId);
if (result.score() < 500) {
// business error: caught by an error boundary event
throw new BpmnError("CREDIT_DENIED", "score=" + result.score());
}
execution.setVariable("creditScore", result.score());
}
}
The task references it as camunda:delegateExpression="${checkCreditDelegate}", usually together with camunda:asyncBefore="true" and camunda:retryTimeCycle="R5/PT30S".
Option two — an expression straight onto a bean, with no delegate class: camunda:expression="${notificationService.notifyCustomer(execution.getVariable('customerId'))}" together with camunda:resultVariable="notificationId" to store the return value.
Option three — External Task (the recommended production choice):
<bpmn:serviceTask id="Task_CheckCredit" name="Check credit"
camunda:type="external"
camunda:topic="credit-check" />
5.3 Why external tasks scale better
With a JavaDelegate your code runs inside the engine's transaction: a job executor thread is held until your HTTP call returns; a 30-second service dries up the pool and stalls every process. Code and engine must also share a JVM and a deployment.
External tasks invert the model: the engine says "there is work on topic credit-check", and a worker pulls it from outside, takes a lock, does the work and reports back. The benefits: deployment isolation (another service in another language, scaled independently), natural backpressure (a slow worker just makes jobs queue up), fault isolation (a worker crash does not take the engine down; the lock expires and the work returns), and a tunable lockDuration for genuinely long work.
Diagram: the fetch-and-lock cycle of an external task — چرخهٔ fetch-and-lock در external task.
sequenceDiagram
participant W as External worker
participant E as Camunda 7 engine
W->>E: POST /external-task/fetchAndLock (topic, lockDuration)
E-->>W: list of locked tasks + variables
W->>W: call downstream system
alt success
W->>E: POST /external-task/{id}/complete (variables)
else technical failure
W->>E: POST /external-task/{id}/failure (retries, retryTimeout)
else business error
W->>E: POST /external-task/{id}/bpmnError (errorCode)
end
With the official Spring Boot starter (org.camunda.bpm.springboot:camunda-bpm-spring-boot-starter-external-task-client):
import org.camunda.bpm.client.spring.annotation.ExternalTaskSubscription;
import org.camunda.bpm.client.task.*;
@Component
@ExternalTaskSubscription(topicName = "credit-check", lockDuration = 20000)
public class CreditCheckHandler implements ExternalTaskHandler {
private final CreditClient creditClient;
public CreditCheckHandler(CreditClient creditClient) { this.creditClient = creditClient; }
@Override
public void execute(ExternalTask task, ExternalTaskService service) {
String customerId = task.getVariable("customerId");
try {
CreditResult r = creditClient.score(customerId);
if (r.score() < 500) {
service.handleBpmnError(task, "CREDIT_DENIED", "score too low");
return;
}
service.complete(task, Map.of("creditScore", r.score()));
} catch (TransientException e) {
int retriesLeft = task.getRetries() == null ? 3 : task.getRetries() - 1;
long backoffMs = (long) Math.pow(2, 3 - retriesLeft) * 10_000L; // 10s, 20s, 40s
service.handleFailure(task, e.getMessage(), stackTraceOf(e), retriesLeft, backoffMs);
}
}
}
camunda:
bpm:
client:
base-url: http://camunda:8080/engine-rest
worker-id: credit-worker-1
max-tasks: 10
lock-duration: 20000
async-response-timeout: 20000 # long polling
async-response-timeout is long polling: the worker sends one request and the server holds it open until work arrives or the timeout expires. Without it, workers poll every few hundred milliseconds; across 20 workers and 5 topics that saturates the engine database with pointless queries — the first thing to check when tuning Camunda 7.
If lockDuration is shorter than the real work time, the lock expires mid-flight, a second worker picks up the same task and the work is done twice (two transfers of the same money, for example). If it is far too long, a worker crash blocks the work for hours. The correct answer: lockDuration slightly above the P99 of the work, plus idempotency on the downstream service, plus lock extension (extendLock) for long-running work.
5.4 Transaction boundaries and async continuations — the most important part of Camunda 7
The Camunda 7 engine advances from one wait state to the next inside a single transaction. A wait state is where it persists state and returns control: user tasks, receive tasks, timers, event-based gateways, external tasks.
So if ten service tasks sit between two user tasks, all ten run inside the transaction that called startProcessInstance, and if the tenth throws, all ten roll back — while the external APIs you already called do not, leaving you inconsistent. The fix is an asynchronous continuation: camunda:asyncBefore="true" or camunda:asyncAfter="true" tells the engine "commit here, create a job, and let the job executor do the rest later".
Diagram: transaction boundaries with and without async continuations — مرزهای تراکنش با و بدون async continuation.
flowchart LR
subgraph TX1["One transaction - risky"]
S1(("Start")) --> A1["Charge card"] --> A2["Reserve stock"] --> A3["Send email"] --> U1["User task"]
end
subgraph TX2["Split with asyncBefore"]
S2(("Start")) --> B1["Charge card"]
B1 -.->|"commit / job"| B2["Reserve stock"]
B2 -.->|"commit / job"| B3["Send email"]
B3 --> U2["User task"]
end
Practical rules: asyncAfter after every external call that cannot be rolled back; asyncBefore on the branches after every parallel gateway, otherwise both branches run sequentially on one thread in one transaction and you get no real parallelism; and asyncBefore on the first activity of request-started processes so the caller gets a fast response.
Jobs are exclusive by default: two jobs of the same instance never run at the same time, because both write the same execution rows and would collide on the revision column (REV_). If you set camunda:exclusive="false" to go "faster", you get a stream of OptimisticLockingExceptions. The engine retries that exception without decrementing retries, but it burns your useful throughput. Do not touch exclusivity unless you really know why.
5.5 Messages, correlation, timers and retries
Correlation answers "which running instance does this incoming message belong to?":
runtimeService.createMessageCorrelation("PaymentConfirmed")
.processInstanceBusinessKey(orderId) // or .processInstanceVariableEquals("orderId", orderId)
.setVariable("paidAt", Instant.now().toString())
.correlateWithResult();
If the criteria match more than one active instance, Camunda 7 throws MismatchingMessageCorrelationException; if they match none, the same. The worst case is a message arriving before the process reaches its waiting point — normal in event-driven systems. The fix: hold the message in a queue with retries (see rabbitmq-amqp and messaging), or model an event-based gateway that catches it before any long-running work. Always choose a unique, stable business key.
Timers are ISO-8601: timeDuration (PT30M, P3D), timeDate (an instant such as 2026-09-01T09:00:00Z) and timeCycle (R3/PT1H, or a cron expression). Retry with backoff is declared on the activity and can list several intervals:
<bpmn:serviceTask id="Task_CallProvider" camunda:type="external" camunda:topic="provider"
camunda:asyncBefore="true">
<bpmn:extensionElements>
<camunda:failedJobRetryTimeCycle>R3/PT10S,R3/PT5M,R2/PT1H</camunda:failedJobRetryTimeCycle>
</bpmn:extensionElements>
</bpmn:serviceTask>
Three attempts 10 seconds apart, then three 5 minutes apart, then two an hour apart — then an incident. That is the exponential backoff you built in resilience, expressed declaratively without a single line of code.
5.6 Testing processes
With org.camunda.bpm.assert:camunda-bpm-assert you test a process like a state machine:
import static org.camunda.bpm.engine.test.assertions.bpmn.BpmnAwareTests.*;
@Test
void shouldRejectWhenCreditDenied() {
when(creditClient.score(any())).thenReturn(new CreditResult(300));
ProcessInstance pi = runtimeService.startProcessInstanceByKey(
"order-fulfillment", Map.of("customerId", "C-1", "amount", 5000));
assertThat(pi).isWaitingAt("Task_CheckCredit");
complete(externalTask()); // simulate the worker
assertThat(pi).hasPassed("EndEvent_Rejected").isEnded();
}
Write one test per decision path (approved, rejected, timeout, cancelled) and cover delegates/workers with plain unit tests. Driving the whole happy path against a real service is slow and brittle — the testing pyramid holds for BPMN too (see testing).
6. Java integration — Camunda 8
In Camunda 8 your application is a client, not the engine's host. The plain client is io.camunda:camunda-client-java with the CamundaClient class:
try (CamundaClient client = CamundaClient.newClientBuilder()
.grpcAddress(URI.create("http://localhost:26500"))
.restAddress(URI.create("http://localhost:8080"))
.build()) {
client.newDeployResourceCommand().addResourceFromClasspath("order-fulfillment.bpmn").execute();
client.newCreateInstanceCommand()
.bpmnProcessId("order-fulfillment").latestVersion()
.variables(Map.of("orderId", "O-1001", "amount", 12_500))
.execute();
}
With Spring Boot it is cleaner:
<dependency>
<groupId>io.camunda</groupId>
<artifactId>camunda-spring-boot-starter</artifactId>
<version>8.8.9</version>
</dependency>
camunda:
client:
mode: self-managed
auth:
method: none
grpc-address: http://localhost:26500
rest-address: http://localhost:8080
worker:
defaults:
max-jobs-active: 32
timeout: PT1M
retry-backoff: PT10S
deployment:
enabled: true
import io.camunda.spring.client.annotation.JobWorker;
import io.camunda.spring.client.annotation.Variable;
import io.camunda.client.api.response.ActivatedJob;
import io.camunda.client.api.worker.JobClient;
@Component
public class CreditWorker {
// constructor injection of creditClient and providerClient omitted
// job type defaults to the method name unless you set it; the return value becomes process variables
@JobWorker(type = "credit-check")
public Map<String, Object> checkCredit(@Variable String customerId) {
return Map.of("creditScore", creditClient.score(customerId).score());
}
// full control over failure handling and backoff
@JobWorker(type = "call-provider", autoComplete = false)
public void callProvider(JobClient jobClient, ActivatedJob job) {
try {
providerClient.send(job.getVariablesAsMap());
jobClient.newCompleteCommand(job).execute();
} catch (BusinessRejection e) {
jobClient.newThrowErrorCommand(job) // reaches an error boundary event
.errorCode("PROVIDER_REJECTED")
.errorMessage(e.getMessage())
.execute();
} catch (TransientException e) {
jobClient.newFailCommand(job)
.retries(job.getRetries() - 1)
.retryBackoff(Duration.ofSeconds(30))
.errorMessage(e.getMessage())
.execute();
}
}
}
A worker has exactly three answers: complete (done), fail (a technical failure, with retries and backoff; when retries reaches zero an incident is raised) and throwError (a business error handled at the BPMN level).
If a worker does its work and crashes right before complete, the job timeout expires and the job goes to another worker — the same side effect runs again. In Camunda 7 the shared transaction could save you; here it cannot. Every service task must be idempotent: use job.getKey() or a business key as the unique key downstream. This is the "there is no exactly-once" argument from messaging and distributed-systems-theory.
Message correlation uses the message name plus a correlationKey, and messages are buffered on the broker for timeToLive — so unlike Camunda 7, a message arriving before the process is not necessarily lost:
client.newPublishMessageCommand()
.messageName("PaymentConfirmed")
.correlationKey("O-1001") // matched against the correlation variable in the model
.messageId("payment-evt-77") // used for deduplication
.timeToLive(Duration.ofMinutes(30))
.variables(Map.of("paidAt", "2026-08-13T10:00:00Z"))
.execute();
Testing uses the official Camunda Process Test library (io.camunda:camunda-process-test-spring for Spring, camunda-process-test-java for the plain client): annotate the test class with @CamundaSpringProcessTest and assert with CamundaAssert.assertThat(instance).isActive().hasActiveElements("Task_ManualApproval").
For local development, Camunda 8 Run starts everything with ./c8run start; Operate is at http://localhost:8080/operate, the gateway on port 26500.
7. DMN — separating the decision from the flow
DMN (Decision Model and Notation) is BPMN's sibling standard for modelling business rules, and its workhorse is the decision table: input columns, output columns, one rule per row.
A decision table is the insurance tariff sheet pinned to the office wall: "age 18 to 30 and low risk → factor 1.0". When management changes the tariff, the wall changes, not the building. That is DMN: changing a rule without changing code or redeploying the application.
The hit policy says what happens when several rows match:
| Hit policy | Marker | Behaviour | Use |
|---|---|---|---|
| Unique | U | exactly one row may match, otherwise an error | deterministic tables — the safe default |
| First | F | the first matching row wins | prioritised rules with meaningful order |
| Any | A | several rows may match but all must give the same output | redundant tables |
| Priority | P | the output with the highest priority | risk banding |
| Collect | C | all results as a list, with sum/min/max/count aggregators | "which discounts apply" |
| Rule Order | R | all of them, in row order | a list of actions |
In Camunda 7 table expressions can be JUEL or FEEL; in Camunda 8 only FEEL. A FEEL input entry looks like [18..30], >= 1000, not("VIP"), or "GOLD","SILVER".
In Camunda 7 you can evaluate a table directly with decisionService.evaluateDecisionTableByKey("insurance-rate").variables(...).evaluate(), or wire it into the model with a business rule task:
<bpmn:businessRuleTask id="Task_Rate" name="Determine rate"
camunda:decisionRef="insurance-rate"
camunda:resultVariable="rateResult"
camunda:mapDecisionResult="singleEntry" />
In Camunda 8 the same task binds to a decision via the Zeebe extension (decisionId, resultVariable), and .dmn files deploy with the same newDeployResourceCommand.
The senior rule: DMN is for decisions, not for flow. A table returning "the name of the next step" hides the process and makes the BPMN model lie. Return a business value — a rate, a risk band, an approver group — and branch on it in a gateway.
8. Orchestration or choreography? BPMN or saga?
- Orchestration: a central component knows what comes next and commands the services — a conductor.
- Choreography: no centre; each service publishes an event and others react — dancers hearing the music.
Diagram: central commands versus reacting to events — دستور مرکزی در برابر واکنش به رویداد.
flowchart TB
subgraph O["Orchestration"]
ENG["Workflow engine"] --> S1["Payment"]
ENG --> S2["Inventory"]
ENG --> S3["Shipping"]
end
subgraph CH["Choreography"]
P["Payment"] -- "PaymentCaptured" --> BUS[["Event bus"]]
BUS --> I["Inventory"]
I -- "StockReserved" --> BUS
BUS --> SH["Shipping"]
end
| Criterion | Orchestration with an engine | Choreography with events |
|---|---|---|
| Where is the flow? | explicit, in one visible model | implicit, spread across subscriptions |
| Adding a new step | change the model in one place | change several services + coordination |
| "Where are we now?" | immediate in Cockpit/Operate | needs distributed tracing |
| Compensation | modelled and explicit | hand-written in every consumer |
| Coupling | services depend on the engine | services depend on the event contract |
| Best for | processes with human tasks, SLAs, audit | simple, high-volume, fire-and-forget flows |
The answer is "both, at different layers" — and you must draw the line. Between bounded contexts, use events so coupling stays loose. Inside a context, when a multi-step process has an SLA and clear ownership, orchestrate, because the flow becomes explicit and observable. The common mistake is choreographing a twelve-step process: six months later nobody knows where an order is stuck and "distributed responsibility" has become "nobody is responsible". The opposite is just as bad: a central engine coupled to every small service change is the old ESB with a new name.
BPMN is not a competitor to the saga pattern; it is one way to implement it. Saga (built in ms-data) says: split a distributed transaction into local transactions with a compensating action for each. The orchestration-based variant in BPMN is exactly that: a compensation boundary event on each activity plus a compensation throw event, with the engine compensating only the activities that actually ran, in reverse order.
Diagram: an orchestrated saga with compensation handlers — saga با compensation در BPMN.
flowchart LR
A["Reserve stock"] --> B["Charge card"] --> C["Book courier"] --> D{"All ok?"}
D -- "no" --> X(("Throw compensation"))
A -. "compensate: release stock" .-> CA["Release stock"]
B -. "compensate: refund" .-> CB["Refund payment"]
C -. "compensate: cancel booking" .-> CC["Cancel courier"]
D -- "yes" --> E(("End"))
9. Operations — where projects die
9.1 Monitoring and incidents
The operations tool is Cockpit in Camunda 7 and Operate in Camunda 8; both render instances on the diagram, highlight the active token and list incidents. Common actions: retry a job, change a variable, cancel an instance, and modification — moving a token to another activity by hand.
But a dashboard is not enough. At minimum five metrics belong in monitoring with alerts: open incidents (anything older than 15 minutes should page someone), age of the oldest due job (over 5 minutes means the job executor is behind), active instances per definition (steady growth means instances that never finish), process duration at P95 (SLA breaches) and history table size.
For Camunda 7 this query can be wired straight into monitoring — the jobs that have turned into incidents:
SELECT j.id_ AS job_id,
j.process_instance_id_,
j.retries_,
j.duedate_,
left(j.exception_msg_, 200) AS error
FROM act_ru_job j
WHERE j.retries_ = 0
ORDER BY j.duedate_ NULLS LAST
LIMIT 50;SELECT j.id_ AS job_id,
j.process_instance_id_,
j.retries_,
j.duedate_,
SUBSTR(j.exception_msg_, 1, 200) AS error
FROM act_ru_job j
WHERE j.retries_ = 0
ORDER BY j.duedate_ NULLS LAST
FETCH FIRST 50 ROWS ONLY;To see how many old versions are still alive, write the same style of query against act_ru_execution joined to act_re_procdef, filtered by e.parent_id_ IS NULL and grouped by key_ and version_.
Reading ACT_RU_* for lightweight monitoring is fine, but analytical reports against the database the engine writes to directly affect process latency. For BI, move the data into a separate store (see nosql-specialized). Also, the engine's internal schema is not a public contract and changes between versions; anything you build on those tables is migration debt.
9.2 Migrating running instances
You have three options: drain (let them finish — the simplest and safest), cancel and restart (only when there are no side effects), or migration.
// Camunda 7
MigrationPlan plan = runtimeService
.createMigrationPlan(sourceDefinitionId, targetDefinitionId)
.mapEqualActivities() // elements with equal ids are mapped automatically
.mapActivities("Task_OldName", "Task_NewName")
.updateEventTriggers() // rebuild timers and subscriptions
.build();
runtimeService.newMigration(plan)
.processInstanceQuery(runtimeService.createProcessInstanceQuery()
.processDefinitionId(sourceDefinitionId))
.executeAsync(); // as a batch, not one transaction
The Camunda 8 equivalent is a single command: client.newMigrateProcessInstanceCommand(key).migrationPlan(targetProcessDefinitionKey).addMappingInstruction("Task_OldName", "Task_NewName").execute().
mapEqualActivities() only works if element ids stayed stable — hence meaningful ids from day one. And always rehearse a migration against a copy of the production database first; a wrong one strands instances in impossible states.
9.3 Performance and database growth — cheat sheet
| Symptom | Likely cause | Action |
|---|---|---|
| Jobs run late | small pool or small acquisition batch | raise core-pool-size and max-jobs-per-acquisition |
Frequent OptimisticLockingException |
non-exclusive jobs or huge multi-instance | restore exclusivity, break up multi-instance |
| High DB CPU with no user load | aggressive external task polling | enable long polling |
| Everything slowly degrades | ACT_HI_* growth |
history cleanup + lower history level |
| Instances that never finish | no end path or timeout | add a boundary timer and a cancel path |
| Zeebe throughput drops | too few partitions or a slow exporter | add partitions, tune secondary storage |
# Camunda 7 — nightly history cleanup
camunda:
bpm:
generic-properties:
properties:
historyCleanupBatchWindowStartTime: "02:00"
historyCleanupBatchWindowEndTime: "05:00"
historyCleanupDegreeOfParallelism: 2
historyTimeToLive: P30D
In Camunda 8 the equivalent is managing secondary storage: retention policies on the Elasticsearch/OpenSearch indices or the RDBMS tables, plus monitoring their size. The rule is the same: no data stays forever unless you explicitly decided it should.
10. Interview questions
Because it ships six capabilities you would otherwise rebuild badly: durable state with an explicit model, wait states that hold no thread, timers and escalation, retries with backoff plus permanent failures turned into visible incidents, a human work queue, and audit history. Most importantly, versioning and migration of in-flight instances — effectively unsolvable with a status column. It is not free, though: with no wait states, an engine only adds complexity.
A token is the logical marker showing where execution currently sits in the model. A start event creates one, a parallel gateway splits it, a join merges it, an end event consumes it, and when the last token is gone the instance ends. That one concept explains why an AND-join waits, why closing a parallel fork with an exclusive gateway causes double execution, and why terminate kills every branch. In Camunda 7 each token is a row in ACT_RU_EXECUTION.
A JavaDelegate runs inside the engine JVM and its transaction: simple, able to share a transaction with your code, but it occupies an engine thread and welds your deployment to the engine. External tasks are a pull model: a worker fetches and locks work from outside. For anything doing external I/O I choose external tasks — natural backpressure, no burning of the engine's thread pool, independent scaling, and a worker crash does not take the engine down.
Camunda 7 executes everything from one wait state to the next inside one transaction. If five service tasks sit between two wait states and the fifth fails, all five roll back — but the external calls already made do not. asyncBefore/asyncAfter creates an explicit transaction boundary: the engine commits, creates a job, and the job executor continues later. Main uses: after every non-reversible side effect, after a parallel gateway to get real parallelism, and at the start of a process for a fast response.
An incident is the record the engine creates when a job has exhausted its retries and will no longer run automatically, or when something like a correlation mismatch occurs. It means "this work needs a human"; without it, failures would vanish silently. In practice I export open incident count as a metric and alert on it, inspect the cause in Cockpit/Operate, fix the root cause and then retry. A system whose incidents nobody watches is the silent cron job we were escaping.
Camunda 7 is an embedded engine in your JVM on your relational database; Camunda 8 is a separate cluster around Zeebe with your application as a client. Three consequences: the shared transaction disappears, so you write idempotency; direct queries against engine tables give way to APIs and secondary storage; expressions move from JUEL to FEEL. In exchange you get real horizontal scaling and polyglot workers. Licensing is a separate check: Camunda 8 Self-Managed is source-available and production use requires a licence.
I do not — I design so a second execution is harmless. Every distributed engine delivers at-least-once: if the worker does the work and crashes before complete, the lock expires and the job is redelivered. The answer is idempotency: send a stable unique key downstream and reject duplicates there with a unique constraint or a dedup table. The engine's contribution is a lockDuration/timeout slightly above the P99 of the work, plus lock extension for long jobs.
Between bounded contexts, choreograph with events, so coupling stays loose and teams move independently. Inside a context, when there is a multi-step process with an SLA, human tasks, compensation and audit needs, orchestrate, because the flow becomes explicit and observable. The warning sign of too much choreography is correlating logs from five services to answer "where is this order stuck?". The warning sign of too much orchestration is every small service change requiring a central model change — that is an ESB.
First I ask whether it is necessary. Three options: drain — deploy the new version and let old instances finish on their own version, the safest and usually sufficient choice; cancel and restart — only when no irreversible side effects happened; migration — a migration plan with element mappings, executed asynchronously as a batch. Migration requires stable element ids, and I always rehearse it against a copy of the production database and canary a few instances first.
It is valuable when a rule is well defined but changes often and is owned by the business: rates, risk bands, eligibility, approver groups. A decision table with the right hit policy separates rule from code, so changing it needs no application deployment. It is abused when the table returns the next process step instead of a business value. My default hit policy is Unique, because it surfaces rule overlap as an error instead of silently picking a row.
A workflow engine exists for the problem of long-lived state: work that outlives a request, waits for humans, time and messages, must be retried and escalated, and must leave an audit trail. BPMN 2.0 is the shared language — especially the four gateways, interrupting versus non-interrupting boundary events, compensation and multi-instance. The right mental model is the token; the operational model is jobs and incidents. Camunda 7 is an embedded engine on a relational database, with a shared transaction and the limits of one database; Camunda 8/Zeebe is a distributed cluster that scales horizontally but removes the shared transaction and makes idempotency mandatory. At the code level, external tasks and job workers beat in-process delegates almost every time, and async continuations separate a robust process from a fragile one. Architecturally: events between contexts, orchestration inside a context, and BPMN as the cleanest orchestrated saga with compensation. What kills projects is not code but operations: unwatched incidents, giant variables, history that is never cleaned, and a migration nobody rehearsed.