آلة بيع كلها function واحدة

نفس سؤال الـ LLD المشهور، بس من غير State Pattern ومن غير objects بتغيّر في بعض. الآلة كلها step(machine, event) → [machine, effects]: function نقية بتاخد الحالة وحدث، وبترجّع حالة جديدة وقائمة بالحاجات المطلوب تنفيذها. الصفحة دي بتشغّل نفس الكود اللي عليه التستات، ومش بتخزّن state خالص: بتخزّن الـ events وتعيد تشغيلها.

الكود: github.com/abo3ish/vending-machine

TypeScriptpure functionsdiscriminated unionsfast-checknode:test

Imperative shell · the machine

Welcome

each click = one coin · $1.10 = $1 then 10¢

الـ sensor متمثّل هنا: بعد ما تدوس BUY بثانية، بيبعت event اسمه dispensed (أو jammed لو علّمت فوق). الـ core مش بيعرف إن فيه motor ولا sensor، هو بيستقبل events وخلاص، سواء جاية من الزبون أو من الآلة.

Functional core · step()

select cancel no change insert buy dispensed · jammed idle{ } paying{ slot, inserted } dispensing{ …, change }
time travel

    
        

    التستات: قواعد بدل سيناريوهات

    عشان step نقية، التستات مش محتاجة mocks ولا setup. fast-check بيولّد آلاف الآلات وتسلسلات الـ events العشوائية (عملات وقت الـ dispensing، و sensor بيضرب من غير سبب، وخانات مش موجودة) وبيتأكد إن القواعد دي ثابتة في كل لحظة:

    القاعدةبتمسك إيهتسلسلات
    مفيش عملة بتتخلق ولا بتضيع (لكل فئة لوحدها)باقي غلط، أو refund ناقص، أو escrow ضاع2,000
    الصندوق بيزيد بسعر اللي اتباع بالظبطمنتج نزل ببلاش، أو سعر غلط2,000
    المخزون والعملات عمرهم ما يبقوا بالسالببيع من خانة فاضية، أو باقي من عملات مش موجودة2,000
    مفيش dispense غير لمنتج موجود ومدفوع تمنهأي طريق بيوصل للـ motor من غير فلوس2,000
    نفس الـ events بتطلع نفس التاريخأي اعتماد مخفي على وقت أو random300
    makeChange بيلاقي باقي كل ما يكون فيه حلgreedy بيقول "مفيش باقي" وهو موجود2,000

    للتأكد إنها مش تستات شكلية: لو شلت سطر خصم الباقي من الصندوق، 3 تستات بتقع على طول. ولو شلت أي case من الـ switch، الكومبايلر بيرفض يبني.

    قرارات التصميم، ومقارنتها بالحل الكلاسيكي

    الحل المعتاد للسؤال ده: كلاس VendingMachine فيه selectedProduct و totalCoins و currentState، و State Pattern بأربع حالات (Idle و Ready و Dispense و ReturnChange)، وكل حالة بتعمل machine.setState(new ...). ده كويس، بس فيه حاجات أحسن:

    ليه function واحدة بدل State Pattern؟step(state, event)
    • الآلة finite state machine. والـ FSM في الأساس جدول: (حالة، حدث) ← (حالة جديدة، أفعال). الـ State Pattern بيقطّع الجدول ده على 4 كلاسات، كل واحد ماسك reference للآلة وبيغيّر فيها.
    • الـ switch اترفض في الحل الكلاسيكي بحجة Open/Closed. بس مع discriminated unions، الكومبايلر بيجبرك تتعامل مع كل (حالة، حدث). لو ضفت حالة جديدة ونسيت تتعامل معاها في أي مكان، الكود مش هيبني. ده أأمن من OCP، لأن الحالات هنا مجموعة مقفولة مش plugin system.

    نسخة OOP كويسة ممكن تقرّب جداً من ده: states immutable، وكل method بترجّع الـ state اللي بعدها بدل ما تعمل setState. الفرق اللي بيفضل إن الـ effects بتتنفذ جوه الـ method بدل ما ترجع data.

    ليه كل حالة شايلة بياناتها جواها؟make illegal states unrepresentable
    • لو selectedProduct و currentState حقلين منفصلين، تقدر تكتب آلة في Idle ومعاها منتج مختار. حالة مستحيلة، بس الكود بيسمح بيها، فلازم تفضل فاكرها في كل method.
    • هنا idle مالهاش أي بيانات، و paying معاها slot و inserted. الحالة المستحيلة ماينفعش تتكتب أصلاً.
    type Phase =
      | { tag: 'idle' }
      | { tag: 'paying';     slot; inserted }
      | { tag: 'dispensing'; slot; inserted; change }
    إرجاع الباقي حالة ولا فعل؟effects as data
    • ReturnChangeState مش مكان الآلة بتقف فيه، ده حاجة بتعملها. هنا بقى effect: { tag: 'returnCoins', coins }.
    • الـ core مش بيلمس hardware خالص، بيرجّع قائمة. الـ shell (الصفحة دي، أو driver حقيقي) هو اللي ينفّذ. وده اللي خلّى التستات تقرا الـ effects زي أي قيمة، والصفحة تعملها animation.
    • dispensing حالة حقيقية فعلاً، بس لسبب مختلف: الآلة مستنية تأكيد من الـ sensor، والفلوس محجوزة في escrow لحد ما المنتج ينزل.
    طب لو المنتج اتزنق؟ مين يعمل rollback؟commit on confirmation
    • ولا حد، لأن مفيش حاجة اتغيرت. المخزون والصندوق مش بيتلمسوا غير لما event اسمه dispensed يوصل من الـ sensor.
    • لو وصل jammed، الآلة بترجع idle وبترجّع نفس العملات اللي اتحطت بالظبط. مفيش transaction ولا compensation: الـ atomicity جاية من التصميم نفسه.
    ليه الفلوس عملات مش رقم؟CoinCounts, not totalCoins
    • totalCoins = 135 مش بيقولك تقدر ترجّع 35 قرش ولا لأ. لازم تعرف الصندوق فيه كام عملة من كل فئة. من غير كده حالة "Exact change only" مش هتلاقي ليها مكان في التصميم.
    • الـ greedy (ابدأ بأكبر عملة) بيفشل مع مخزون محدود: 30 قرش من {25، 10، 10، 10} مفيش 5 تكمّل بيها الـ 25. makeChange بيبدأ greedy وبيرجع خطوة لو اتزنق، فبيلاقي 10+10+10. شوف سيناريو "باقي صعب".
    • السعر integer بالقروش، لأن 0.1 + 0.2 !== 0.3.
    المخزون بالمنتج ولا بالخانة؟stock per slot
    • التمييز بين الـ position والـ product ID صح، بس المخزون الحقيقي بيتعد بالـ خانة. ممكن نفس المنتج يبقى في خانتين، وكل خانة بتفضى لوحدها.
    والـ concurrency و thread safety؟one event at a time
    • الآلة بيقف قدامها زبون واحد. الـ events بتعدّي على step واحد ورا التاني، والـ core مفيهوش shared mutable state، فمفيش حاجة تتقفل عليها أصلاً.
    • لو الآلة اتحطت ورا API، يبقى الـ lock وظيفة الـ shell: event واحد في المرة على كل آلة. الـ core مش هيتغير.
    إزاي الصفحة بتعمل time travel؟state = replay(events)
    • الصفحة مش بتخزّن الآلة. بتخزّن الآلة اللي بدأت بيها وقائمة الـ events، ومع كل رسمة بتنادي run(start, events) وتعرض اللحظة اللي إنت واقف عليها.
    • ارجع لأي لحظة واكبس أي زرار، هيتعمل فرع جديد من النقطة دي. ده event sourcing ببلاش، و undo ببلاش، وأي bug report ممكن يبقى مجرد قائمة events تعيد تشغيلها.