Private Docs

Backend Code Walkthrough — เจาะลึก Lego Engine (Sprint)

อ่านครั้งเดียวเข้าใจทั้ง concept + architecture + แต่ละ class + code จริง + DB fields ทุก column — เขียนสำหรับคนที่ยังไม่เคยเห็น Lego Engine มาก่อน

อัปเดต: 2026-06-05

เอกสารนี้พาคุณเดินจาก “ไม่รู้เลย”“เข้าใจว่า engine นี้ทำงานอย่างไร เขียนขึ้นมาทำไม และ code จริงเดินอย่างไร” — ก่อนลงรายละเอียด code ต้องเข้าใจ ทำไม ก่อน


1. ทำไมถึงเรียกว่า “Lego”? — แนวคิดพื้นฐาน

ปัญหาแบบดั้งเดิม: Hardcode Flow

ลองนึกถึงระบบ onboarding แบบเก่า — ถ้าต้องเพิ่ม “ขั้นตอนยืนยันที่อยู่” เข้าไปในกระบวนการสมัคร:

  • แก้ Controller ของ flow นั้น
  • เพิ่ม if step == 3 ใน logic
  • deploy ระบบใหม่
  • พอ Business บอกให้สลับลำดับ → refactor ใหม่ทั้งหมด

ทุก flow มี logic การ navigate กระจายอยู่ใน code — ซ้ำซ้อน ทดสอบยาก ขยายยาก

แนวคิด Lego: Data-Driven Engine

Engineไม่รู้จักธุรกิจ — รู้แค่ "เรียก handler → เดินหน้า"
+
FlowDefinition ใน DBบอกว่า step อะไร ลำดับอะไร
=
Flow ที่รันได้เปลี่ยน flow = แก้ DB ไม่ต้อง deploy

นึกถึงตัวต่อ Lego จริงๆ:

ใน Legoใน System
กล่อง Lego (ชิ้นส่วนทุกแบบ)StepCatalog — ทะเบียน step type ทั้งหมด
พิมพ์เขียว (บอกว่าใช้ชิ้นไหน ลำดับไหน)FlowDefinition + FlowStep
ชิ้นส่วนแต่ละอัน (ทำงาน 1 อย่าง)IFlowStepHandler — handler 1 ตัวต่อ 1 step type
มือที่ต่อชิ้นตามพิมพ์เขียวFlowEngine — orchestrator
ผลลัพธ์ที่ประกอบเสร็จ (กำลังวิ่งอยู่)FlowInstance — 1 execution จริง

3 กฎหลักของ Engine

กฎ 1: Handler แต่ละตัว ไม่รู้ ว่าตัวเองอยู่ตำแหน่งไหนใน flow — รู้แค่ input/output ของตัวเอง
กฎ 2: Engine ไม่รู้ ว่า step ทำธุรกิจอะไร — รู้แค่ว่าจะ resolve handler ตัวไหน และจะ advance ไป step ไหนต่อ
กฎ 3: เพิ่ม / ลด / สลับ step = แก้ข้อมูลใน DB เท่านั้น ไม่ต้อง deploy

ผลที่ได้:

  • ทุก flow (STANDARD_CUSTOMER, CUSTOMER_FIXED, NEW_REQUESTOR, …) วิ่งผ่าน engine เดียวกัน
  • Handler unit test ง่าย — ทดสอบแค่ “input นี้ → output นี้”
  • Business ขอเพิ่ม/เปลี่ยน step sequence ได้โดยไม่ต้องรอ engineer

2. สถาปัตยกรรม — Clean Architecture ใน 1 นาที

UserService แบ่งเป็น 4 โปรเจกต์ — ชั้นในไม่รู้จักชั้นนอก:

01 · APIController รับ HTTP แล้วส่งต่อ
03 · ApplicationFlowEngine + Handlers + Commands
02 · InfrastructureRepositories + EF Core
PostgreSQL

04 · Domain (Entities + Interfaces) = แกนใน ทุกชั้นอ้างเข้าหา ไม่ออกนอก

หลักการ Controller ใน Lego Engine:

// ✅ ทำอย่างนี้เท่านั้น
var result = await mediator.Send(new ExecuteStepActionCommand(...));
return ApiMatch(result);  // ไม่มี business logic ใน Controller

3. แผนที่ส่วนประกอบหลัก — แต่ละตัวมีไว้ทำอะไร

ก่อนดู code ให้รู้จัก “ตัวละคร” ทุกตัวก่อน:

ตัวละครTypeไฟล์หน้าที่
FlowEngineClassEngine/FlowEngine.csorchestrator — Start / ExecuteAction / AdvanceAutomatic / BuildNavigation
IFlowStepHandlerInterface04.Domain/Ports/contract ของทุก handler: string StepType + ExecuteAsync(ctx)
StepHandlerRegistryClassEngine/StepHandlerRegistry.csresolve handler ตาม stepType จาก DI
FlowSnapshot + BuilderClassEngine/FlowSnapshot*.csแช่แข็ง FlowDefinition → JSON ใน instance ตอน Start
FlowSnapshotNavigatorClassEngine/FlowSnapshotNavigator.csnavigation แบบ pure: nextInteractive, prev, stepAt, isLast
FlowAccessGuardClassEngine/FlowAccessGuard.csตรวจสิทธิ์ 2 ทาง: JWT owner-match หรือ EditSession cookie
FlowDefinitionEntity04.Domain/Entities/Onboarding/template ของ flow (admin config ใน DB)
FlowInstanceEntity04.Domain/Entities/Onboarding/1 execution จริง มี state machine
FlowInstanceStepDataEntity04.Domain/Entities/Onboarding/output ของแต่ละ step (1 row ต่อ instance+stepType)
EditSessionEntity04.Domain/Entities/Onboarding/anonymous session ก่อน bind owner (cookie __Host-onboarding)
CorrectionRequestEntity04.Domain/Entities/Onboarding/ประวัติคำขอแก้ไขจาก approver (append-only)
InvitationTokenEntity04.Domain/Entities/Onboarding/one-time token สำหรับ invite customer ผ่าน link
AutoApproveWorkerWorker02.Infrastructure/BackgroundJobs/subscribe flow-submitted → auto-approve ถ้า flowCode อยู่ใน whitelist
Workers (×3)Workers02.Infrastructure/BackgroundJobs/AutoAbandon (6h) · RefNoRetry (5m) · StuckChainRecovery (15m)

4. Endpoints ทั้งหมด (OnboardingV2Controller)

Method · PathทำอะไรCommand/Query
GET /onboarding/instancesใบสมัครของฉัน (ตาม JWT owner)ListMyFlowInstancesQuery
GET /onboarding/instances/{id}ดู instance เดียวGetFlowInstanceQuery
GET /onboarding/sessionresume จาก cookie __Host-onboardingGetSessionNavQuery
POST /onboarding/instancesเริ่ม flow (anonymous ได้) → set cookieStartFlowInstanceCommand
POST /onboarding/instances/{id}/openเปิด instance เดิมOpenFlowInstanceCommand
POST /onboarding/instances/{id}/steps/{stepType}/actions/{action}ขับ step (Next/Back/Submit…)ExecuteStepActionCommand
GET /admin/flow-instances · …/retry-stuck · …/abandonadmin opsAdmin*

ทุก endpoint รับ request → mediator.Send(...)return ApiMatch(result) — Controller ไม่มี business logic


5. Code Walkthrough — กด “ถัดไป” 1 ครั้ง

POST /api/user-service/v1/onboarding/instances/{id}/steps/PersonalInfoStep/actions/Next

sequenceDiagram
  autonumber
  participant FE as Frontend
  participant C as OnboardingV2Controller
  participant P as MediatR pipeline
  participant H as ExecuteStepActionHandler
  participant E as FlowEngine
  participant SH as PersonalInfoStepHandler
  participant R as Repository (EF)
  participant DB as PostgreSQL
  FE->>C: POST .../actions/Next { stepData }
  C->>P: Send(ExecuteStepActionCommand)
  P->>H: behaviors (Performance/Audit/Validation/Tx…)
  H->>H: FlowAccessGuard (JWT owner หรือ EditSession cookie)
  H->>E: ExecuteActionAsync(instance, "PersonalInfoStep", Next, json)
  E->>SH: handler.ExecuteAsync(ctx)
  SH-->>E: FlowStepExecutionResult.Ok(output)
  E->>R: UpsertAsync(FlowInstanceStepData)
  R->>DB: INSERT/UPDATE DataJson (jsonb)
  E->>E: AdvanceTo(next interactive) + AdvanceAutomaticAsync
  E->>R: UpdateAsync(instance) — Status/CurrentStepIndex
  E-->>H: StepNavigationV2Dto (step ถัดไป)
  H-->>C: Result.Success(nav)
  C-->>FE: 200 ApiOk(nav)

ชั้นที่ 1 — Controller (รับ + ส่งต่อ)

[HttpPost("onboarding/instances/{id:guid}/steps/{stepType}/actions/{actionCode}")]
public async Task<IActionResult> Action(Guid id, string stepType, string actionCode, ExecuteActionRequest? body)
{
    if (!Enum.TryParse<StepActionCode>(actionCode, true, out var parsed))
        return BadRequest(new { errorCode = "Session.InvalidAction" });

    var inputJson = body?.StepData?.GetRawText();        // เก็บเป็น raw JSON ส่งต่อ
    var editSessionId = ReadSessionCookie();             // __Host-onboarding
    var result = await mediator.Send(new ExecuteStepActionCommand(id, stepType, parsed, inputJson, editSessionId));
    if (result.IsFailure) return ApiMatch(result);
    // sliding refresh cookie...
    return ApiOk(result.Value.Nav);
}

ชั้นที่ 2 — Application Handler (guard + เรียก engine + map error)

public async Task<Result<ExecuteStepActionResult>> Handle(ExecuteStepActionCommand cmd, CancellationToken ct)
{
    var instance = await repo.GetByIdAsync(cmd.InstanceId, ct);
    if (instance is null) return Result.Failure(Error.NotFound("Application.NotFound", "ไม่พบใบสมัคร"));

    // สิทธิ์ 2 ทาง: JWT owner-match หรือ EditSession cookie ที่ผูก instance นี้ (กัน IDOR)
    var auth = await FlowAccessGuard.AuthorizeAsync(instance, currentUser, cmd.EditSessionId, editSessionRepo, ct);
    if (auth.IsFailure) return Result.Failure(auth.Error);

    try
    {
        var nav = await engine.ExecuteActionAsync(instance, cmd.StepType, cmd.ActionCode, cmd.InputJson, auth.Value, ct);
        // sliding refresh: เลื่อน ExpiresAt ของ EditSession ทุก action
        return Result.Success(new ExecuteStepActionResult(nav, cookieSessionId, ttlSeconds));
    }
    catch (FlowEngineException ex)
    {
        return Result.Failure(Error.Validation(ex.ErrorCode, ex.Message));   // → 400 + ข้อความที่สื่อความหมาย
    }
}

ชั้นที่ 3 — FlowEngine (หัวใจ runtime)

public async Task<StepNavigationV2Dto> ExecuteActionAsync(
    FlowInstance instance, string stepType, StepActionCode action, string? inputJson, string actor, CancellationToken ct)
{
    var snapshot = FlowSnapshotBuilder.Deserialize(instance.FlowSnapshotJson);   // อ่าน flow ที่แช่แข็งไว้
    var curStep  = FlowSnapshotNavigator.StepAt(snapshot, instance.CurrentStepIndex);

    if (curStep.StepType != stepType)                                            // กันยิงผิด step
        throw new FlowEngineException("Session.InvalidStep", "stepType ไม่ตรงกับ currentStep");

    var handler = handlerRegistry.Resolve(stepType);                             // หา IFlowStepHandler ตาม stepType
    var result  = await handler.ExecuteAsync(new FlowStepExecutionContext {
        Instance = instance, StepType = stepType, InputJson = inputJson, StepData = existing, ActionCode = action }, ct);

    if (!result.Success)                                                         // validate ไม่ผ่าน
        throw new FlowEngineException(result.ErrorCode ?? "Step.Failed", result.ErrorMessage);

    await stepDataRepo.UpsertAsync(                                              // เก็บ output ของ step ลง DB
        FlowInstanceStepData.CreateExecuted(instance.Id, stepType, result.OutputJson ?? inputJson, actor), ct);

    // Next → เลื่อนไป interactive ถัดไป แล้วรัน Automatic chain ที่คั่น
    var next = FlowSnapshotNavigator.NextInteractiveIndex(snapshot, instance.CurrentStepIndex);
    instance.AdvanceTo(next >= 0 ? next : instance.CurrentStepIndex + 1, actor);
    await AdvanceAutomaticAsync(instance, snapshot, actor, ct);
    await instanceRepo.UpdateAsync(instance, ct);
    return await BuildNavigationAsync(instance, snapshot, ct);
}

:::details Automatic chain — AdvanceAutomaticAsync (engine รัน step เองจนเจอ Interactive หรือจบ)

while (true)
{
    var step = FlowSnapshotNavigator.StepAt(snapshot, instance.CurrentStepIndex);
    if (step is null) { instance.TransitionToFinalized(actor); return; }   // จบ flow
    if (step.ExecutionMode != StepExecutionMode.Automatic) return;         // หยุดที่ Interactive

    var result = await handlerRegistry.Resolve(step.StepType)
        .ExecuteAsync(new FlowStepExecutionContext { Instance = instance, StepType = step.StepType, InputJson = null, ... }, ct);

    if (!result.Success) { instance.MarkStuck(step.StepType, actor); return; }   // ค้าง → admin retry
    await stepDataRepo.UpsertAsync(FlowInstanceStepData.CreateExecuted(...), ct);
    instance.AdvanceTo(instance.CurrentStepIndex + 1, actor);
}

:::

ชั้นที่ 4 — Handler (โค้ดที่รัน 1 step)

PersonalInfoStepHandler — parse JSON → validate → คืน output:

public Task<FlowStepExecutionResult> ExecuteAsync(FlowStepExecutionContext ctx, CancellationToken ct = default)
{
    if (string.IsNullOrWhiteSpace(ctx.InputJson))
        return Fail("Step.NoInput", "กรุณากรอกข้อมูลส่วนตัว");

    using var doc = JsonDocument.Parse(ctx.InputJson);
    // root = view ที่ชี้เข้า doc — ต้องอ่านค่าทั้งหมด "ก่อน" doc ถูก dispose
    var root = doc.RootElement.TryGetProperty("stepData", out var sd) ? sd : doc.RootElement;
    var firstNameTH = GetStr(root, "firstNameTH");
    // ... validate required ...
    var output = JsonSerializer.Serialize(new { firstNameTH, lastNameTH, phone, ... });
    return Ok(output);
}

6. Handler เจาะลึก: OtpVerificationStep (identity boundary)

Step นี้คือจุดเปลี่ยน anonymous → authenticated — 1 step มี 2 action:

SaveDraft= ส่ง OTP (Entra SignUpStart)
·
Next= verify → สร้าง User → bind owner → rotate session
sequenceDiagram
  autonumber
  participant FE as Frontend
  participant H as OtpVerificationStepHandler
  participant OTP as OtpStateClient
  participant EN as Entra (CIAM)
  participant U as CreateUserInternalService
  participant DB as PostgreSQL
  FE->>H: Next (verify) { otp, refCode }
  H->>OTP: VerifyOtpAsync(otp)
  H->>EN: SignUpContinueWithOtp → id_token (oid)
  H->>EN: DeactivateIdentity(oid)  %% กัน orphan จน ActivateEntraUser
  H->>U: CreateAsync(minimal User: email+oid)
  U->>DB: INSERT Users (+ UserRestrictedProfile ค่าว่าง)
  H->>H: instance.BindOwner(userId)  %% anonymous → owner
  H->>DB: rotate EditSession (UserId set + sessionId ใหม่)
  H-->>FE: Ok(status=VERIFIED)
var verify = await otpState.VerifyOtpAsync(OtpModule.Onboarding, email, otp, continuationToken, ct);
if (verify.IsFailure) return Fail(verify.Error.Code, verify.Error.Description);

var auth = await entraSignUp.SignUpContinueWithOtpAsync(_tenantKey, continuationToken, otp, ...);
var oid  = ExtractOidFromIdToken(auth.IdToken);              // CIAM v2: ใช้ oid ไม่ใช่ sub
await graphIdentity.DeactivateIdentityAsync(_tenantKey, oid, ct);

var create = await createUser.CreateAsync(new CreateUserCommand(oid, email, ...), ct);
ctx.Instance.BindOwner(create.Value.Id, "otp-identity");    // engine persist instance หลัง handler
// rotate EditSession: deactivate เก่า + สร้างใหม่ (stamp UserId, anti session-fixation)

7. Attribute ของ Step — มีอะไรให้กำหนดได้บ้าง

ก่อนดูว่าระบบมี step อะไรบ้าง ต้องเข้าใจก่อนว่า step 1 ตัวมี “ลักษณะ” อะไรที่ตั้งได้บ้าง — ทั้งหมดนี้เก็บอยู่ใน StepCatalog (ทะเบียน step type) และ FlowStep (การผูก step เข้ากับ flow)

ExecutionMode — ใครขับ step นี้?

Modeค่าEngine ทำอะไรใครเป็นคนกระตุ้น
Interactive0หยุดรอที่ step นี้ ส่ง currentStep กลับให้ Frontend renderUser กรอกข้อมูล + กด action (Next/Back/Submit)
Automatic1engine รัน handler ทันทีแล้วเดินหน้าต่อ — FE ไม่เห็น step นี้เลยEngine เอง (หลัง user กด Next ที่ Interactive step ก่อนหน้า)

ExecutionMode กำหนดที่ StepCatalog ต่อ step type — Interactive step ทุกตัวจะมี component บน FE ส่วน Automatic จะไม่อยู่ใน STEP_REGISTRY เลย

CanGoBack — ย้อนกลับได้ไหม?

กำหนดที่ FlowStep (ต่อ step ต่อ flow — ไม่ใช่ต่อ step type) เพราะ step เดียวกันอาจ canGoBack ต่างกันในแต่ละ flow

canGoBack = true  → user กด Back กลับมา step นี้ได้
canGoBack = false → step นี้ผ่านแล้วย้อนไม่ได้ (เช่น OTP ที่ bind identity แล้ว)

MaxRetries + RetryBackoffSeconds — Automatic step ล้มเหลวจัดยังไง?

เฉพาะ Automatic step เท่านั้น — ถ้า handler คืน Fail():

Fail()
MarkStuckทันที — บันทึก StuckAtStepType
StuckChainRecoveryWorker
retry ทุก 15 นาที

ปัจจุบัน: engine ไม่ retry inline — Fail() ครั้งเดียวก็ MarkStuck ทันที แล้วปล่อยให้ Worker จัดการ

AttributeDefaultสถานะ
MaxRetries0ประกาศใน DB แต่ engine ยังไม่ใช้ (Phase 2)
RetryBackoffSeconds30ประกาศใน DB แต่ engine ยังไม่ใช้ (Phase 2)

DependenciesJson — step นี้ต้องการ step อื่นก่อนไหม?

เก็บเป็น JSON array บอกว่า step type ไหนต้องทำก่อน และ mode ว่าบังคับหรือแนะนำ:

[
  { "StepTypeCode": "JuristicBindingStep", "Mode": "Required" },
  { "StepTypeCode": "UserModeStep",        "Mode": "Optional" }
]
Modeความหมาย
Requiredถ้าไม่มี step นี้ใน flow → FlowStudio แสดง error · handler เช็คเองและ return Fail() ถ้าข้อมูลขาด
Optionalแนะนำให้มี แต่ไม่บังคับ

SkipScope — ข้ามซ้ำได้ไหม?

กำหนดว่า user ที่เคยทำ step นี้ไปแล้วในอดีตจะ “ข้าม” step ได้ไหม (copy-on-skip pattern):

Valueความหมาย
Never (0)ทำซ้ำทุกครั้ง ไม่ข้าม
PerUser (1)user คนนี้เคยทำแล้ว → ข้ามได้ (copy data เก่ามา)
PerUserPerCompany (2)user + บริษัทเดิม → ข้าม
PerUserPerCompanyPerSvc (3)user + บริษัท + service เดิม → ข้าม

LockedAfterFirstExecution — ทำซ้ำไม่ได้

true  → step นี้ execute ได้ครั้งเดียว เช่น OTP, NDID (กัน replay attack)
false → ทำซ้ำได้ (เช่น กรอกข้อมูลส่วนตัวแก้ได้หลายครั้ง)

ctx.StepData — handler อ่านข้อมูลจาก step อื่น

ทุก handler รับ FlowStepExecutionContext ซึ่งมี StepData เป็น dictionary ของ output ทุก step ที่ผ่านมาแล้ว:

// อ่านข้อมูลจาก step อื่นผ่าน StepType — ไม่ใช่ index
var juristicData = ctx.StepData.GetValueOrDefault("JuristicBindingStep");

ทำให้ handler สามารถ cross-reference ข้อมูลจาก step อื่น โดยไม่ต้องรู้ตำแหน่งของ step นั้นในลำดับ


การ Parse InputJson — Pattern และข้อควรระวัง

ctx.InputJson คือ raw JSON string จาก client — codebase ทั้งหมดอ่านผ่าน JsonDocument + TryGetProperty แบบ manual ไม่ใช้ Deserialize<T> โดยตรง

Pattern มาตรฐาน (ทุก handler ใช้แบบนี้):

using var doc = JsonDocument.Parse(ctx.InputJson ?? "{}");
// รองรับทั้ง { "stepData": { ... } } และ flat { ... }
var root = doc.RootElement.TryGetProperty("stepData", out var sd) ? sd : doc.RootElement;

// อ่านทีละ field ภายใน using scope
var firstName = root.TryGetProperty("firstNameTH", out var v1) ? v1.GetString() ?? "" : "";
var phone     = root.TryGetProperty("phone",       out var v2) ? v2.GetString() ?? "" : "";

ทำไมไม่ Deserialize<T> ตรงๆ? เพราะ JsonElement ยังผูกกับ doc — ถ้าอยาก map ใส่ model มี 2 ทางที่ปลอดภัย:

// ทาง 1: Deserialize จาก GetRawText() ภายใน using (doc) — ยังอยู่ใน scope
using var doc = JsonDocument.Parse(ctx.InputJson ?? "{}");
var root  = doc.RootElement.TryGetProperty("stepData", out var sd) ? sd : doc.RootElement;
var model = JsonSerializer.Deserialize<MyInputModel>(root.GetRawText()); // ✅

// ทาง 2: ข้าม JsonDocument ทั้งหมด — Deserialize จาก InputJson ตรงๆ
var wrapper = JsonSerializer.Deserialize<StepDataWrapper>(ctx.InputJson ?? "{}");
var model   = wrapper?.StepData ?? wrapper;                               // ✅

บาง handler สร้าง private sealed record เพื่อรับค่า แต่ก็ยัง map ด้วยมือ ผ่าน TryGetProperty:

// PersonalInfoStepHandler — มี record แต่ map เอง ไม่มี auto-deserialize
private sealed record PersonalInfoRecord(string FirstNameTH, string LastNameTH, string Phone, ...);

var pi = new PersonalInfoRecord(
    FirstNameTH: root.TryGetProperty("firstNameTH", out var v1) ? v1.GetString() ?? "" : "",
    LastNameTH:  root.TryGetProperty("lastNameTH",  out var v2) ? v2.GetString() ?? "" : "",
    Phone:       root.TryGetProperty("phone",       out var v5) ? v5.GetString() ?? "" : ""
);

8. ประเภท Step และ Handler ทั้งหมด

Lego Engine มี handler 20 ตัว แบ่งเป็น Interactive (รอ user) และ Automatic (engine run เอง) เข้าใจแต่ละตัวก่อนจะทำให้เห็นภาพว่า “เลโก้” แต่ละชิ้นมี spec อะไร

ตารางสรุป StepCatalog ทั้งหมด

StepTypeModeFlow ที่ใช้ValidatesExternal Service
OtpVerificationStepInteractiveทุก flow ที่ anonymous startEmail + OTPEntra CIAM, OtpState
JuristicBindingStepInteractiveNEW/RETURNING_REQUESTORjuristicId, CRM eligibilityDBD, CRM
RequesterInfoStepInteractiveRequestor flowsidType + idNo format
CompanyInfoStepInteractiveNEW/RETURNING_REQUESTOR(เก็บ input ดิบ)
UserModeStepInteractiveRequestor flowsmode = “Single” เท่านั้น
DesignateCustomersStepInteractiveRequestor flowsID/email format, limit ≤ cap
ServiceTcConsentStepInteractiveRequestor flowsagreed = true
SignatoryFormStepInteractiveRequestor flowscount 1-4, formDownloaded=true
SubmitApplicationStepInteractive (gate)Requestor flowsrequired steps ครบ 6 ตัว
ConsentStepInteractiveCustomer flowsagreed = true
NdidStepInteractive (stub)STANDARD_CUSTOMERalways passes
PersonalInfoStepInteractiveCustomer flowsfirstName, lastName, phone
SubmitRegistrationStepInteractive (gate)Customer flowsConsent + PersonalInfo ครบ
CreatePlatformUserAutomaticCustomer flowsUser exists, PersonalInfo ครบUser repo
ActivateEntraUserAutomatic (stub)Customer flowsalways passes
CreateUserCompanyMappingAutomaticCUSTOMER_FIXEDInvitationToken validInvitationToken repo
ProvisionCustomerAccessAutomaticCUSTOMER_FIXEDtoken lineage, designee matchCompanyRole, UserCompanyMapping
CreateCompanyApplicationAutomaticRequestor flowsCompanyId + ServiceIdCompanyApplication repo
GenerateCustomerInvitationsAutomaticRequestor flows(list อาจว่าง)InvitationToken repo
SendInvitationEmailsAutomaticRequestor flows(ใช้ GenerateCustomerInvitations)IInvitationEventPublisher

Interactive Steps — เจาะลึกทีละตัว

OtpVerificationStep — Identity Boundary (2-action)

Step นี้พิเศษกว่าตัวอื่น: 1 step มี 2 action แตกต่างกันโดยสิ้นเชิง

ActionทำอะไรOutput
SaveDraftส่ง OTP → Entra SignUpStart{refCode, status:"OTP_SENT", expirySeconds:300}
Nextverify OTP → สร้าง User → BindOwner → rotate session{status:"VERIFIED", userId:"<guid>"}

SaveDraft (ส่ง OTP) flow:

  1. อ่าน email จาก EditSession (server-side — ไม่รับจาก client เพื่อป้องกัน account takeover)
  2. ค้นหา orphan identity ใน Entra (email เดิม) → deactivate ทิ้ง
  3. สร้าง OTP refCode ผ่าน OtpStateClient
  4. เรียก Entra SignUpStart → บันทึก continuation token

Next (verify OTP) flow:

  1. Verify OTP ผ่าน OtpStateClient
  2. เรียก Entra SignUpContinueWithOtp → ได้ id_token → แยก oid
  3. Deactivate Entra identity ชั่วคราว (ป้องกัน orphan ก่อน ActivateEntraUser)
  4. สร้าง User minimal (email + oid) → CreatePlatformUser จะ fill profile ทีหลัง
  5. ctx.Instance.BindOwner(userId) — instance เปลี่ยนจาก anonymous → owner-bound
  6. Rotate EditSession (สร้างใหม่ + invalidate เก่า — anti session-fixation per ADR-0023)

JuristicBindingStep — CRM + DBD + Company Uniqueness

Step ที่ซับซ้อนที่สุดใน Requestor flow — มี 3 external calls + 3 uniqueness checks:

juristicId → DBD lookup → company info
           → CRM eligibility check → block ถ้าไม่ eligible
           → find-or-create Company (sync from DBD)
           → uniqueness check 1: same-requestor duplicate?
           → uniqueness check 2: cross-requestor in-progress?
           → uniqueness check 3: requestor already bound to company?
           → ctx.Instance.SetCompanyId(companyId)

OutputJson: {juristicId, companyId, nameTH, nameEN, juristicType, juristicStatus, address, creditLineCap}


DesignateCustomersStep — Validation หนักที่สุด

Validate ทุก customer ที่ Requestor ระบุมา:

Ruleรายละเอียด
≥1 customerlist ว่างไม่ได้
idType + idNonationalId = 13 digits · passport = 6-9 chars
email formatregex validate
transactionLimit> 0 และ ≤ creditLineCap จาก JuristicBindingStep
no duplicateไม่ซ้ำ idNo และ email ใน payload เดียวกัน
Single modeforce companyRole = "Maker" (อ่านจาก UserModeStep)

Reads from ctx.StepData: JuristicBindingStep (creditLineCap), UserModeStep (mode)

OutputJson: Array ของ {idType, idNo, nameTH, nameEN, position, email, companyRole, transactionLimit}


SubmitApplicationStep — Dependency Gate (Requestor)

ตรวจว่า 6 step ครบทุกตัว ก่อน Submit:

required = ["JuristicBindingStep", "CompanyInfoStep", "UserModeStep",
            "DesignateCustomersStep", "ServiceTcConsentStep", "SignatoryFormStep"]

ถ้าขาด step ใด → Fail("Step.DependencyMissing", "กรุณาทำ ... ให้ครบก่อน")


SubmitRegistrationStep — Dependency Gate (Customer)

required = ["ConsentStep", "PersonalInfoStep"]

PersonalInfoStep — ข้อมูลส่วนตัว (Customer)

Required: firstNameTH, lastNameTH, phone Optional: firstNameEN, lastNameEN, nameTitleTH, nameTitleEN


NdidStep — Stub (ยังไม่ integrate จริง)

ปัจจุบัน stub: always return {status:"verified", verifiedAt:"<timestamp>"} ไม่ว่า input จะเป็นอะไร — จะ replace ด้วย NDID service จริงในอนาคต


Automatic Steps — เจาะลึกทีละตัว

Automatic step รับ InputJson = null (ไม่มี user input) — อ่านข้อมูลจาก ctx.StepData ของ step ก่อนหน้าแทน

CreatePlatformUser — Fill User Profile

ctx.StepData["PersonalInfoStep"] → firstName, lastName, phone, nameTitle
User minimal (สร้างตอน OTP) → update profile

Idempotent: ถ้า User ถูก create แล้วก็แค่ update profile — ไม่ duplicate


ActivateEntraUser — Stub

ปัจจุบัน stub: return {entraActivated:false, stub:true} — จะ replace ด้วย Microsoft Graph call เพื่อ activate identity ที่ถูก deactivate ตอน OTP


CreateUserCompanyMapping — ผูก Customer กับ Company (CUSTOMER_FIXED)

InvitationToken.CompanyId → สร้าง UserCompanyMapping (isDefault=true)

ผูก Customer user เข้ากับบริษัทที่ Requestor เชิญมา


CreateCompanyApplication — สร้าง CompanyApplication (Requestor)

instance.CompanyId + instance.ServiceId → สร้าง CompanyApplication
→ Lock Requestor ผ่าน CompanyRequestor junction table
→ Auto-reject competing in-progress application สำหรับ company+service เดิม (ถ้ามี)

Idempotent: ถ้ามี CompanyApplication อยู่แล้ว → skip ไม่ error


GenerateCustomerInvitations — สร้าง InvitationToken

ctx.StepData["DesignateCustomersStep"] → array of customers (email, role, limit)
FlowDefinition.SpawnsCustomerFlowCode → flow code สำหรับ customer
FlowDefinition.InvitationTtlDays → TTL ของ token (default 7 วัน)
→ สร้าง InvitationToken per customer (CompanyId + OriginatingFlowInstanceId)

OutputJson: {invitations:[{email, tokenId}]}


SendInvitationEmails — ส่งอีเมล

ctx.StepData["GenerateCustomerInvitations"] → list of {email, tokenId}
FlowDefinition.TemplateKey → email template
FlowDefinition.FrontendBaseUrl → base URL สำหรับ link
→ publish invitation event per email → NotificationService
Link format: {FrontendBaseUrl}/onboarding?code={tokenId}

ProvisionCustomerAccess — Set Role + Limit (CUSTOMER_FIXED)

Step สุดท้ายที่ซับซ้อนที่สุดใน Customer Fixed flow — ต้อง cross-reference ไป Requestor flow:

InvitationToken.OriginatingFlowInstanceId → Requestor's FlowInstance
→ อ่าน DesignateCustomersStep ของ Requestor
→ match customer by email
→ resolve companyRole enum
→ update UserCompanyMapping (role + transactionLimit)

8. การตั้งค่า FlowDefinition — ทุก Field มีผลอะไรที่ Runtime

Admin ตั้งค่าเหล่านี้ใน FlowStudio (หรือ DB โดยตรง) และ engine จะ อ่านค่าเหล่านี้ตอน runtime เพื่อปรับพฤติกรรม:

Settingค่าตัวอย่างRuntime Effect
RefNoIssueAt = "OnEnterDraft"NEW_REQUESTORออกเลขที่ใบสมัครตอน SaveDraft ครั้งแรก — ถ้า external service ล้มเหลว → RefNoPending=trueRefNoRetryWorker retry ทุก 5 นาที
RefNoIssueAt = "OnEnterSubmitted"ออกเลขที่ตอน Submit — ถ้าไม่มี RefNo ตอน Submit (และ RefNoIssueAt != null) → throw Application.RefNoUnavailable → 400
RefNoIssueAt = nullCUSTOMER_FIXEDไม่ออกเลขที่เลย — Submit gate ข้ามการ check
StaleDaysBeforeAbandon = 90AutoAbandonWorker (ทุก 6 ชม.) scan Draft instances ที่ไม่มีการแก้ >90 วัน → Abandon()
StaleDaysBeforeAbandon = nullปิด auto-abandon
SpawnsCustomerFlowCode = "CUSTOMER_FIXED_ONBOARDING"NEW_REQUESTORGenerateCustomerInvitations อ่านค่านี้เพื่อกำหนด FlowCode ใน InvitationToken
SpawnsCustomerFlowCode = nullGenerateCustomerInvitations จะสร้าง token แต่ไม่รู้ว่าต้องให้ customer เริ่ม flow อะไร
SessionTtlSeconds = 86400ทุก flowEditSession cookie หมดอายุใน 1 วัน — sliding refresh ทุก action จะ reset ExpiresAt
InvitationTtlDays = 7CUSTOMER_FIXEDInvitationToken หมดอายุใน 7 วัน — link ที่ส่งใน email ใช้ได้แค่ 7 วัน
TemplateKey = "invitation_fx"CUSTOMER_FIXEDSendInvitationEmailsHandler ส่ง event ไป NotificationService พร้อม template key นี้
FrontendBaseUrl = "https://app.exim.go.th"ใช้ build invitation link: {FrontendBaseUrl}/onboarding?code={tokenId}
IsActive = falseengine block การ Start instance ใหม่ — instance เก่ายังวิ่งต่อได้

9. Database — ทุก Table ทุก Field

FlowInstance เป็นศูนย์กลาง ผูกกับทุกตารางอื่น section นี้อธิบายทุก field ว่าเก็บอะไร เพื่ออะไร และ null ได้ไหม

FlowDefinitions — template ของ flow (admin config)

FieldTypeค่าว่างได้?อธิบาย
Iduuid PKรหัสระบุ FlowDefinition — ใช้ FK ใน FlowSteps + FlowInstances
Codetextชื่อย่อของ flow เช่น "STANDARD_CUSTOMER_ONBOARDING" — Frontend ส่ง code นี้ใน POST /onboarding/instances เพื่อบอกว่าจะเริ่ม flow ไหน
Nametextชื่อเต็มสำหรับแสดงใน Admin UI
ServiceIduuidnull = platform-level (ทุก service ใช้ได้) · มีค่า = ผูกกับ Service เฉพาะเช่น FX
RefNoIssueAttextบอกว่าต้องออกเลขที่เอกสารเมื่อไร: null = ไม่ออกเลย · "OnEnterDraft" = ออกตอน SaveDraft ครั้งแรก · "OnEnterSubmitted" = ออกตอน Submit
StaleDaysBeforeAbandonintกี่วันถ้า instance ถูกทิ้งไว้แล้ว AutoAbandonWorker จะเปลี่ยนเป็น Abandoned อัตโนมัติ · null = ปิด feature นี้
SpawnsCustomerFlowCodetextหลัง approve แล้วให้สร้าง flow อีกตัวให้ customer อัตโนมัติ เช่น NEW_REQUESTOR approve → spawn CUSTOMER_FIXED_ONBOARDING ให้ customer แต่ละคน
TemplateKeytexttemplate key สำหรับ Email service ใช้ส่งอีเมลคำเชิญ
FrontendBaseUrltextBase URL ของ Frontend เพื่อ build invitation link ในอีเมล เช่น https://app.exim.go.th
InvitationTtlDaysintinvitation token มีอายุกี่วัน
SessionTtlSecondsintEditSession cookie หมดอายุในกี่วินาที (default 86400 = 1 วัน)
IsActiveboolfalse = ปิดรับ instance ใหม่ (instance เก่ายังวิ่งต่อได้)
StaleSubmittedWarnDaysintเตือน admin ถ้า instance ค้าง Submitted นาน N วัน

StepCatalogs — ทะเบียน step type ทั้งหมด

FieldTypeค่าว่างได้?อธิบาย
Iduuid PKรหัส
StepTypeCodetextชื่อที่เป็น contract เช่น "PersonalInfoStep" — ต้องตรงกัน 3 ที่: ที่นี่ = handler.StepType = FE STEP_REGISTRY key (ผิดตัวอักษรเดียว engine หา handler ไม่เจอ)
DisplayNametextชื่อสำหรับแสดงใน Admin UI
Descriptiontextคำอธิบายสั้นๆ
ExecutionModeint0=Interactive รอ user input · 1=Automatic engine run เอง ไม่ผ่าน user
SkipScopeintskip eligibility: 0=Never · 1=PerUser · 2=PerUserPerCompany · 3=PerUserPerCompanyPerSvc ⚠️ ยังไม่ enforce (Phase 2)
LockedAfterFirstExecutionbooltrue = ทำซ้ำไม่ได้ เช่น OTP, NDID ⚠️ ยังไม่ enforce (Phase 2)
DependenciesJsontextJSON array ของ step ที่ต้องทำก่อน เช่น [{"StepTypeCode":"JuristicBindingStep","Mode":"Required"}] ⚠️ ยังไม่ validate (Phase 2)
MaxRetriesintAutomatic step: retry กี่ครั้งก่อนถือว่า stuck (default 0)
RetryBackoffSecondsintรอกี่วินาทีระหว่าง retry (default 30)

FlowSteps — ลำดับ step ใน flow

FieldTypeค่าว่างได้?อธิบาย
Iduuid PKรหัส
FlowDefinitionIduuid FKผูกกับ FlowDefinition ไหน
StepCatalogIduuid FKผูกกับ StepCatalog ไหน
Orderintลำดับใน flow — engine เรียงตาม Order นี้ ห้ามซ้ำกันใน flow เดียวกัน เปลี่ยน Order = เปลี่ยนลำดับ step ใน DB โดยไม่ต้อง deploy
StepTypetextdenormalize จาก StepCatalog.StepTypeCode — เก็บซ้ำเพื่อให้ Snapshot build เร็วโดยไม่ต้อง join
CanGoBackbooltrue = user กด Back กลับมา step นี้ได้

FlowInstances — 1 execution จริงของ flow

ตารางนี้สำคัญที่สุด — ทุก action ของ user อ่านและเขียนที่นี่:

FieldTypeค่าว่างได้?อธิบาย
Iduuid PKรหัสที่ Frontend เก็บไว้ใช้ทุก API call ตลอด session
FlowDefinitionIduuid FKสร้างมาจาก FlowDefinition ไหน
Statusvarcharสถานะปัจจุบัน — state machine: DraftSubmittedApprovedFinalized / Rejected / CorrectionRequested / Abandoned
OwnerUserIduuidnullablenull = anonymous (ยังไม่ผ่าน OTP) · มีค่า = หลัง OtpVerificationStep เรียก instance.BindOwner() — เป็นหลักประกันว่า auth ผ่านแล้ว ถ้า null ต้องใช้ EditSession cookie ยืนยันแทน
CurrentStepIndexintเหมือน bookmark — index ใน FlowSnapshotJson.steps array ที่กำลัง active อยู่ · engine อ่านตัวนี้ทุก request เพื่อรู้ว่าต้อง execute step ไหนต่อ
FlowSnapshotJsonjsonbสำคัญที่สุด — สำเนา FlowDefinition+Steps แช่แข็งตอนที่ instance ถูกสร้าง ทำให้แก้ FlowDefinition ทีหลังไม่กระทบ instance ที่วิ่งอยู่ — instance ต้องจบตาม “สัญญาเดิม”
CompanyIduuidบริษัทที่ผูกกับ instance — null ก่อน JuristicBindingStep · ใช้ uniqueness check: ไม่ให้ apply ซ้ำใน company+service เดิม
ServiceIduuidService ที่ instance สังกัด เช่น FX service Id
StuckAtStepTypevarcharเมื่อ Automatic step fail: engine เขียน StepTypeCode ไว้ที่นี่ แทนที่จะ crash ทั้งระบบ · StuckChainRecoveryWorker scan ทุก 15 นาทีแล้ว retry
RefNovarcharเลขที่ใบสมัคร/เอกสาร ออกโดย Centralize Document Service · null จนกว่าถึงเวลา issue ตาม FlowDefinition.RefNoIssueAt
RefNoPendingbooltrue = request ออก RefNo แล้วแต่ external service ล้มเหลว → RefNoRetryWorker retry ทุก 5 นาทีโดยอัตโนมัติ
Archivedboolsoft-delete flag — ซ่อนจาก list แต่ไม่ลบ record จริงเพื่อ audit trail
SubmittedAttimestampเวลา Submit — ใช้ track SLA ว่าแต่ละ flow ใช้เวลาเท่าไร
FinalizedAttimestampเวลา Finalized — ใช้ analytics

FlowInstanceStepData — output ของแต่ละ step

FieldTypeค่าว่างได้?อธิบาย
FlowInstanceIduuidComposite PK ส่วนที่ 1
StepTypevarcharComposite PK ส่วนที่ 2 — ผลคือ 1 row ต่อ (instance, stepType) handler UpsertAsync ทำให้ทำซ้ำได้
DataJsonjsonboutput ที่ handler คืนมา เก็บเป็น JSON raw · schema เป็นของแต่ละ handler (engine ไม่รู้ structure) · handler อื่นอ่านผ่าน ctx.StepData["StepType"]
IsSkippedbooltrue = ข้อมูล copy มาจาก record เก่า (skip pattern Phase 2)
CompletedAttimestampเวลาที่ step execute สำเร็จ

EditSessions — anonymous session ก่อน bind owner

FieldTypeค่าว่างได้?อธิบาย
Iduuid PKรหัส
SessionIdvarcharrandom token — เก็บใน __Host-onboarding cookie ของ browser · ใช้ยืนยันตัวตนแทน JWT สำหรับ anonymous user ก่อน OTP (กัน IDOR โดยไม่ต้องมี JWT)
FlowInstanceIduuid FKsession นี้ผูกกับ instance ไหน
UserIduuidnull = anonymous · มีค่า = หลัง OTP verify สำเร็จ (OtpVerificationStep rotate session + stamp UserId)
Emailvarcharemail ที่ user กรอกตอนเริ่ม flow — OtpVerificationStep ใช้ส่ง OTP
IsActiveboolfalse = หมดอายุหรือถูก rotate (สร้าง session ใหม่หลัง OTP สำเร็จ — anti session-fixation)
ExpiresAttimestampเวลาหมดอายุ ตาม FlowDefinition.SessionTtlSeconds

InvitationTokens — one-time token สำหรับ invite customer

FieldTypeค่าว่างได้?อธิบาย
Idtext PKopaque token string — ส่งใน URL ?code=<token> ของ invitation link
Emailtextemail ของ customer ที่ถูก invite
FlowCodetextflow ที่ customer จะเริ่มเมื่อ click invitation link เช่น CUSTOMER_FIXED_ONBOARDING
StatustextPENDINGCONSUMED / REVOKED / EXPIRED
CreatedByAdminIduuidAdmin ที่สร้าง invitation
ExpiresAttimestampวันหมดอายุ — link เก่ากว่านี้ใช้ไม่ได้
ConsumedAttimestampเมื่อ customer ใช้ token สำเร็จ
CompanyIduuidบริษัทที่ customer จะ join
OriginatingFlowInstanceIduuidFlowInstance ของ Requestor ที่สร้าง invitation — ProvisionCustomerAccess (Automatic step) ใช้อ่าน DesignateCustomersStep ของ Requestor เพื่อ set role/limit ให้ customer
ConsumedByFlowInstanceIduuidFlowInstance ของ customer ที่ใช้ token นี้แล้ว — ใช้ track lineage

CorrectionRequests — ประวัติคำขอแก้ไข (append-only)

FieldTypeค่าว่างได้?อธิบาย
Iduuid PKรหัส
FlowInstanceIduuid FKinstance ที่ถูกขอให้แก้ไข
ApprovalDecisionIduuidรหัสจาก approval workflow — unique index ป้องกันเพิ่ม correction request ซ้ำ (idempotent)
NoteTextvarcharข้อความอธิบายจาก approver ว่าต้องการให้แก้อะไร
TargetStepsJsonjsonbรายชื่อ StepTypeCode ที่ปลดล็อกให้แก้ได้ · step อื่นที่ไม่อยู่ใน list จะเป็น read-only ระหว่างแก้ไข
RequestedAttimestampเวลาที่ approver ส่ง correction request
RequestedFromStatusvarcharstatus ณ ตอนที่ correction ถูกขอ

8. Use Cases / Scenarios

A · Happy path (STANDARD_CUSTOMER_ONBOARDING) — เดินครบจน Finalized

OtpVerificationStep ConsentStep NdidStep PersonalInfoStep SubmitRegistrationStep CreatePlatformUser ActivateEntraUser Finalized

B · Validation fail → field ว่าง → handler Fail("Step.RequiredField", "...") → engine throw FlowEngineException → catch → 400 + ข้อความไทย (ผู้ใช้เข้าใจ ไม่ใช่ SYS001)

C · Unhandled exceptionObjectDisposedException (เคสที่แก้ใน sprint) → หลุดถึง GlobalExceptionHandler500 SYS001 generic — แต่ stack trace ถูก log ฝั่ง server (มี traceId ใน problem+json)

D · Resume จาก cookie → เปิด /onboarding ใหม่ → GET /onboarding/session:

// resume เฉพาะ flow ที่ยัง in-progress; instance ที่จบ/ส่งแล้ว = "ไม่มี session" → 404 → FE เริ่มใหม่
if (instance.Status is not (FlowInstanceStatus.Draft or FlowInstanceStatus.CorrectionRequested))
    return NoSession();

9. วิธีเพิ่ม Step ใหม่ — สรุปย่อ

การเพิ่ม step ใหม่แตะ 4 จุด backend + 2 จุด frontend และทั้งหมดผูกกันด้วย string เดียวคือ stepType:

1· เขียน Handler 2· INSERT StepCatalog 3· INSERT FlowStep 4· Register DI 5· FE component 6· STEP_REGISTRY
// Template handler — StepType ต้องตรงกับ StepCatalog.StepTypeCode และ FE STEP_REGISTRY key เป๊ะ
public sealed class MyNewStepHandler : IFlowStepHandler
{
    public string StepType => "MyNewStep";   // ← string นี้คือ contract ทั้งหมด

    public Task<FlowStepExecutionResult> ExecuteAsync(FlowStepExecutionContext ctx, CancellationToken ct = default)
    {
        using var doc = JsonDocument.Parse(ctx.InputJson ?? "{}");
        var root = doc.RootElement.TryGetProperty("stepData", out var sd) ? sd : doc.RootElement;
        // validate ที่นี่ — อ่านค่าให้เสร็จก่อน doc ถูก dispose
        var val = GetStr(root, "requiredField");
        if (string.IsNullOrWhiteSpace(val))
            return Task.FromResult(FlowStepExecutionResult.Fail("Step.RequiredField", "กรุณากรอกข้อมูล"));

        // อ่าน output ของ step อื่นผ่าน StepType (ไม่ใช่ index)
        var prev = ctx.StepData.GetValueOrDefault("JuristicBindingStep");

        var output = JsonSerializer.Serialize(new { val });
        return Task.FromResult(FlowStepExecutionResult.Ok(output));
    }
}

10. สรุปสิ่งที่ทำใน Sprint นี้

  • engine generic (FlowEngine) — Start / ExecuteAction / AdvanceAutomatic + state machine (Draft→Submitted→Approved→Finalized/…)
  • endpoint เดียวขับทุก step (POST .../steps/{stepType}/actions/{action}) + guard 2 ทาง (owner / EditSession)
  • handler ต่อ step (Consent / NDID / PersonalInfo / Otp identity / Automatic chain)
  • error model — Fail()→FlowEngineException→400 ไทย · unhandled→500 SYS001 (+log)
  • resume guard — instance ที่จบแล้วไม่ resume (404)
  • บั๊กที่แก้ — PersonalInfoStep ObjectDisposedException (JsonElement หลุด using scope) → 500 หาย