IoT Workshop #5: MongoDB Models & Repository Pattern
IoT Workshop #5: MongoDB Models & Repository Pattern
Branch:
step-02-mongodb-modelsPhase: Development (2/9) — Database Layer Repo: kangana1024/showkhun-workshop
ถ้าตอนที่แล้วเราเปิดร้านได้แล้ว (foundation ขึ้น pong ได้) ตอนนี้ก็ถึงเวลา ออกแบบชั้นวางของให้เป็นระบบ — รู้ว่าของชิ้นไหนวางตรงไหน หยิบมาได้เร็ว และข้อมูลที่ใส่เข้าไปต้องถูกต้องตั้งแต่ประตูทางเข้า วันนี้เราจะต่อ MongoDB ของจริงเข้ากับ backend กันครับ (˶ᵔ ᵕ ᵔ˶)
ทำไม Repository Pattern เพิ่งโผล่ตอนนี้? เพราะ step-01 เราตั้งใจ “อย่าสร้าง abstraction ก่อนมีอะไรให้ abstract” — พอตอนนี้มี MongoDB จริงให้คุยด้วยแล้ว interface ระหว่าง business logic กับ database ถึงจะมีความหมาย บทความนี้อ้างอิงโค้ดจริงใน branch
step-02-mongodb-modelsทั้งหมด
สิ่งที่จะได้เรียนรู้
- ออกแบบ domain model (Device, User, DeviceGroup, AlertRule) พร้อม BSON + JSON tags
- เชื่อม MongoDB ด้วย official Go driver v2 (
go.mongodb.org/mongo-driver/v2) + connection pooling - เข้าใจ Repository Pattern — แยก interface (สัญญา) ออกจาก implementation (คนทำงานจริง)
- สร้าง index แบบ idempotent ที่รันซ้ำได้ทุกครั้งตอนบูต
- ทำ validator กลางที่รายงาน error ด้วย json field name
- กัน NoSQL injection ด้วย typed filter (ไม่ให้ string ดิบหลุดไปเป็น query operator)
ทำไมต้องมี Domain Model + Repository?
พี่โชว์ขอเปรียบ architecture ชั้นนี้เป็น ระบบคลังสินค้า นะครับ:
| ชั้น | เปรียบกับชีวิตจริง | หน้าที่ใน code |
|---|---|---|
| Domain Model | “ฟอร์มรายการสินค้า” — กำหนดว่าของชิ้นนึงมี field อะไร | struct ที่ map กับ MongoDB document |
| Repository Interface | “เคาน์เตอร์รับ-จ่ายของ” — ให้ business logic คุยด้วย | interface ที่ service layer เรียกใช้ |
| Repository Impl | “คนงานในคลัง” — รู้จัก MongoDB จริงๆ | struct ที่ query MongoDB จริง |
ถ้าไม่มีชั้นนี้ business logic จะรู้จัก MongoDB โดยตรง แล้ววันนึงอยากเปลี่ยน database หรืออยาก mock เพื่อ test ก็ต้องรื้อทุกที่ — เจ็บปวดมาก
graph TD
A[service layer] -->|เรียกผ่าน| B[DeviceRepository interface]
B -.implemented by.-> C[mongo.DeviceRepository]
B -.implemented by.-> D[mock repo สำหรับ test]
C --> E[(MongoDB)]
จุดเด็ดคือ — service ผูกกับ interface ไม่ใช่ MongoDB ตรงๆ เวลา test ก็แค่เสียบ mock เข้าไปแทน ไม่ต้องมี MongoDB จริง
Step 1: Domain Models
ในโปรเจกต์นี้เราวาง model ไว้ที่ backend/internal/model/ แล้วให้ persistence concern (BSON tag) กับ transport concern (JSON tag + validation) อยู่ด้วยกัน — เพื่อให้ shape ของข้อมูลถูกอธิบายไว้ที่เดียว
internal/model/device.go
เริ่มจาก enum ที่มี method Valid() ติดมาด้วย — เพื่อจะ validate ได้โดยไม่ต้องเชื่อ input จาก client:
// DeviceStatus enumerates the lifecycle states a device can be in.
type DeviceStatus string
const (
DeviceStatusOnline DeviceStatus = "online"
DeviceStatusOffline DeviceStatus = "offline"
DeviceStatusError DeviceStatus = "error"
DeviceStatusMaintenance DeviceStatus = "maintenance"
)
// Valid reports whether s is a recognised device status.
func (s DeviceStatus) Valid() bool {
switch s {
case DeviceStatusOnline, DeviceStatusOffline, DeviceStatusError, DeviceStatusMaintenance:
return true
default:
return false
}
}
// DeviceType enumerates the supported categories of device.
type DeviceType string
const (
DeviceTypeTemperatureHumidity DeviceType = "temperature_humidity"
DeviceTypeMotion DeviceType = "motion"
DeviceTypeRelay DeviceType = "relay"
DeviceTypeGateway DeviceType = "gateway"
)
สังเกต device type จริง: มันสะท้อนของที่เราจะใช้จริงใน workshop —
temperature_humidity,motion,relay,gatewayไม่ใช่ของลอยๆ อย่าง camera ที่ workshop นี้ไม่ได้แตะ
ตัว Device เองใช้ bson.ObjectID ของ driver v2 (ไม่ใช่ primitive.ObjectID แบบ v1 แล้วนะครับ — เปลี่ยน import path ด้วย):
import "go.mongodb.org/mongo-driver/v2/bson"
// Device is the registry record for a single physical or virtual device.
type Device struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
DeviceID string `bson:"device_id" json:"device_id"`
Name string `bson:"name" json:"name"`
Description string `bson:"description,omitempty" json:"description,omitempty"`
Type DeviceType `bson:"type" json:"type"`
Status DeviceStatus `bson:"status" json:"status"`
GroupID *bson.ObjectID `bson:"group_id,omitempty" json:"group_id,omitempty"`
OwnerID bson.ObjectID `bson:"owner_id,omitempty" json:"owner_id,omitempty"`
Location *Location `bson:"location,omitempty" json:"location,omitempty"`
Tags []string `bson:"tags,omitempty" json:"tags,omitempty"`
Enabled bool `bson:"enabled" json:"enabled"`
// Token authenticates the device for ingestion. ไม่เคย serialize ออก API
Token string `bson:"token,omitempty" json:"-"`
LastSeenAt *time.Time `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// CollectionName returns the MongoDB collection devices are stored in.
func (Device) CollectionName() string { return "devices" }
ดีไซน์เล็กๆ ที่กัน security hole: Token มี json:"-" — เวลา serialize เป็น JSON ส่งให้ client มันจะ หายไปเลย ไม่หลุดออกไป ส่วน Location เป็น struct ย่อยที่เก็บตำแหน่งติดตั้งจริง (building/floor/room + พิกัด):
type Location struct {
Building string `bson:"building,omitempty" json:"building,omitempty"`
Floor int `bson:"floor,omitempty" json:"floor,omitempty"`
Room string `bson:"room,omitempty" json:"room,omitempty"`
Latitude float64 `bson:"latitude,omitempty" json:"latitude,omitempty"`
Longitude float64 `bson:"longitude,omitempty" json:"longitude,omitempty"`
}
Request DTOs แยกจาก entity
internal/model/device_request.go แยก payload ที่รับเข้ามาออกจาก entity ที่เก็บลง DB — และนี่คือจุดที่ validation tag อยู่:
// CreateDeviceRequest is the payload accepted when registering a new device.
type CreateDeviceRequest struct {
DeviceID string `json:"device_id" validate:"required,min=3,max=128,alphanumdash"`
Name string `json:"name" validate:"required,min=1,max=200"`
Description string `json:"description" validate:"max=1000"`
Type DeviceType `json:"type" validate:"required,oneof=temperature_humidity motion relay gateway"`
GroupID string `json:"group_id" validate:"omitempty,len=24,hexadecimal"`
Location *Location `json:"location" validate:"omitempty"`
Tags []string `json:"tags" validate:"omitempty,dive,min=1,max=64"`
Enabled *bool `json:"enabled"`
}
// UpdateDeviceRequest — ทุก field optional, ใช้ pointer แยก "ไม่ส่งมา" จาก "ส่งค่า zero มา"
type UpdateDeviceRequest struct {
Name *string `json:"name" validate:"omitempty,min=1,max=200"`
Description *string `json:"description" validate:"omitempty,max=1000"`
Type *DeviceType `json:"type" validate:"omitempty,oneof=temperature_humidity motion relay gateway"`
Status *DeviceStatus `json:"status" validate:"omitempty,oneof=online offline error maintenance"`
GroupID *string `json:"group_id" validate:"omitempty,len=24,hexadecimal"`
Location *Location `json:"location" validate:"omitempty"`
Tags *[]string `json:"tags" validate:"omitempty,dive,min=1,max=64"`
Enabled *bool `json:"enabled"`
}
ทำไม update ใช้ pointer ทุก field? เพราะใน Go ค่า
falseกับ “ไม่ได้ส่ง field มา” มันแยกกันไม่ออกถ้าไม่ใช้ pointer — ถ้าEnabledเป็นboolธรรมดา เราจะไม่รู้ว่า client ตั้งใจ disable หรือแค่ไม่แตะ field นี้ pointer ที่เป็นnil= “ไม่ส่งมา”, ที่ชี้ไปfalse= “ตั้งใจ disable”
ส่วน User, DeviceGroup, AlertRule ก็อยู่ในไฟล์ของตัวเอง (user.go, group.go, alert.go) แต่ละตัวมี CollectionName() ของมัน เช่น AlertRule ที่จะถูกใช้จริงตอน step alerting:
// AlertCondition describes when an alert rule should fire.
type AlertCondition struct {
Metric string `bson:"metric" json:"metric"` // เช่น "temperature"
Operator AlertOperator `bson:"operator" json:"operator"` // gt/gte/lt/lte/eq/neq
Value float64 `bson:"value" json:"value"`
DurationSeconds int `bson:"duration_seconds,omitempty" json:"duration_seconds,omitempty"`
}
Step 2: MongoDB Connection + Pooling
Connection pooling คือการจอง “ช่องคุย” กับ MongoDB ไว้ล่วงหน้าหลายช่อง แทนที่จะเปิด-ปิดทุก request — เหมือนทางด่วนหลายเลน ไม่ต้องรอคิวยาว backend/internal/database/mongo.go ห่อ *mongo.Client ไว้ (ตัว client เองก็ดูแล pool ให้):
// Connect dials MongoDB, configures pooling and verifies with a ping.
func Connect(ctx context.Context, cfg config.MongoConfig) (*Mongo, error) {
opts := options.Client().
ApplyURI(cfg.URI).
SetMaxPoolSize(cfg.MaxPoolSize).
SetMinPoolSize(cfg.MinPoolSize).
SetConnectTimeout(cfg.ConnectTimeout).
SetServerSelectionTimeout(cfg.ServerSelectionTimeout).
// ตั้ง default deadline ให้ทุก operation ที่ไม่มี deadline ของตัวเอง
// เพื่อให้ server ที่ค้างไม่ทำให้ request แขวนไปตลอดกาล
SetTimeout(cfg.OperationTimeout)
client, err := mongo.Connect(opts)
if err != nil {
return nil, fmt.Errorf("connect mongo: %w", err)
}
m := &Mongo{
client: client,
database: client.Database(cfg.Database),
opTimeout: cfg.OperationTimeout,
}
// verify ว่า connection ใช้ได้จริงก่อนส่งกลับ
pingCtx, cancel := context.WithTimeout(ctx, cfg.ConnectTimeout)
defer cancel()
if err := client.Ping(pingCtx, readpref.Primary()); err != nil {
_ = client.Disconnect(context.Background()) // best-effort cleanup
return nil, fmt.Errorf("ping mongo: %w", err)
}
return m, nil
}
เกร็ด driver v2:
mongo.Connect(opts)ใน v2 ไม่รับ context แล้ว (ต่างจาก v1) — เลย ping แยกต่างหากเพื่อ verify การ pool config (max/min pool, timeout ต่างๆ) อ่านจาก env ทั้งหมด เช่นAPP_MONGO_MAX_POOL_SIZE(default 100),APP_MONGO_MIN_POOL_SIZE(default 5)
ตัว Mongo ยังมี method Ping(ctx) ที่จะถูกเอาไปเสียบเป็น health checker — นี่แหละคือ checker ที่เราเกริ่นไว้ใน step-01:
// Ping checks that MongoDB is reachable. ปลอดภัยที่จะเรียกจาก health handler
func (m *Mongo) Ping(ctx context.Context) error {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, m.opTimeout)
defer cancel()
}
if err := m.client.Ping(ctx, readpref.Primary()); err != nil {
return fmt.Errorf("ping mongo: %w", err)
}
return nil
}
พอเสียบเข้า health handler แล้ว /healthz จะฉลาดขึ้น — ถ้า MongoDB ล่ม มันจะตอบ 503 degraded พร้อมบอกว่า dependency ไหนพัง แทนที่จะตอบ 200 ทั้งที่ DB ตายไปแล้ว:
// health handler ใน step นี้ probe dependency ที่ register ไว้
for _, nc := range h.checks {
if err := nc.checker.Ping(ctx); err != nil {
deps[nc.name] = "unavailable"
overall = fiber.StatusServiceUnavailable
status = "degraded"
continue
}
deps[nc.name] = "ok"
}
Step 3: Repository Interface — สัญญาก่อนเสมอ
backend/internal/repository/repository.go ประกาศ sentinel error ที่ service layer จะเอาไปแปลงเป็น HTTP status — นี่คือ “ภาษากลาง” ระหว่าง layer:
var (
// ErrNotFound คืนเมื่อ document ที่ขอไม่มีอยู่
ErrNotFound = errors.New("repository: document not found")
// ErrConflict คืนเมื่อ write ชน unique constraint เช่น device_id ซ้ำ
ErrConflict = errors.New("repository: document already exists")
)
แล้ว internal/repository/device.go ก็ประกาศ contract ของ device repo — สังเกต typed filter ที่เป็นหัวใจของการกัน NoSQL injection:
// DeviceFilter — ทุก field optional, zero value ถูกข้าม การใช้ typed filter
// (แทนที่จะรับ map ดิบจาก caller) เป็นการกัน NoSQL injection โดยตั้งใจ:
// มีแค่ field ที่ระบุชื่อและ type-checked เท่านั้นที่ถึง query ได้
type DeviceFilter struct {
Status *model.DeviceStatus
Type *model.DeviceType
GroupID *bson.ObjectID
Enabled *bool
Tags []string
Search string // case-insensitive, ถือเป็น literal เสมอ ไม่ใช่ regex ดิบ
}
// DeviceRepository — contract ที่ service layer เขียนพึ่ง และ test mock
type DeviceRepository interface {
Create(ctx context.Context, d *model.Device) error
GetByID(ctx context.Context, id bson.ObjectID) (*model.Device, error)
GetByDeviceID(ctx context.Context, deviceID string) (*model.Device, error)
List(ctx context.Context, params DeviceListParams) (devices []model.Device, total int64, err error)
Update(ctx context.Context, id bson.ObjectID, fields bson.M) (*model.Device, error)
Delete(ctx context.Context, id bson.ObjectID) error
BulkCreate(ctx context.Context, devices []model.Device) (inserted int, err error)
}
อุปมา: interface คือ เมนูร้านอาหาร — บอกว่าสั่งอะไรได้บ้าง โดยลูกค้า (service) ไม่ต้องรู้ว่าหลังครัวทำยังไง จะเปลี่ยนพ่อครัว (เปลี่ยนเป็น mock ตอน test) ก็ได้ ตราบใดที่เมนูยังเหมือนเดิม
Step 4: MongoDB Implementation + Indexes
backend/internal/repository/mongo/device.go คือ “คนงานในคลัง” ที่รู้จัก MongoDB จริงๆ มี compile-time assertion ยืนยันว่ามันทำตาม interface ครบ:
// compile-time assertion ว่า implementation ทำตาม interface
var _ repository.DeviceRepository = (*DeviceRepository)(nil)
Index แบบ idempotent
EnsureIndexes ถูกออกแบบให้ เรียกซ้ำได้ทุกครั้งตอนบูต (MongoDB จะข้าม index ที่มีอยู่แล้ว) — เลยไม่ต้องเขียน migration script แยก:
// EnsureIndexes สร้าง index ที่ collection devices ต้องใช้ idempotent + ปลอดภัย
func (r *DeviceRepository) EnsureIndexes(ctx context.Context) error {
models := []mongo.IndexModel{
{
Keys: bson.D{{Key: "device_id", Value: 1}},
Options: options.Index().SetUnique(true).SetName("uniq_device_id"),
},
{
Keys: bson.D{{Key: "status", Value: 1}, {Key: "type", Value: 1}},
Options: options.Index().SetName("status_type"),
},
{
Keys: bson.D{{Key: "group_id", Value: 1}},
Options: options.Index().SetName("group_id"),
},
{
Keys: bson.D{{Key: "tags", Value: 1}},
Options: options.Index().SetName("tags"),
},
{
Keys: bson.D{{Key: "created_at", Value: -1}},
Options: options.Index().SetName("created_at_desc"),
},
}
if _, err := r.coll.Indexes().CreateMany(ctx, models); err != nil {
return mapWriteError(err)
}
return nil
}
ทำไมต้อง index? นึกภาพห้องสมุดที่ไม่มีดัชนี — หาหนังสือเล่มนึงต้องเดินดูทุกชั้น index ช่วยให้ query ข้ามตรงไปหา document ที่ต้องการได้เลยโดยไม่ต้อง full scan ที่นี่เราใส่ index ตาม query ที่ ใช้จริง: lookup ด้วย device_id (unique), กรองด้วย status+type, หาตาม group, ตาม tag, และเรียงตามเวลาสร้าง
กัน NoSQL Injection ที่ชั้น query builder
ส่วนที่พี่โชว์ชอบที่สุดคือ buildDeviceFilter — มันแปลง typed filter เป็น BSON query ที่ทุกค่าถูก bind แบบปลอดภัย และ escape search string ไม่ให้กลายเป็น regex อันตราย:
// buildDeviceFilter แปลง DeviceFilter เป็น BSON query
// ทุกค่าผูกผ่าน typed field ไม่มี string จาก caller หลุดไปเป็น operator
// — นี่คือ NoSQL-injection guard
func buildDeviceFilter(f repository.DeviceFilter) bson.M {
query := bson.M{}
if f.Status != nil {
query["status"] = *f.Status
}
if f.Type != nil {
query["type"] = *f.Type
}
if f.GroupID != nil {
query["group_id"] = *f.GroupID
}
if f.Enabled != nil {
query["enabled"] = *f.Enabled
}
if len(f.Tags) > 0 {
query["tags"] = bson.M{"$all": f.Tags}
}
if f.Search != "" {
// escape input ให้ match แบบ literal ไม่ตีความเป็น regex
safe := regexp.QuoteMeta(f.Search)
rx := bson.Regex{Pattern: safe, Options: "i"}
query["$or"] = bson.A{
bson.M{"name": rx},
bson.M{"device_id": rx},
}
}
return query
}
ทำไม
regexp.QuoteMetaสำคัญ? ถ้า user ส่ง search เป็น.*หรือ regex ซับซ้อนแบบ catastrophic backtracking มันอาจทำให้ MongoDB ทำงานหนักจน DoS ได้ การ escape ทำให้มันถูกค้นแบบ “ตัวอักษรตรงๆ” เท่านั้น ปลอดภัยกว่าเยอะ
อีกจุดที่ป้องกัน injection คือ whitelist sort field — caller จะ sort ด้วย field มั่วๆ ไม่ได้ ถ้าไม่อยู่ใน list ก็ fallback เป็น created_at:
var allowedDeviceSortFields = map[string]string{
"created_at": "created_at",
"updated_at": "updated_at",
"name": "name",
"device_id": "device_id",
"status": "status",
"type": "type",
}
BulkCreate ที่ทน partial failure
BulkCreate ใช้ unordered write — ถ้ามี device_id ซ้ำตัวนึง มันจะข้ามตัวนั้นแล้วไปต่อ ไม่ทำให้ทั้ง batch พัง แล้วคืนจำนวนที่ insert ได้จริง:
func (r *DeviceRepository) BulkCreate(ctx context.Context, devices []model.Device) (int, error) {
// ... stamp timestamps + default status ...
res, err := r.coll.InsertMany(ctx, docs, options.InsertMany().SetOrdered(false))
if res != nil {
// partial failure: InsertMany ยังรายงานตัวที่ใส่สำเร็จ
if err != nil && mongo.IsDuplicateKeyError(err) {
return len(res.InsertedIDs), repository.ErrConflict
}
if err == nil {
return len(res.InsertedIDs), nil
}
}
// ...
}
ในโลก IoT การ register device ทีละร้อยตัวเป็นเรื่องปกติ ถ้า device ตัวที่ 50 ซ้ำแล้วทำให้อีก 449 ตัวพังตามไปด้วย คงไม่ไหว unordered write เลยเป็นทางเลือกที่ถูกต้อง
Step 5: Validator กลาง
backend/internal/validate/validate.go ห่อ go-playground/validator ไว้ พร้อม 2 ความพิเศษ: รายงาน error ด้วย json field name (ให้ตรงกับ wire format) และมี custom rule alphanumdash:
func Validator() *validator.Validate {
once.Do(func() {
v := validator.New(validator.WithRequiredStructEnabled())
// รายงาน error ด้วยชื่อ json tag ไม่ใช่ชื่อ struct field
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
// custom rule: identifier จำกัดที่ [A-Za-z0-9_-]
_ = v.RegisterValidation("alphanumdash", func(fl validator.FieldLevel) bool {
return alphanumDashRe.MatchString(fl.Field().String())
})
instance = v
})
return instance
}
เวลา validate ไม่ผ่าน มันคืน FieldErrors ที่เป็น map[string]string — field → ข้อความ พร้อมเป็น JSON ส่งให้ frontend ได้เลย:
// FieldErrors maps field name → human-readable message
type FieldErrors map[string]string
ทำไม report ด้วย json name? เพราะ client ส่ง
device_idมา ไม่ใช่DeviceID(ชื่อ Go field) ถ้า error message บอกว่า “DeviceID is required” client จะงง เพราะมันไม่เคยเห็นชื่อนั้น การ map กลับเป็น json name ทำให้ error ตรงกับสิ่งที่ client ส่งมาจริง
ลองรันกัน!
ตอนนี้ backend ต้องพึ่ง MongoDB แล้ว เลยต้องเปิด infra ก่อน:
# 1) เปิด infrastructure (MongoDB, Mosquitto, InfluxDB, Telegraf)
make up
# 2) รัน backend — มันจะ connect mongo + ensure index ตอนบูต
make run
ตอนบูตจะเห็น log ประมาณนี้ (จาก main.go ที่ต่อ DB + สร้าง index ก่อนเปิดรับ traffic):
connected to mongodb {"database": "iot_workshop"}
device indexes ensured
starting http server {"addr": "0.0.0.0:3000", "env": "development"}
เช็คว่า health check รู้จัก MongoDB แล้ว:
curl http://localhost:3000/healthz
# {"status":"ok","service":"showkhun-iot-platform","version":"0.2.0",
# "dependencies":{"mongodb":"ok"}, ...}
เห็น "mongodb":"ok" ใน dependencies แล้วใช่ไหมครับ — นั่นแหละ checker ที่เราเสียบเข้าไป ลองปิด MongoDB (docker stop showkhun-mongodb) แล้ว curl ใหม่ จะได้ 503 พร้อม "mongodb":"unavailable" ทันที
╔══════════════════════════════════════╗
║ Architecture ที่ดี = ║
║ เหนื่อยหน่อยตอนออกแบบ ║
║ สบายมากตอนแก้บัค (ง •̀_•́)ง ║
╚══════════════════════════════════════╝
สรุป: เราได้อะไรในตอนนี้
| ส่วนประกอบ | รายละเอียด |
|---|---|
| Domain models | Device, User, DeviceGroup, AlertRule + BSON/JSON tags + Valid() enums |
| Driver | official mongo-driver/v2 (bson.ObjectID) + connection pooling |
| Connection | Connect/Ping/Disconnect + เสียบ Ping เป็น health checker |
| Repository | interface (สัญญา) แยกจาก mongo impl (คนทำงาน) + sentinel errors |
| Indexes | EnsureIndexes แบบ idempotent ตาม query ที่ใช้จริง |
| Injection guard | typed filter + regexp.QuoteMeta + whitelist sort field |
| Validator | report ด้วย json name + custom rule alphanumdash |
หัวใจคือ — เราออกแบบ seam ระหว่าง business logic กับ database ไว้แล้ว step หน้าพอเราเขียน service + handler มันจะ plug เข้ากับ repository interface นี้ได้พอดี และ test ได้โดยไม่ต้องมี MongoDB จริง
Next Step
ตอนหน้าเราจะเอา repository นี้มาประกอบเป็น Device Management API ของจริง — service layer ที่ออก ingestion token, handler ที่บางและ map error เป็น HTTP status, envelope httpx (data / error / pagination) และ unit test แบบ table-driven ที่ mock repository มาลุยต่อ! ٩(◕‿◕。)۶
Navigation
- ก่อนหน้า: IoT Workshop #4: วาง Foundation ด้วย Go + Fiber
- ถัดไป: IoT Workshop #6: Device Management API
- แผนการ Workshop ทั้งหมด: IoT Workshop Master Plan