Solution A: Entity-Centric

Overview

Solution A คือการแก้กฎ down-tier จาก refund/void โดยรวม logic ใหม่ไว้ใน member.entity.ts เป็นหลัก แล้วใช้ recent transactions ที่โหลดมาอยู่แล้วคำนวณใน memory

แนวคิดของ solution นี้คือ:

  • ไม่เปลี่ยน upgrade logic
  • ไม่เพิ่ม schema field ใหม่
  • ไม่เพิ่ม DB query ใหม่
  • ไม่เปลี่ยน orchestration path ของ service
  • แก้เฉพาะจุดที่ business rule เปลี่ยนจริง คือ refund/void path

ถ้าพูดให้สั้นที่สุด:

Solution A = เก็บ architecture เดิมทั้งหมดไว้
แล้วเปลี่ยนเฉพาะวิธีคิด down-tier ใน entity

Solution A ทำอะไร

ปัจจุบัน refund path ของระบบเป็นแบบนี้:

Kafka Consumer
  -> RefundSalesTransactionService
  -> MemberRepository.findMemberWithRecentSalesTransactionById(memberId)
  -> MemberEntity.addNewRefundSalesTransaction(...)
  -> MemberRepository.updateTier(...)

Solution A จะไม่เปลี่ยน path นี้

สิ่งที่เปลี่ยนคือ:

  1. ขยาย recent transactions จาก 36 เดือนเป็น 48 เดือน
  2. เพิ่ม method ใน MemberEntity สำหรับคำนวณ Accum Tier Logic ตามกฎใหม่
  3. แก้ addNewRefundSalesTransaction() ให้ตัดสิน down-tier ตามกฎใหม่
  4. เพิ่ม test สำหรับ refund/down-tier cases ให้ครอบคลุม

ดังนั้น Solution A ไม่ได้เป็นการ redesign ระบบ แต่เป็นการ เปลี่ยน business logic ใน aggregate เดิม


ทำไม Solution A ถึงเข้ากับ current architecture

เพราะ architecture ปัจจุบันของ loyalty module เอา business decision หลักไว้ที่ MemberEntity อยู่แล้ว โดยเฉพาะการคำนวณ tier หลังมี sales/refund

จุดสำคัญคือ:

  • RefundSalesTransactionService ทำ orchestration และโหลด aggregate
  • MemberEntity เป็นคนคำนวณ state ใหม่
  • MemberRepository.updateTier() เป็นคน persist state กลับ DB

ดังนั้น requirement ใหม่ที่เปลี่ยนเฉพาะ refund/down-tier จึงเหมาะกับการแก้ใน entity มากที่สุด ไม่จำเป็นต้องขยับไป schema-driven หรือ workflow-driven ตั้งแต่รอบแรก


Current Assumptions

Solution A ใช้ premise ต่อไปนี้:

  1. tierStartedAt ใช้เป็น up-tier date ของรอบ tier ปัจจุบันได้
  2. recent transactions ต้องเก็บ 48 เดือน เพื่อรองรับ
    • 24 เดือนก่อน up-tier date
    • transactions หลัง up-tier date จนถึงปัจจุบัน
  3. recent 48 เดือนต้องพร้อมก่อน deploy ของ logic ใหม่ เพื่อไม่ให้ refund/down-tier path ตัดสินบนข้อมูลย้อนหลังไม่ครบ
  4. กฎใหม่เปลี่ยนเฉพาะ refund/void/down-tier
  5. upgrade logic เดิมยังใช้ได้
  6. inbound events ใช้ memberId เป็น Kafka key อยู่แล้ว จึงมี same-member ordering สำหรับ event ใน topic เดียวกัน
  7. ปัจจุบัน sales อยู่คนละ topic กับ refund/void โดย refund และ void อยู่ topic เดียวกัน

ต้องปรับแก้อะไรบ้าง

1. ขยาย recent transaction window เป็น 48 เดือน

ไฟล์หลัก:

  • src/modules/loyalty/services/recent-sales-transaction.service.ts

สิ่งที่ทำ:

  • เปลี่ยน retention จาก accumulateThresholdMonth + 12
  • เป็น accumulateThresholdMonth + 24

ความหมายเชิง business:

  • เดิมเก็บไว้พอสำหรับ rolling 24 เดือน + buffer บางส่วน
  • ใหม่ต้องเก็บให้พอสำหรับกฎ down-tier ที่ต้องมองย้อน 24 เดือนก่อน up-tier date และยังต้องเห็น transaction หลัง up-tier ด้วย

สิ่งที่ต้องมีเพิ่มเติม:

  • one-time backfill สำหรับ recent sales/refund ช่วง 36-48 เดือน

จุดนี้เป็น rollout dependency ของ Solution A โดยตรง ไม่ใช่ optional optimization

2. เพิ่ม method ใหม่ใน MemberEntity

ไฟล์หลัก:

  • src/modules/loyalty/domains/entities/member.entity.ts

method ที่ต้องเพิ่มมี 5 กลุ่ม:

  1. calculateAccumTierLogicSpending()
    ใช้คำนวณยอดตามกฎใหม่:

    Accum Tier Logic = ยอด 24 เดือนก่อน up-tier date
                     + ทุก transaction หลัง up-tier date
    
  2. calculateTierAdjustmentWithNewLogic()
    ใช้ตัดสินว่า refund ครั้งนี้ยังอยู่ tier เดิม หรือต้อง down-tier ไป tier ไหน

  3. calculatePostUpTierMaintainSpending()
    ใช้คำนวณ maintain spending ใหม่หลังเกิด down-tier

  4. findPreviousTierInfo()
    ใช้ดึง tier dates จาก MemberTierHistory ถ้ามี tier ก่อนหน้าที่ valid

  5. findQualifyingTransactionDate()
    ใช้ fallback scan หา transaction ที่ทำให้ member เคย qualify ถึง tier เป้าหมาย

3. แก้ addNewRefundSalesTransaction()

นี่คือจุดเปลี่ยนหลักของ Solution A

สิ่งที่เปลี่ยนใน method นี้มี 4 ส่วน:

  1. Guard ใหม่
    ถ้า refund ไปโดน transaction ที่เกิดหลัง up-tier date จะไม่ down-tier

  2. Tier decision ใหม่
    เปลี่ยนจาก rolling 24-month เดิม เป็น Accum Tier Logic ใหม่

  3. Tier dates ใหม่
    ถ้าต้อง down-tier จะไม่ตั้ง tierStartedAt = now ทันทีเสมอไป แต่พยายามใช้ tier history หรือ qualifying transaction ก่อน

  4. Maintain spending ใหม่
    ไม่ reset เป็น 0 แบบเดิม แต่ recalculation ตามกฎใหม่

4. เพิ่ม test coverage

ไฟล์หลัก:

  • test/modules/loyalty/domain/entities/member.entity.spec.ts

test ใหม่ควรครอบคลุมอย่างน้อย:

  • refund หลัง up-tier ไม่ down-tier
  • refund ก่อน up-tier แต่ยังอยู่ tier เดิม
  • refund แล้วลง tier ก่อนหน้าได้จาก valid history
  • refund แล้วต้อง fallback หา qualifying transaction
  • refund แล้วลงเป็น NAVY
  • maintain spending หลัง down-tier
  • DEDUCT path ที่สุดท้ายวิ่งเข้า refund logic เดียวกัน

Solution A improve อะไรบ้าง

1. เปลี่ยนน้อย แต่แก้ถูกจุด

ข้อดีใหญ่สุดของ A คือมันแก้ตรง root ของ requirement คือ refund/down-tier logic โดยไม่ลากระบบส่วนอื่นให้เปลี่ยนตาม

พูดอีกแบบคือ:

  • requirement เปลี่ยนตรง refund decision
  • Solution A ก็ไปเปลี่ยนตรง refund decision

ไม่ได้เพิ่ม schema, ไม่ได้เพิ่ม workflow, ไม่ได้เพิ่ม derived field ที่ต้อง maintain ทุก flow

2. ไม่มี schema migration

อันนี้สำคัญมาก เพราะตัด complexity ออกไปหลายอย่าง:

  • ไม่ต้อง add column
  • ไม่ต้อง backfill member field ใหม่
  • ไม่ต้องแก้ mapper/report/export surface
  • ไม่ต้อง coordinate schema-first rollout

แต่ยังต้องย้ำว่า A ยังมี data rollout สำหรับ recent 48 เดือนเหมือนกัน เพียงแค่ไม่มี member field initialization เพิ่มเข้ามาอีกชั้น

3. ไม่มี DB query เพิ่ม

Solution A ใช้ข้อมูลที่ findMemberWithRecentSalesTransactionById() โหลดมาอยู่แล้ว

ดังนั้น refund hot path ไม่ได้เพิ่ม round-trip ไป DB เพิ่มจากเดิม เพียงแต่ใช้ CPU ใน memory มากขึ้น

4. ไม่กระทบ flow อื่นมาก

flow ที่เปลี่ยนจริงมีหลักๆ แค่:

  • Flow 2: Refund/Void
  • Flow 6 DEDUCT ที่สุดท้ายวิ่งเข้า refund service

ส่วน sales upgrade, maintain tier, reconcile, co-brand, staff exit, import member ยังอยู่บน path เดิม

5. Current Kafka ordering ช่วยเรื่อง same-member inbound path อยู่แล้ว

ใน current setup inbound event ใช้ memberId เป็น key และมี partition ordering ของ Kafka ช่วยอยู่แล้วสำหรับ event ของ member เดียวกันใน topic เดียวกัน

และจาก topology ปัจจุบัน:

  • refund กับ void อยู่ topic เดียวกัน
  • sales อยู่อีก topic หนึ่ง

ความหมายคือ:

  • ordering ระหว่าง refund กับ void ของ member เดียวกันถูกคุมได้ดีกว่า เพราะอยู่ใน topic เดียวกัน
  • แต่ ordering ระหว่าง sales กับ refund/void ยังเป็นเรื่องข้าม topic ซึ่ง Kafka ไม่ได้ guarantee ให้โดยตรง

ดังนั้น Solution A ไม่ได้เริ่มจากสภาวะที่ same-member inbound events ชนกันมั่วๆ โดย default


ตรงไหนหน้า concern

1. Logic ใน entity จะยาวและซับซ้อนขึ้น

นี่เป็น concern จริงของ Solution A

เพราะ addNewRefundSalesTransaction() จะกลายเป็น method ที่รับผิดชอบ business branch เยอะขึ้นมาก เช่น:

  • guard ว่า refund หลัง up-tier หรือไม่
  • คำนวณ accum ใหม่
  • ตัดสิน tier ใหม่
  • หา tier dates จาก history หรือจาก transaction
  • คำนวณ maintain spending ใหม่

ผลคือ code อ่านยากขึ้นถ้าไม่จัด method ย่อยให้ดี

2. CPU cost ใน memory จะเพิ่มขึ้น

Solution A ใช้การ iterate/filter/reduce transaction arrays หลายรอบมากขึ้น โดยเฉพาะ refund case ที่เกิด down-tier จริง

ดังนั้น cost หลักของ A คือ:

  • ไม่ใช่ DB query เพิ่ม
  • แต่เป็น in-memory calculation เพิ่ม

อธิบายแบบตรงไปตรงมา:

  • request หนึ่งเข้ามาแล้วระบบต้อง “อ่าน” transaction ของ member คนนั้นหลายรอบ
  • จึงมีแนวโน้มทำให้ request ใช้เวลานานขึ้น และใช้ CPU มากขึ้น
  • แต่ไม่ได้หมายความว่าระบบจะสร้างข้อมูลใหม่ขนาดใหญ่มากจน memory พุ่งทันที

ดังนั้นเวลาเรียกว่า concern หลักคือ CPU/time complexity ต่อ request ความหมายคือ:

  • refund request อาจช้าลง เพราะต้องคำนวณหลาย step
  • p95/p99 latency อาจสูงขึ้นถ้า member มี transaction เยอะ
  • pod อาจใช้ CPU สูงขึ้นในช่วงที่มี refund หนาแน่น

ไม่ใช่ความหมายว่า:

  • Node process จะ heap เต็มง่ายๆ
  • หรือจะ out-of-memory โดยตรงจาก logic นี้เป็น default case

3. Recent window โตจาก 36 เป็น 48 เดือน

แปลว่า aggregate ที่โหลดต่อ member จะใหญ่ขึ้น

ผลกระทบมี 2 ด้าน:

  • memory ต่อ request สูงขึ้น
  • การ iterate ต่อ request ใช้เวลามากขึ้น

4. Cross-topic / non-Kafka path ยังเป็น residual risk

แม้ inbound Kafka path จะมี ordering ตาม key อยู่แล้ว แต่ Kafka guarantee นี้ครอบคลุมแค่ event ใน topic/partition เดียว

ใน topology ปัจจุบัน จุดที่ต้องระวังคือ:

  • sales อยู่คนละ topic กับ refund/void
  • ดังนั้น dependency ที่ต้องพึ่งลำดับระหว่าง sale event กับ refund/void event ยังเป็น cross-topic concern
  • แต่ refund กับ void เองอยู่ topic เดียวกัน จึงน่ากังวลน้อยกว่ากรณีที่แยกกันคนละ topic ทั้งหมด

สรุปคือ Solution A ไม่ได้แก้ cross-topic ordering ให้โดยตรง แต่ residual risk ถูกจำกัด scope แคบลงเหลือหลักๆ ที่ความสัมพันธ์ระหว่าง sales กับ refund/void


ตรงไหนไม่น่ากังวลขนาดนั้น

1. เรื่อง OOM ไม่ใช่ concern หลัก

Solution A ไม่ได้โหลด transaction ทั้งระบบเข้ามาใน memory

มันโหลดแค่ recent transactions ของ member เดียวใน request นั้นผ่าน aggregate path เดิม

ดังนั้นถ้าจะมี memory concern จริง มักจะเกิดจาก:

  • member รายเดียวมี recent transactions เยอะผิดปกติมาก
  • และมี concurrent requests ของ member กลุ่มใหญ่จำนวนมากพร้อมกัน

ซึ่งโดยธรรมชาติแล้วไม่ใช่ default case ของระบบ loyalty ทั่วไป

เพราะฉะนั้น concern หลักของ A คือ CPU/time complexity ต่อ request มากกว่า heap explosion

คำว่า heap explosion ในที่นี้หมายถึงกรณีที่ process สร้างหรือถือ object ใน memory มากผิดปกติจน heap โตเร็วและเสี่ยง OOM

แต่สิ่งที่ Solution A ทำส่วนใหญ่คือ:

  • ใช้ array ของ transactions ที่โหลดมาแล้ว
  • วนอ่านข้อมูลเดิมหลายรอบเพื่อคำนวณ
  • สร้าง temporary arrays บ้างจาก filter() หรือ sort() แต่ยังอยู่ในขอบเขตของ member เดียว

ดังนั้นอาการที่น่าจะเจอก่อนมักเป็น:

  • request ช้าลง
  • consumer lag สูงขึ้น
  • CPU usage สูงขึ้น

ไม่ใช่อาการแบบ:

  • memory โตไม่หยุด
  • process โดน kill เพราะ heap เต็มทันที

2. same-member concurrency ใน inbound Kafka path ไม่น่ากลัวเท่าที่คิด

เพราะ current architecture มี A1 อยู่แล้วจากการใช้ memberId เป็น Kafka key

ผลคือ event ของ member เดียวกันใน topic เดียวกันจะถูก partition ordering คุมอยู่ก่อนแล้ว

ดังนั้นถ้าพูดให้ละเอียด:

  • ใน refund/void path ความเสี่ยง same-member race ต่ำกว่าเดิมมาก เพราะสอง event type นี้อยู่ topic เดียวกัน
  • ใน sales เทียบกับ refund/void ยังต้องยอมรับว่าเป็น cross-topic ordering concern

เพราะฉะนั้นเหตุผลหลักที่ต้องระวัง Solution A ไม่ใช่ refund-vs-void race แต่เป็น sale-vs-refund ordering ถ้า business case ต้องพึ่งลำดับข้ามสอง topic นี้อย่างเข้มงวด

3. Performance gap กับ Solution C อาจไม่ significant ในของจริง

ถ้าดูแบบ algorithm อย่างเดียว A ดูแพงกว่า Solution C เพราะยัง iterate in memory

แต่ใน production path จริง ยังมี cost อื่นอยู่แล้ว เช่น:

  • load aggregate จาก DB
  • persist transaction + member update
  • emit events/logs ตาม flow เดิม

ดังนั้นถ้าจำนวน transactions ต่อ member ยังอยู่ในช่วงหลักร้อยถึงหลักพันต้นๆ ความต่างของ in-memory iteration มักยังไม่ใช่ bottleneck ตัวแรกที่ควรกังวล


ถ้าจะ improve A เพิ่ม ยังทำอะไรได้บ้าง

1. ปรับ internal lookup ให้ efficient ขึ้น

ตัวอย่างเช่น:

  • สร้าง map ของ sales transaction by id ไว้ใช้ใน scope ของ calculation
  • ลดการ linear search ซ้ำๆ ของ getSalesTransactionById()

อันนี้ช่วยลด CPU cost ได้โดยไม่เปลี่ยน architecture

2. เพิ่ม profiling / metrics ก่อน optimize ต่อ

ควรวัดจริงหลัง rollout:

  • p95/p99 latency ของ refund path
  • member ที่มี recent transaction count สูงสุด
  • จำนวน down-tier cases จริงใน production

ถ้าตัวเลขยังนิ่ง ก็ไม่มีเหตุผลต้องรีบขยับไป Solution C หรือ initiative orchestration แยกต่างหาก

3. ถ้าอนาคตมี race จาก non-Kafka path จริง ค่อยเพิ่ม guardrail เฉพาะจุด

เช่น:

  • optimistic update ที่ repository layer
  • Redis per-member lock สำหรับ manual/admin path

จุดสำคัญคือไม่ต้องแบก complexity พวกนี้ตั้งแต่รอบแรกถ้ายังไม่มีหลักฐานว่าจำเป็น


File Impact

Files ที่ต้องแก้

src/
├── modules/loyalty/domains/entities/
│   └── member.entity.ts
├── modules/loyalty/services/
│   └── recent-sales-transaction.service.ts

test/
├── modules/loyalty/domain/entities/
│   └── member.entity.spec.ts

Files ที่ไม่ควรต้องแก้ใน Solution A

src/
├── modules/loyalty/services/
│   ├── sales-transaction.service.ts
│   ├── refund-sales-transaction.service.ts
│   ├── maintain-tier.service.ts
│   ├── reconcile-member-tier.service.ts
│   ├── member-co-brand-card.service.ts
│   ├── import-staff-exit.service.ts
│   └── import-member.service.ts
│
├── modules/loyalty/drivers/repositories/
│   └── member.repository.ts
│
├── infrastructure/persistence/prisma/
│   └── schema.prisma

เหตุผลคือ Solution A ตั้งใจให้เป็น local change ที่ไม่ขยาย impact ไปไกลเกิน requirement


Risk Summary

เรื่อง ระดับ เหตุผล
Business logic complexity สูง refund branch ซับซ้อนขึ้นจริง
CPU cost ต่อ refund ปานกลาง iterate มากขึ้นแต่ยังอยู่ใน memory
Memory footprint ต่ำถึงปานกลาง recent window เพิ่มเป็น 48 เดือน
Schema / deployment risk ต่ำ ไม่มี schema migration
Same-member refund/void Kafka race ต่ำ ใช้ memberId key และอยู่ topic เดียวกัน
Sales vs refund/void ordering ปานกลาง ยังเป็น cross-topic residual risk
Non-Kafka race ปานกลาง ยังเป็น residual risk
Regression surface ต่ำ เปลี่ยนเฉพาะ refund/down-tier path

Recommendation

Solution A เหมาะเมื่อเป้าหมายคือ:

  • ส่งกฎ down-tier ใหม่ให้ถูกก่อน
  • เปลี่ยนของให้น้อย
  • ลด deployment complexity
  • จำกัด regression surface

มันอาจไม่ใช่ solution ที่ elegant ที่สุดในเชิง algorithm แต่เป็น solution ที่ สมดุลที่สุดกับ current repository เพราะแก้ตรงจุด, ไม่ลากระบบส่วนอื่นมาพัง, และใช้ architecture ที่มีอยู่แล้วให้คุ้มที่สุด

สรุปแบบ practical:

Solution A ทำให้ระบบคิด down-tier แบบใหม่ได้
โดยยังคง service path เดิม, DB model เดิม, และ deployment model เดิม

ถ้าหลัง rollout พบว่า refund path หนักจริงจาก profiling ค่อยพิจารณายกระดับไป Solution C แต่ไม่ควรจ่าย complexity นั้นตั้งแต่ต้นถ้ายังไม่มี evidence ว่า A ไม่พอ