IoT Workshop #5: MongoDB Models & Repository Pattern

IoT Workshop #5: MongoDB Models & Repository Pattern

ShowkhunWorkshop

IoT Workshop #5: MongoDB Models & Repository Pattern

Branch: step-02-mongodb-models Phase: 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 มาลุยต่อ! ٩(◕‿◕。)۶