รูปแบบ Repository CRUD ด้วย Go generics สำหรับชั้นข้อมูล Go ที่สะอาด
เรียนรู้รูปแบบ repository CRUD ด้วย Go generics แบบปฏิบัติได้ เพื่อใช้ซ้ำ logic ของ list/get/create/update/delete ด้วยข้อจำกัดอ่านง่าย ไม่มี reflection และโค้ดชัดเจน

ทำไม repository CRUD ถึงยุ่งเหยิงใน Go
CRUD repositories เริ่มจากเรียบง่าย คุณเขียน GetUser แล้ว ListUsers แล้วก็ทำแบบเดียวกันสำหรับ Orders และ Invoices ไปเรื่อยๆ ไม่กี่เอนทิตีต่อมา ชั้นข้อมูลก็กลายเป็นกองสำเนาที่คล้ายกันซึ่งข้อแตกต่างเล็กน้อยง่ายที่จะพลาด
สิ่งที่ซ้ำกันบ่อยที่สุดไม่ใช่ SQL เอง แต่เป็น flow รอบๆ: รันคิวรี, สแกนแถว, จัดการ “ไม่พบ”, แปลงข้อผิดพลาดของฐานข้อมูล, ตั้งค่าเริ่มต้นการแบ่งหน้า และแปลงอินพุตเป็นชนิดที่ถูกต้อง
จุดที่มักเป็นปัญหาคุ้นเคย: โค้ด Scan ที่ซ้ำกัน, รูปแบบ context.Context และ transaction ที่ซ้ำ, โค้ดบอยเลอร์เพลตสำหรับ LIMIT/OFFSET (บางครั้งรวมการนับทั้งหมด), การเช็ก "0 แถวหมายความว่าไม่พบ" เดิม ๆ และรูปแบบ INSERT ... RETURNING id ที่ก็อปปี้กันไปมา
เมื่อการทำซ้ำเริ่มทำให้เจ็บ ทีมหลายทีมมักหันไปใช้ reflection ซึ่งสัญญาว่า "เขียนครั้งเดียว" CRUD: เอา struct ใดก็ได้มาเติมจากคอลัมน์ตอน runtime แต่ต้นทุนจะปรากฏทีหลัง โค้ดที่ใช้ reflection มากอ่านยากลง, การสนับสนุนใน IDE แย่ลง, และความล้มเหลวย้ายจากเวลา compile มาที่ runtime การเปลี่ยนแปลงเล็กๆ เช่น การเปลี่ยนชื่อฟิลด์หรือการเพิ่มคอลัมน์ที่เป็น nullable จะกลายเป็นความประหลาดใจที่เห็นได้แค่ตอนทดสอบหรือในโปรดักชัน
การใช้ซ้ำแบบ type-safe หมายถึงการแชร์ flow ของ CRUD โดยไม่สละความสะดวกประจำวันของ Go: ซิกเนเจอร์ชัดเจน, ชนิดตรวจโดยคอมไพเลอร์, และ autocomplete ที่ช่วยได้จริง ๆ ด้วย generics คุณสามารถใช้ซ้ำการดำเนินการอย่าง Get[T] และ List[T] ในขณะที่ยังคง要求ให้แต่ละเอนทิตีให้สิ่งที่ไม่สามารถเดาได้ เช่น วิธีสแกนแถวเป็น T
รูปแบบนี้ตั้งใจมาสำหรับชั้นการเข้าถึงข้อมูล มันทำให้ SQL และการแม็ปสอดคล้องและน่าเบื่อ มันไม่ได้พยายามจำลองโดเมนของคุณ, บังคับกฎธุรกิจ, หรือแทนที่ตรรกะระดับบริการ
เป้าหมายการออกแบบ (และสิ่งที่ไม่ได้พยายามแก้)
รูปแบบ repository ที่ดีทำให้การเข้าถึงฐานข้อมูลทั่วไปคาดเดาได้ คุณควรอ่าน repository แล้วเห็นได้อย่างรวดเร็วว่ามันทำอะไร, รัน SQL ไหน, และคืนข้อผิดพลาดอะไรได้บ้าง
เป้าหมายนั้นเรียบง่าย:
- ความปลอดภัยแบบชนิดข้อมูลเต็มทาง (IDs, เอนทิตี, และผลลัพธ์ไม่ใช่
any) - ข้อจำกัดที่อธิบายเจตนาโดยไม่ต้องเล่นกลชนิด
- บอยเลอร์เพลตน้อยลงโดยไม่ซ่อนพฤติกรรมสำคัญ
- พฤติกรรมสอดคล้องกันข้าม List/Get/Create/Update/Delete
สิ่งที่ไม่ใช่เป้าหมายสำคัญไม่แพ้กัน นี่ไม่ใช่ ORM มันไม่ควรเดาการแม็ปฟิลด์, join ตารางอัตโนมัติ, หรือเปลี่ยนคิวรีแบบเงียบๆ "การแม็ปเวทย์มนตร์" จะผลักคุณกลับไปหาการใช้ reflection, tags, และ edge cases
สมมติ workflow SQL ปกติ: SQL ชัดเจน (หรือ query builder บางๆ), ขอบเขต transaction ชัดเจน, และข้อผิดพลาดที่คุณพิจารณาได้ เมื่อล้มเหลว ข้อผิดพลาดควรบอกว่า "ไม่พบ", "conflict/constraint violation" หรือ "DB ไม่พร้อม" ไม่ใช่ "repository error" ที่คลุมเครือ
การตัดสินใจสำคัญคืออะไรที่เป็น generic กับอะไรที่ยังคงเฉพาะต่อเอนทิตี
- Generic: flow (รันคิวรี, สแกน, คืนค่า typed, แปลงข้อผิดพลาดทั่วไป)
- ต่อเอนทิตี: ความหมาย (ชื่อตาราง, คอลัมน์ที่เลือก, joins, และสตริง SQL)
การพยายามบังคับให้เอนทิตีทุกตัวใช้ระบบฟิลเตอร์สากลมักทำให้โค้ดอ่านยากกว่าการเขียนคิวรีที่ชัดเจนสองแบบ
การเลือกข้อจำกัดของเอนทิตีและ ID
โค้ด CRUD ซ้ำเพราะทุกตารางมีการเคลื่อนไหวพื้นฐานแบบเดียวกัน แต่ทุกเอนทิตีมีฟิลด์ของตัวเอง ด้วย generics เทคนิคคือแชร์รูปทรงเล็กๆ และปล่อยให้ส่วนอื่นเป็นอิสระ
เริ่มจากตัดสินใจว่า repository ต้องรู้เรื่องอะไรบ้างเกี่ยวกับเอนทิตี สำหรับหลายทีม ชิ้นสากลเดียวคือ ID timestamp อาจมีประโยชน์แต่ไม่ใช่สากล และการบังคับให้มันอยู่ในทุกชนิดมักทำให้โมเดลรู้สึกไม่เป็นธรรมชาติ
เลือกชนิด ID ที่คุณอยู่กับมันได้
ชนิด ID ควรตรงกับวิธีที่คุณระบุแถวในฐานข้อมูล บางโปรเจคใช้ int64 อื่นๆ ใช้ UUID string ถ้าคุณอยากมีแนวทางเดียวที่ใช้ข้ามเซอร์วิส ให้ทำให้ ID เป็น generic ถ้าความฐานโค้ดของคุณใช้ชนิด ID เดียว การเก็บให้คงที่อาจย่นซิกเนเจอร์ได้
ข้อจำกัดเริ่มต้นที่ดีสำหรับ ID คือ comparable เพราะคุณจะเปรียบเทียบ ID, ใช้เป็นคีย์ใน map, และส่งต่อไปรอบๆ
type ID interface {
comparable
}
type Entity[IDT ID] interface {
GetID() IDT
SetID(IDT)
}
เก็บข้อจำกัดของเอนทิตีให้ขั้นต่ำ
หลีกเลี่ยงการบังคับฟิลด์ผ่านการฝัง struct หรือกลเม็ด type-set อย่าง ~struct{...} แม้จะดูทรงพลัง แต่จะผูกชนิดโดเมนของคุณเข้ากับรูปแบบ repository
แทนที่จะเป็นเช่นนั้น ให้ต้องการเฉพาะสิ่งที่ flow ร่วมต้องการ:
- ดึงและตั้ง ID (เพื่อให้ Create คืนค่าได้ และ Update/Delete สามารถระบุเป้าหมายได้)
ถ้าคุณต้องการฟีเจอร์เพิ่มเติมเช่น soft delete หรือ optimistic locking ให้เพิ่มอินเทอร์เฟซเล็กๆ แบบ opt-in (เช่น GetVersion/SetVersion) และใช้เฉพาะที่จำเป็น อินเทอร์เฟซเล็กๆ มักยืดอายุได้ดี
อินเทอร์เฟซ repository แบบ generic ที่อ่านได้
อินเทอร์เฟซ repository ควรบรรยายสิ่งที่แอปต้องการ ไม่ใช่สิ่งที่ฐานข้อมูลทำ ถ้าอินเทอร์เฟซเหมือน SQL มากเกินไป มันจะรั่วรายละเอียดไปทั่ว
เก็บเซตเมทอดให้เล็กและคาดเดาได้ ใส่ context.Context ไว้ก่อน แล้วตามด้วยอินพุตหลัก (ID หรือข้อมูล) แล้วรวมตัวเลือกเป็น struct เดียว
type Repository[T any, ID comparable, CreateIn any, UpdateIn any, ListQ any] interface {
Get(ctx context.Context, id ID) (T, error)
List(ctx context.Context, q ListQ) ([]T, error)
Create(ctx context.Context, in CreateIn) (T, error)
Update(ctx context.Context, id ID, in UpdateIn) (T, error)
Delete(ctx context.Context, id ID) error
}
สำหรับ List หลีกเลี่ยงการบังคับชนิดฟิลเตอร์สากล ฟิลเตอร์คือที่ที่เอนทิตีต่างกันมากที่สุด วิธีปฏิบัติที่ใช้ได้คือชนิด query ต่อเอนทิตีบวกรูปร่างการแบ่งหน้าขนาดเล็กที่สามารถฝังได้
type Page struct {
Limit int
Offset int
}
การจัดการข้อผิดพลาดคือที่ที่ repository มักมีเสียงดัง ตัดสินใจล่วงหน้าว่า caller สามารถแตกแขนงตามข้อผิดพลาดใดได้ เซ็ตง่ายๆ มักเพียงพอ:
ErrNotFoundเมื่อตัวระบุไม่มีอยู่ErrConflictสำหรับการละเมิด unique หรือต่อสู้เวอร์ชันErrValidationเมื่ออินพุตไม่ถูกต้อง (เฉพาะถ้า repo ตรวจสอบ)
ทุกอย่างอื่นสามารถเป็น low-level error ที่ห่อไว้ ด้วยสัญญานี้ โค้ดบริการสามารถจัดการ "not found" หรือ "conflict" ได้โดยไม่สนใจว่าตอนนี้เก็บใน PostgreSQL หรืออย่างอื่นในภายหลัง
วิธีหลีกเลี่ยง reflection ในขณะที่ยังใช้ซ้ำ flow ได้
Reflection มักลอบเข้ามาเมื่อคุณต้องการให้โค้ดชิ้นเดียว "เติม struct ใดก็ได้" นั่นซ่อนข้อผิดพลาดจนถึง runtime และทำให้กฎไม่ชัดเจน
วิธีที่สะอาดคือใช้ซ้ำเฉพาะส่วนที่น่าเบื่อ: รันคิวรี, วนแถว, ตรวจเช็กจำนวนแถวที่ได้รับผลกระทบ, และห่อข้อผิดพลาดอย่างสม่ำเสมอ เก็บการแม็ปไปมาเป็น explicit
แยกความรับผิดชอบ: SQL, การแม็ป, และ flow ร่วม
การแยกที่ปฏิบัติได้เป็นแบบนี้:
- ต่อเอนทิตี: เก็บสตริง SQL และลำดับพารามิเตอร์ไว้ในที่เดียว
- ต่อเอนทิตี: เขียนฟังก์ชันแม็ปเล็กๆ ที่สแกนแถวเป็น struct เฉพาะ
- Generic: ให้ flow ร่วมที่รันคิวรีแล้วเรียก mapper
ด้วยวิธีนี้ generics ลดการทำซ้ำโดยไม่ซ่อนสิ่งที่ DB กำลังทำ
นี่คือตัวนามธรรมเล็กๆ ที่ให้คุณส่งได้ทั้ง *sql.DB หรือ *sql.Tx โดยที่โค้ดส่วนอื่นไม่ต้องสนใจ:
type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
สิ่งที่ generics ควร (และไม่ควร) ทำ
เลเยอร์ generic ไม่ควรพยายาม "เข้าใจ" struct ของคุณ แทนที่จะเป็นเช่นนั้น ควรรับฟังก์ชันชัดเจนที่คุณให้ เช่น:
- binder ที่แปลงอินพุตเป็นอาร์กิวเมนต์ของคิวรี
- scanner ที่อ่านคอลัมน์เข้าเป็นเอนทิตี
ตัวอย่างเช่น repository ของ Customer เก็บ SQL เป็นค่าคงที่ (selectByID, insert, update) และ implement scanCustomer(rows) ครั้งเดียว List แบบ generic สามารถจัดการลูป, context, และการห่อข้อผิดพลาด ในขณะที่ scanCustomer ทำให้การแม็ปเป็นแบบ type-safe และชัดเจน
ถ้าคุณเพิ่มคอลัมน์ คุณอัปเดต SQL และ scanner คอมไพเลอร์จะช่วยหาว่าสิ่งใดพัง
ทีละขั้นตอน: การลงมือทำรูปแบบนี้
เป้าหมายคือ flow ที่ใช้ซ้ำได้สำหรับ List/Get/Create/Update/Delete ในขณะที่ทำให้แต่ละ repository ซื่อสัตย์ต่อ SQL และการแม็ปแถวของตัวเอง
1) กำหนดชนิดแกนหลัก
เริ่มด้วยข้อจำกัดน้อยที่สุดที่ใช้งานได้ เลือกชนิด ID ที่เหมาะกับฐานโค้ดของคุณและอินเทอร์เฟซ repository ที่คาดเดาได้
type ID interface{ ~int64 | ~string }
type Repo[E any, K ID] interface {
Get(ctx context.Context, id K) (E, error)
List(ctx context.Context, limit, offset int) ([]E, error)
Create(ctx context.Context, e *E) error
Update(ctx context.Context, e *E) error
Delete(ctx context.Context, id K) error
}
2) เพิ่ม executor สำหรับ DB และ transactions
อย่าผูกโค้ด generic โดยตรงกับ *sql.DB หรือ *sql.Tx ขึ้นอยู่กับอินเทอร์เฟซ executor ขนาดเล็กที่ตรงกับที่คุณเรียก (QueryContext, ExecContext, QueryRowContext) แล้วบริการสามารถส่ง DB หรือ transaction โดยไม่ต้องเปลี่ยนโค้ด repository
3) สร้าง base แบบ generic ที่มี flow ร่วม
สร้าง baseRepo[E,K] ที่เก็บ executor และฟิลด์ฟังก์ชันเล็กๆ Base จะจัดการส่วนที่น่าเบื่อ: เรียกคิวรี, แม็ป “ไม่พบ”, ตรวจเช็กแถวที่ได้รับผลกระทบ, และคืนข้อผิดพลาดสม่ำเสมอ
4) ติดตั้งส่วนที่เฉพาะต่อเอนทิตี
แต่ละ repository ของเอนทิตีให้สิ่งที่ไม่สามารถ generic ได้:
- SQL สำหรับ list/get/create/update/delete
- ฟังก์ชัน
scan(row)ที่แปลงแถวเป็นE - ฟังก์ชัน
bind(...)ที่คืนค่า args ของคิวรี
5) เชื่อม concrete repos และใช้จาก services
สร้าง NewCustomerRepo(exec Executor) *CustomerRepo ที่ฝังหรือห่อ baseRepo เลเยอร์บริการของคุณขึ้นอยู่กับอินเทอร์เฟซ Repo[E,K] และตัดสินใจว่าเมื่อใดจะเริ่ม transaction; repository แค่ใช้ executor ที่ได้มา
จัดการ List/Get/Create/Update/Delete โดยไม่มีความประหลาดใจ
repository แบบ generic จะช่วยได้ก็ต่อเมื่อแต่ละเมทอดมีพฤติกรรมเหมือนกันทุกที่ ปัญหาส่วนใหญ่มาจากความไม่สอดคล้องเล็กๆ: repo หนึ่งสั่ง ORDER BY created_at, อีกอันโดย id; หนึ่งคืน nil, nil เมื่อไม่มีแถว อีกอันคืน error
List: การแบ่งหน้าและการจัดลำดับที่ไม่เปลี่ยน
เลือกสไตล์การแบ่งหน้าหนึ่งแบบและใช้ให้สม่ำเสมอ การแบ่งหน้าแบบ offset (limit/offset) เรียบง่ายและเหมาะกับหน้าฝ่ายดูแล ส่วน cursor pagination เหมาะกับการเลื่อนต่อเนื่องแต่ต้องการคีย์การจัดเรียงที่เสถียร
ไม่ว่าเลือกแบบไหน ให้ทำให้อันดับชัดเจนและเสถียร การจัดเรียงด้วยคอลัมน์ที่เป็น unique (มักเป็น primary key) ป้องกันไม่ให้รายการกระโดดระหว่างหน้าเมื่อมีแถวใหม่
Get: สัญญาณ "ไม่พบ" ที่ชัดเจน
Get(ctx, id) ควรคืนเอนทิตี typed และสัญญาณการขาดหายที่ชัดเจน มักเป็น sentinel error ร่วมอย่าง ErrNotFound หลีกเลี่ยงการคืนค่า zero-value พร้อม error nil ผู้เรียกจะไม่สามารถแยก "หายไป" กับ "ฟิลด์ว่าง" ได้
ทำให้ชิน: ชนิดสำหรับข้อมูล, error สำหรับสถานะ
ก่อนจะ implement เมทอด ให้ตัดสินใจไม่กี่อย่างและรักษาความสอดคล้อง:
Create: รับชนิดอินพุต (ไม่มี ID, ไม่มี timestamps) หรือรับเอนทิตีเต็ม? หลายทีมชอบCreate(ctx, in CreateX)เพื่อป้องกันผู้เรียกตั้งฟิลด์ที่เซิร์ฟเวอร์เป็นเจ้าของUpdate: เป็นการแทนที่ทั้งหมดหรือ patch? ถ้าเป็น patch อย่าใช้ struct ธรรมดาที่ค่าศูนย์กำกวม ใช้ pointer, ชนิด nullable, หรือ field mask ชัดเจนDelete: hard delete หรือ soft delete? ถ้าเป็น soft delete ให้ตัดสินใจว่าGetจะซ่อนแถวที่ถูกลบโดยดีฟอลต์หรือไม่
ยังต้องตัดสินใจว่าเมทอดเขียนคืนค่าอะไร ตัวเลือกที่ไม่ประหลาดใจคือคืนเอนทิตีที่อัปเดตแล้ว (หลัง default ของ DB) หรือคืนเฉพาะ ID พร้อม ErrNotFound เมื่อไม่มีการเปลี่ยนแปลง
ยุทธศาสตร์การทดสอบสำหรับส่วน generic และเฉพาะเอนทิตี
แนวทางนี้คุ้มค่าก็ต่อเมื่อเชื่อถือได้ แยกการทดสอบตามเส้นเดียวกับโค้ด: ทดสอบ helper ร่วมครั้งเดียว แล้วทดสอบ SQL และการสแกนของแต่ละเอนทิตีแยกกัน
ถือชิ้นส่วนร่วมเป็นฟังก์ชันบริสุทธิ์เล็กๆ เท่าที่ทำได้ เช่น การตรวจสอบ pagination, การแม็ปคีย์การเรียงไปยังคอลัมน์ที่อนุญาต, หรือการสร้าง WHERE fragment เหล่านี้สามารถครอบคลุมด้วย unit tests ที่เร็ว
สำหรับ query แบบ list การทดสอบแบบ table-driven ทำได้ดี เพราะ edge cases คือปัญหา สร้างครอบคลุมเช่น filter ว่าง, sort key ไม่รู้จัก, limit 0, limit เกิน max, offset ติดลบ, และขอบเขตหน้าถัดไปที่คุณฟETCH แถวเพิ่มหนึ่ง
เก็บการทดสอบเฉพาะเอนทิตีให้เน้นสิ่งที่เฉพาะเจาะจงจริงๆ: SQL ที่คาดว่าจะรันและวิธีที่แถวสแกนเป็นชนิดเอนทิตี ใช้ SQL mock หรือฐานข้อมูลทดสอบขนาดเล็กและตรวจว่าโลจิกการสแกนจัดการ nulls, คอลัมน์เป็น optional, และการแปลงชนิดได้
ถ้ารูปแบบของคุณรองรับ transaction ให้ทดสอบพฤติกรรม commit/rollback ด้วย fake executor เล็กๆ ที่บันทึกการเรียกและจำลองข้อผิดพลาด:
- Begin คืน executor ในขอบเขต tx
- บน error, เรียก rollback ครั้งเดียว
- บนความสำเร็จ, เรียก commit ครั้งเดียว
- ถ้า commit ล้มเหลว, คืนข้อผิดพลาดไม่เปลี่ยน
คุณยังสามารถเพิ่ม "contract tests" เล็กๆ ที่ทุก repository ต้องผ่าน: สร้างแล้ว get คืนข้อมูลเดียวกัน, update เปลี่ยนฟิลด์ที่ตั้งใจ, delete ทำให้ get คืน not found, และ list คืนการเรียงลำดับคงที่ภายใต้ input เดียวกัน
ความผิดพลาดและกับดักที่พบบ่อย
Generics ทำให้ยากที่จะต่อต้านการสร้าง repository เดียวเพื่อปกครองทุกอย่าง การเข้าถึงข้อมูลเต็มไปด้วยความแตกต่างเล็กๆ และความแตกต่างเหล่านั้นมีความสำคัญ
กับดักที่พบบ่อย:
- ทำให้กว้างจนทุกเมทอดรับ options ถุงใหญ่ (joins, search, permissions, soft deletes, caching) เมื่อถึงตอนนั้น คุณก็สร้าง ORM อีกรอบหนึ่ง
- ข้อจำกัดที่ซับซ้อนเกินไป ถ้าผู้อ่านต้องถอดรหัส type sets เพื่อรู้ว่าเอนทิตีต้องทำอะไร abstraction จะมีต้นทุนมากกว่าประโยชน์
- ถืออินพุตเป็นโมเดล DB เมื่อ Create และ Update รับ struct เดียวกับที่คุณสแกนจากแถว รายละเอียด DB รั่วไหลไปยัง handlers และ tests และการเปลี่ยนสคีมาจะกระทบทั้งแอป
- พฤติกรรมเงียบใน
List: การเรียงไม่เสถียร, ค่าเริ่มต้นไม่สอดคล้อง, หรือนโยบาย paging ที่ต่างกันระหว่างเอนทิตี - การจัดการ not-found ที่บังคับให้ caller ต้อง parse สตริงข้อผิดพลาดแทนการใช้
errors.Is
ตัวอย่างที่จับต้องได้: ListCustomers คืนลูกค้าในลำดับต่างกันทุกครั้งเพราะ repository ไม่ตั้ง ORDER BY การแบ่งหน้าจึงซ้ำหรือข้ามรายการระหว่างคำขอ ทำให้ตั้งการเรียงให้ชัดเจน (แม้จะเป็นเพียง primary key) และทดสอบค่าเริ่มต้นเพื่อป้องกัน
เช็คลิสต์ด่วนก่อนนำไปใช้
ก่อนจะนำ generic repository เข้าไปในทุกแพ็กเกจ ให้แน่ใจว่าจะลดการทำซ้ำโดยไม่ซ่อนพฤติกรรมฐานข้อมูลสำคัญ
เริ่มจากความสอดคล้อง ถ้า repo หนึ่งรับ context.Context และอีก repo ไม่ หรืออันหนึ่งคืน (T, error) ในขณะที่อีกอันคืน (*T, error) ความเจ็บปวดจะแสดงทุกที่: services, tests, และม็อก
ทำให้แน่ใจว่าแต่ละเอนทิตียังคงมีที่ชัดเจนสำหรับ SQL ของมัน Generics ควรใช้ซ้ำ flow (scan, validate, map errors) ไม่ใช่กระจายคิวรีเป็นชิ้นๆ
ชุดเช็กด่วนที่ป้องกันความประหลาดใจส่วนใหญ่:
- คอนเวนชันซิกเนเจอร์เดียวสำหรับ List/Get/Create/Update/Delete
- กฎ not-found เดียวที่ใช้โดยทุก repo
- การเรียง list ที่เสถียร ซึ่งมีเอกสารและทดสอบ
- วิธีสะอาดในการรันโค้ดเดียวกันบน
*sql.DBและ*sql.Tx(ผ่านอินเทอร์เฟซ executor) - ขอบเขตชัดเจนระหว่างโค้ด generic และกฎเอนทิตี (การ validate และเช็คธุรกิจอยู่ข้างนอกเลเยอร์ generic)
ถ้าคุณกำลังสร้างเครื่องมือภายในอย่างรวดเร็วใน AppMaster แล้วต่อมาจะส่งออกหรือขยายโค้ด Go ที่สร้าง การเช็กเหล่านี้ช่วยให้ชั้นข้อมูลคาดเดาได้และทดสอบง่าย
ตัวอย่างที่สมจริง: สร้าง Customer repository
นี่คือรูปแบบ Customer repository เล็กๆ ที่ยังคง type-safe โดยไม่ซับซ้อน
เริ่มจากโมเดลที่เก็บไว้ ให้ ID มีชนิดชัดเจนเพื่อไม่ให้สับกับ ID อื่นโดยบังเอิญ:
type CustomerID int64
type Customer struct {
ID CustomerID
Name string
Status string // "active", "blocked", "trial"...
}
ตอนนี้แยกระหว่าง "สิ่งที่ API รับ" กับ "สิ่งที่คุณเก็บ" นี่คือที่ Create และ Update ควรต่างกัน
type CreateCustomerInput struct {
Name string
Status string
}
type UpdateCustomerInput struct {
Name *string
Status *string
}
base แบบ generic ของคุณสามารถจัดการ flow ร่วม (รัน SQL, สแกน, แม็ปข้อผิดพลาด) ขณะที่ Customer repo เป็นเจ้าของ SQL เฉพาะของ Customer และการแม็ป จากมุมมองชั้นบริการ อินเทอร์เฟซยังคงชัดเจน:
type CustomerRepo interface {
Create(ctx context.Context, in CreateCustomerInput) (Customer, error)
Update(ctx context.Context, id CustomerID, in UpdateCustomerInput) (Customer, error)
Get(ctx context.Context, id CustomerID) (Customer, error)
Delete(ctx context.Context, id CustomerID) error
List(ctx context.Context, q CustomerListQuery) ([]Customer, int, error)
}
สำหรับ List ให้ปฏิบัติต่อ filters และ pagination เป็นวัตถุคำขอชั้นหนึ่ง มันทำให้ call sites อ่านง่ายและลดโอกาสลืม limit
type CustomerListQuery struct {
Status *string // filter
Search *string // name contains
Limit int
Offset int
}
จากตรงนั้น รูปแบบนี้สเกลได้ดี: คัดลอกรูปร่างสำหรับเอนทิตีถัดไป, แยกอินพุตออกจากโมเดลที่เก็บ, และเก็บการสแกนเป็น explicit เพื่อให้การเปลี่ยนแปลงยังคงชัดเจนและคอมไพเลอร์ช่วย
คำถามที่พบบ่อย
ใช้ generics เพื่อใช้ซ้ำส่วน flow (การรันคิวรี, วนอ่านแถว, การจัดการ not-found, ค่าเริ่มต้นการแบ่งหน้า, การแม็ปข้อผิดพลาด) แต่เก็บ SQL และการแม็ปแถวไว้ชัดเจนต่อเอนทิตีแต่ละตัว วิธีนี้ลดการทำซ้ำโดยไม่เปลี่ยนชั้นข้อมูลให้เป็น “เวทย์มนตร์” ที่พังเงียบๆ
การใช้ reflection ซ่อนกฎการแม็ปและย้ายความล้มเหลวไปที่ runtime คุณจะเสียการตรวจสอบโดยคอมไพเลอร์, การช่วยเติมโค้ดใน IDE แย่ลง และการเปลี่ยนแปลงสคีมาขนาดเล็กกลายเป็นความประหลาดใจ ด้วย generics พร้อมฟังก์ชัน scanner ที่ชัดเจน คุณยังได้ความปลอดภัยแบบ type-safe ในขณะที่แชร์ส่วนที่ซ้ำๆ
ค่าดีฟอลต์ที่ดีคือ comparable เพราะ ID มักถูกเปรียบเทียบ, ใช้เป็นคีย์ในแผนที่ และส่งต่อไปรอบๆ ถ้าระบบของคุณมีหลายสไตล์ ID (เช่น int64 และ UUID string) การทำให้ ID เป็น generic จะช่วยหลีกเลี่ยงการบังคับทางเดียวกับทุก repo
เก็บให้เรียบง่าย: โดยปกติให้รวมเฉพาะสิ่งที่ flow ร่วมต้องการ เช่น GetID() และ SetID() หลีกเลี่ยงการบังคับฟิลด์ทั่วไปผ่าน embedding หรือ type sets ที่ซับซ้อน เพราะจะผูกชนิดโดเมนของคุณเข้ากับรูปแบบ repository และทำให้การรีแฟคเตอร์ลำบาก
ใช้อินเทอร์เฟซ executor ขนาดเล็ก (มักเรียกว่า DBTX) ที่มีเฉพาะเมทอดที่คุณเรียก เช่น QueryContext, QueryRowContext, และ ExecContext จากนั้นโค้ด repository ของคุณสามารถทำงานกับทั้ง *sql.DB หรือ *sql.Tx โดยไม่ต้องแยกกรณีหรือทำซ้ำเมทอด
การคืนค่าเป็นศูนย์พร้อม error เป็น nil สำหรับ "ไม่พบ" จะบังคับให้ผู้เรียกเดาว่าเอนทิตีหายไปหรือเพียงแค่ฟิลด์ว่าง ใช้ sentinel ร่วมเช่น ErrNotFound เพื่อเก็บสถานะไว้ในช่อง error ดังนั้นโค้ดบริการจะสามารถเช็กด้วย errors.Is ได้อย่างเชื่อถือได้
แยกอินพุตออกจากโมเดลที่เก็บไว้ โดยทั่วไปให้ใช้ Create(ctx, CreateInput) และ Update(ctx, id, UpdateInput) เพื่อป้องกันไม่ให้ผู้เรียกตั้งฟิลด์ที่เซิร์ฟเวอร์เป็นเจ้าของเช่น ID หรือ timestamps สำหรับการอัปเดตแบบ patch ให้ใช้ pointer (หรือชนิด nullable) เพื่อแยกความหมายระหว่าง "ไม่ได้ตั้ง" กับ "ตั้งเป็นค่าศูนย์"
ตั้ง ORDER BY ที่ชัดเจนและมีเสถียรภาพทุกครั้ง โดยเฉพาะบนคอลัมน์ที่เป็น unique เช่น primary key หากไม่กำหนด อันดับการคืนข้อมูลอาจเปลี่ยนไปและการแบ่งหน้าจะทำให้ข้ามหรือซ้ำรายการเมื่อตารางมีการเปลี่ยนแปลง
เปิดเผยชุดเล็กของข้อผิดพลาดที่ผู้เรียกสามารถแยกเงื่อนไขได้ เช่น ErrNotFound และ ErrConflict ส่วนข้อผิดพลาดอื่นๆ ให้ห่อด้วยบริบทจากข้อผิดพลาดของ DB อย่าให้ผู้เรียกต้อง parse สตริง เป้าหมายคือ errors.Is สำหรับการเช็กและข้อความที่ชัดเจนสำหรับการล็อก
ทดสอบ helper ร่วมเพียงครั้งเดียว (การ normalize pagination, การแม็ป not-found, การเช็ก affected-rows) แล้วทดสอบ SQL และการสแกนของแต่ละเอนทิตีแยกกัน เพิ่ม "contract tests" เล็กๆ ต่อ repository เช่น สร้างแล้ว get คืนข้อมูลเดียวกัน, อัปเดตเปลี่ยนฟิลด์ที่ตั้งใจ, ลบทำให้ get คืน ErrNotFound, และการเรียงลำดับของ list คงที่


