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 นี้
สิ่งที่เปลี่ยนคือ:
- ขยาย recent transactions จาก 36 เดือนเป็น 48 เดือน
- เพิ่ม method ใน
MemberEntityสำหรับคำนวณ Accum Tier Logic ตามกฎใหม่ - แก้
addNewRefundSalesTransaction()ให้ตัดสิน down-tier ตามกฎใหม่ - เพิ่ม 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 และโหลด aggregateMemberEntityเป็นคนคำนวณ state ใหม่MemberRepository.updateTier()เป็นคน persist state กลับ DB
ดังนั้น requirement ใหม่ที่เปลี่ยนเฉพาะ refund/down-tier จึงเหมาะกับการแก้ใน entity มากที่สุด ไม่จำเป็นต้องขยับไป schema-driven หรือ workflow-driven ตั้งแต่รอบแรก
Current Assumptions
Solution A ใช้ premise ต่อไปนี้:
tierStartedAtใช้เป็น up-tier date ของรอบ tier ปัจจุบันได้- recent transactions ต้องเก็บ 48 เดือน เพื่อรองรับ
- 24 เดือนก่อน up-tier date
- transactions หลัง up-tier date จนถึงปัจจุบัน
- recent 48 เดือนต้องพร้อมก่อน deploy ของ logic ใหม่ เพื่อไม่ให้ refund/down-tier path ตัดสินบนข้อมูลย้อนหลังไม่ครบ
- กฎใหม่เปลี่ยนเฉพาะ refund/void/down-tier
- upgrade logic เดิมยังใช้ได้
- inbound events ใช้
memberIdเป็น Kafka key อยู่แล้ว จึงมี same-member ordering สำหรับ event ใน topic เดียวกัน - ปัจจุบัน
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 กลุ่ม:
calculateAccumTierLogicSpending()
ใช้คำนวณยอดตามกฎใหม่:Accum Tier Logic = ยอด 24 เดือนก่อน up-tier date + ทุก transaction หลัง up-tier datecalculateTierAdjustmentWithNewLogic()
ใช้ตัดสินว่า refund ครั้งนี้ยังอยู่ tier เดิม หรือต้อง down-tier ไป tier ไหนcalculatePostUpTierMaintainSpending()
ใช้คำนวณ maintain spending ใหม่หลังเกิด down-tierfindPreviousTierInfo()
ใช้ดึง tier dates จากMemberTierHistoryถ้ามี tier ก่อนหน้าที่ validfindQualifyingTransactionDate()
ใช้ fallback scan หา transaction ที่ทำให้ member เคย qualify ถึง tier เป้าหมาย
3. แก้ addNewRefundSalesTransaction()
นี่คือจุดเปลี่ยนหลักของ Solution A
สิ่งที่เปลี่ยนใน method นี้มี 4 ส่วน:
Guard ใหม่
ถ้า refund ไปโดน transaction ที่เกิดหลัง up-tier date จะไม่ down-tierTier decision ใหม่
เปลี่ยนจาก rolling 24-month เดิม เป็น Accum Tier Logic ใหม่Tier dates ใหม่
ถ้าต้อง down-tier จะไม่ตั้งtierStartedAt = nowทันทีเสมอไป แต่พยายามใช้ tier history หรือ qualifying transaction ก่อน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/voidpath ความเสี่ยง 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 ไม่พอ