IoT Workshop: MongoDB Models & Repository Pattern
Branch:
workshop/dev-02-mongodb-modelsPhase: Development — Database Layer Repository: https://github.com/kangana1024/iot-workshop
ถ้าตอนที่แล้วเราเปิดร้านกาแฟได้แล้ว (Fiber Bootstrap) ตอนนี้ก็ถึงเวลาออกแบบ “ชั้นวาง” ให้เป็นระบบ — รู้ว่าของแต่ละชิ้นวางไว้ไหน เรียกมาได้เร็ว และเวลาของหาย ก็ไม่ได้หายจริงๆ (Soft Delete ไง 555) วันนี้เราจะมาสร้าง Database Layer กันครับ
(˶ᵔ ᵕ ᵔ˶) "โค้ดดีมีครึ่งทาง ดีไซน์ดีมีครบทาง!"
น้องๆ จะได้อะไรจากตอนนี้
- เข้าใจว่าทำไมต้องออกแบบ Domain Model ก่อนเขียน Code
- เชื่อมต่อ MongoDB ด้วย Official Go Driver พร้อม Connection Pooling
- ออกแบบ Domain Models (Device, User, DeviceGroup, AlertRule) พร้อม BSON Tags
- เข้าใจ Repository Pattern — แยก Business Logic ออกจาก Data Access
- สร้าง Database Indexes ให้ Query วิ่งได้เร็ว
- Validate Input ด้วย
go-playground/validator+ Custom Rules - ใช้ Soft Delete เพื่อรักษา audit trail
ทำไม (WHY) ต้องมี Domain Model และ Repository Pattern?
พี่โชว์ขอเปรียบ architecture ชั้นนี้เหมือน ระบบคลังสินค้า นะ
| ชั้น | เปรียบกับชีวิตจริง | หน้าที่ใน Code |
|---|---|---|
| Domain Model | ”ฟอร์มรายการสินค้า” — กำหนดว่า “สินค้า” มีฟิลด์อะไร | Struct ที่ map กับ MongoDB document |
| Repository Interface | ”เคาน์เตอร์รับ-จ่ายสินค้า” — ให้ Business Logic คุยด้วย | Interface ที่ UseCase เรียกใช้ |
| Repository Impl | ”คนงานในคลัง” — รู้จัก MongoDB จริงๆ | Concrete struct ที่ query MongoDB |
ถ้าไม่มีชั้นนี้ UseCase จะรู้จัก MongoDB โดยตรง แล้ววันนึงถ้าจะเปลี่ยนไป PostgreSQL ก็ต้องแก้ทุกที่ — เจ็บปวดมาก
จะเห็นว่า Device เป็นตัวกลาง — มี owner, อยู่ใน group, และถูก monitor ด้วย AlertRule มาลุยกันทีละส่วนเลย!
Domain Models
internal/domain/device.go
ก่อนจะเขียน Database Query สักบรรทัด เราต้องรู้ก่อนว่า “Device” ในระบบของเรามีหน้าตาอย่างไร — เหมือนออกแบบ “ใบสั่งซื้อ” ก่อนสร้างระบบสต็อก
package domain
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// DeviceStatus represents the current state of a device
type DeviceStatus string
const (
DeviceStatusOnline DeviceStatus = "online"
DeviceStatusOffline DeviceStatus = "offline"
DeviceStatusInactive DeviceStatus = "inactive"
DeviceStatusError DeviceStatus = "error"
)
// DeviceType classifies the kind of IoT device
type DeviceType string
const (
DeviceTypeSensor DeviceType = "sensor"
DeviceTypeActuator DeviceType = "actuator"
DeviceTypeGateway DeviceType = "gateway"
DeviceTypeCamera DeviceType = "camera"
)
// Device represents an IoT device registered in the platform
type Device struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
DeviceID string `bson:"device_id" json:"device_id"` // Human-readable unique ID e.g. "sensor-001"
Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"`
Type DeviceType `bson:"type" json:"type"`
Status DeviceStatus `bson:"status" json:"status"`
GroupID *primitive.ObjectID `bson:"group_id,omitempty" json:"group_id,omitempty"`
OwnerID primitive.ObjectID `bson:"owner_id" json:"owner_id"`
Location *Location `bson:"location,omitempty" json:"location,omitempty"`
Metadata map[string]interface{} `bson:"metadata,omitempty" json:"metadata,omitempty"`
Tags []string `bson:"tags,omitempty" json:"tags,omitempty"`
Token string `bson:"token" json:"-"` // Device auth token — never expose
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"`
DeletedAt *time.Time `bson:"deleted_at,omitempty" json:"deleted_at,omitempty"`
}
// Location holds geographic coordinates
type Location struct {
Latitude float64 `bson:"latitude" json:"latitude"`
Longitude float64 `bson:"longitude" json:"longitude"`
Altitude float64 `bson:"altitude" json:"altitude,omitempty"`
Label string `bson:"label" json:"label,omitempty"`
}
// IsOnline returns true if the device is currently online
func (d *Device) IsOnline() bool {
return d.Status == DeviceStatusOnline
}
// IsDeleted returns true if the device has been soft-deleted
func (d *Device) IsDeleted() bool {
return d.DeletedAt != nil
}
// CollectionName returns the MongoDB collection name for Device
func (Device) CollectionName() string {
return "devices"
}
// CreateDeviceRequest is the DTO for creating a new device
type CreateDeviceRequest struct {
DeviceID string `json:"device_id" validate:"required,min=3,max=64,alphanum_dash"`
Name string `json:"name" validate:"required,min=1,max=128"`
Description string `json:"description" validate:"max=512"`
Type DeviceType `json:"type" validate:"required,oneof=sensor actuator gateway camera"`
GroupID string `json:"group_id" validate:"omitempty,mongodb_id"`
Location *Location `json:"location" validate:"omitempty"`
Metadata map[string]interface{} `json:"metadata"`
Tags []string `json:"tags" validate:"omitempty,max=10,dive,min=1,max=32"`
}
// UpdateDeviceRequest is the DTO for updating device fields
type UpdateDeviceRequest struct {
Name *string `json:"name" validate:"omitempty,min=1,max=128"`
Description *string `json:"description" validate:"omitempty,max=512"`
GroupID *string `json:"group_id" validate:"omitempty,mongodb_id"`
Location *Location `json:"location"`
Metadata map[string]interface{} `json:"metadata"`
Tags []string `json:"tags" validate:"omitempty,max=10,dive,min=1,max=32"`
}
// DeviceFilter defines query parameters for listing devices
type DeviceFilter struct {
Status DeviceStatus `query:"status"`
Type DeviceType `query:"type"`
GroupID string `query:"group_id"`
Search string `query:"search"`
Page int `query:"page" validate:"min=1"`
Limit int `query:"limit" validate:"min=1,max=100"`
SortBy string `query:"sort_by"`
SortDir string `query:"sort_dir" validate:"omitempty,oneof=asc desc"`
}
สังเกตนิดนึงนะ: Token มี json:"-" — แปลว่าเวลา serialize เป็น JSON ออกไปหา client มันจะหายไปเลย ไม่ถูก expose ออกไป ดีไซน์เล็กๆ ที่ป้องกัน security hole ได้
internal/domain/user.go
package domain
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// UserRole defines access level
type UserRole string
const (
UserRoleAdmin UserRole = "admin"
UserRoleOperator UserRole = "operator"
UserRoleViewer UserRole = "viewer"
)
// User represents a platform user
type User struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
Email string `bson:"email" json:"email"`
Username string `bson:"username" json:"username"`
PasswordHash string `bson:"password_hash" json:"-"`
Role UserRole `bson:"role" json:"role"`
DisplayName string `bson:"display_name" json:"display_name"`
AvatarURL string `bson:"avatar_url" json:"avatar_url,omitempty"`
IsActive bool `bson:"is_active" json:"is_active"`
LastLoginAt *time.Time `bson:"last_login_at" json:"last_login_at,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
func (User) CollectionName() string { return "users" }
// DeviceGroup groups multiple devices together
type DeviceGroup struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"`
OwnerID primitive.ObjectID `bson:"owner_id" json:"owner_id"`
Color string `bson:"color" json:"color"` // UI color hint e.g. "#FF5733"
Icon string `bson:"icon" json:"icon"` // Icon name
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
func (DeviceGroup) CollectionName() string { return "device_groups" }
// AlertSeverity defines alert urgency levels
type AlertSeverity string
const (
AlertSeverityCritical AlertSeverity = "critical"
AlertSeverityHigh AlertSeverity = "high"
AlertSeverityMedium AlertSeverity = "medium"
AlertSeverityLow AlertSeverity = "low"
)
// AlertCondition defines the comparison operator
type AlertCondition string
const (
AlertConditionGT AlertCondition = "gt"
AlertConditionGTE AlertCondition = "gte"
AlertConditionLT AlertCondition = "lt"
AlertConditionLTE AlertCondition = "lte"
AlertConditionEQ AlertCondition = "eq"
AlertConditionNEQ AlertCondition = "neq"
)
// AlertRule defines when an alert should be triggered
type AlertRule struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"`
DeviceID *primitive.ObjectID `bson:"device_id,omitempty" json:"device_id,omitempty"` // nil = apply to all
GroupID *primitive.ObjectID `bson:"group_id,omitempty" json:"group_id,omitempty"`
Field string `bson:"field" json:"field"` // e.g. "temperature"
Condition AlertCondition `bson:"condition" json:"condition"` // e.g. "gt"
Threshold float64 `bson:"threshold" json:"threshold"` // e.g. 80.0
Severity AlertSeverity `bson:"severity" json:"severity"`
IsActive bool `bson:"is_active" json:"is_active"`
Channels []string `bson:"channels" json:"channels"` // ["email", "line", "webhook"]
WebhookURL string `bson:"webhook_url" json:"webhook_url,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
func (AlertRule) CollectionName() string { return "alert_rules" }
AlertRule เก๋มาก: DeviceID และ GroupID เป็น pointer ทั้งคู่ — ถ้า nil แปลว่า rule นั้น apply กับทุก device เหมือน “ประกาศทั่วทั้งโรงงาน” แทนที่จะบอกทีละเครื่อง
MongoDB Connection
internal/database/mongodb.go
Connection Pooling คือการที่เราจอง “ช่องคุย” กับ MongoDB ไว้ล่วงหน้าหลายๆ ช่อง แทนที่จะเปิด-ปิดทุก request — เหมือนมีหลายเลนในทางด่วน ไม่ต้องรอคิวยาว
package database
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.mongodb.org/mongo-driver/mongo/readpref"
"go.uber.org/zap"
"github.com/kangana1024/iot-workshop/backend/internal/config"
)
// MongoDB wraps the mongo.Client with app-specific helpers
type MongoDB struct {
client *mongo.Client
database *mongo.Database
log *zap.Logger
}
// NewMongoDB creates and verifies a MongoDB connection
func NewMongoDB(ctx context.Context, cfg config.MongoDBConfig, log *zap.Logger) (*MongoDB, error) {
// Build client options
opts := options.Client().
ApplyURI(cfg.URI).
SetMaxPoolSize(cfg.PoolSize).
SetMinPoolSize(cfg.MinPoolSize).
SetConnectTimeout(cfg.Timeout).
SetServerSelectionTimeout(cfg.Timeout).
SetRetryWrites(true).
SetRetryReads(true)
// Add compression for bandwidth efficiency
opts.SetCompressors([]string{"snappy", "zlib"})
// Connect
client, err := mongo.Connect(ctx, opts)
if err != nil {
return nil, fmt.Errorf("failed to connect to MongoDB: %w", err)
}
// Verify connection
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := client.Ping(pingCtx, readpref.Primary()); err != nil {
return nil, fmt.Errorf("failed to ping MongoDB: %w", err)
}
log.Info("Connected to MongoDB",
zap.String("uri", maskMongoURI(cfg.URI)),
zap.String("database", cfg.Database),
zap.Uint64("pool_size", cfg.PoolSize),
)
db := &MongoDB{
client: client,
database: client.Database(cfg.Database),
log: log,
}
return db, nil
}
// Client returns the raw mongo.Client
func (m *MongoDB) Client() *mongo.Client {
return m.client
}
// Collection returns a mongo.Collection by name
func (m *MongoDB) Collection(name string) *mongo.Collection {
return m.database.Collection(name)
}
// Disconnect closes the MongoDB connection gracefully
func (m *MongoDB) Disconnect(ctx context.Context) error {
m.log.Info("Disconnecting from MongoDB")
return m.client.Disconnect(ctx)
}
// SetupIndexes creates all required indexes for optimal query performance
func (m *MongoDB) SetupIndexes(ctx context.Context) error {
m.log.Info("Setting up MongoDB indexes...")
if err := m.setupDeviceIndexes(ctx); err != nil {
return fmt.Errorf("device indexes: %w", err)
}
if err := m.setupUserIndexes(ctx); err != nil {
return fmt.Errorf("user indexes: %w", err)
}
if err := m.setupAlertRuleIndexes(ctx); err != nil {
return fmt.Errorf("alert rule indexes: %w", err)
}
m.log.Info("MongoDB indexes setup complete")
return nil
}
func (m *MongoDB) setupDeviceIndexes(ctx context.Context) error {
coll := m.Collection("devices")
indexes := []mongo.IndexModel{
{
// Unique device_id — used for device lookup by ID
Keys: bson.D{{Key: "device_id", Value: 1}},
Options: options.Index().SetUnique(true).SetName("idx_device_id_unique"),
},
{
// Owner + status — used for dashboard queries
Keys: bson.D{{Key: "owner_id", Value: 1}, {Key: "status", Value: 1}},
Options: options.Index().SetName("idx_owner_status"),
},
{
// Group lookup
Keys: bson.D{{Key: "group_id", Value: 1}},
Options: options.Index().SetSparse(true).SetName("idx_group_id"),
},
{
// Text search on name and description
Keys: bson.D{
{Key: "name", Value: "text"},
{Key: "description", Value: "text"},
{Key: "tags", Value: "text"},
},
Options: options.Index().SetName("idx_device_text"),
},
{
// Soft delete filter — most queries exclude deleted devices
Keys: bson.D{{Key: "deleted_at", Value: 1}},
Options: options.Index().SetSparse(true).SetName("idx_deleted_at"),
},
{
// Last seen — for finding stale devices
Keys: bson.D{{Key: "last_seen_at", Value: -1}},
Options: options.Index().SetSparse(true).SetName("idx_last_seen_at"),
},
}
_, err := coll.Indexes().CreateMany(ctx, indexes)
return err
}
func (m *MongoDB) setupUserIndexes(ctx context.Context) error {
coll := m.Collection("users")
indexes := []mongo.IndexModel{
{
Keys: bson.D{{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true).SetName("idx_email_unique"),
},
{
Keys: bson.D{{Key: "username", Value: 1}},
Options: options.Index().SetUnique(true).SetName("idx_username_unique"),
},
}
_, err := coll.Indexes().CreateMany(ctx, indexes)
return err
}
func (m *MongoDB) setupAlertRuleIndexes(ctx context.Context) error {
coll := m.Collection("alert_rules")
indexes := []mongo.IndexModel{
{
Keys: bson.D{{Key: "device_id", Value: 1}, {Key: "is_active", Value: 1}},
Options: options.Index().SetName("idx_alert_device_active"),
},
}
_, err := coll.Indexes().CreateMany(ctx, indexes)
return err
}
// maskMongoURI removes credentials from the URI for safe logging
func maskMongoURI(uri string) string {
// Simple masking — in production use url.Parse for proper handling
if len(uri) > 20 {
return uri[:20] + "***"
}
return "***"
}
ทำไมต้อง Index? นึกภาพห้องสมุดที่ไม่มีดัชนี — จะหาหนังสือเล่มนึงต้องเดินดูทุกชั้น Index ใน MongoDB ก็เหมือนกัน ช่วยให้ query ข้ามตรงไปหา document ที่ต้องการได้เลยโดยไม่ต้อง full scan
Repository Pattern
ทำไมต้อง Interface ก่อน?
เหมือนการกำหนด “สัญญา” ให้ UseCase รู้ว่าจะเรียกอะไรได้บ้าง — โดยไม่สนว่าหลังบ้านจะใช้ MongoDB, PostgreSQL หรือ In-memory Mock สำหรับ test
internal/repository/device_repository.go
package repository
import (
"context"
"github.com/kangana1024/iot-workshop/backend/internal/domain"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// DeviceRepository defines all database operations for devices
// This interface allows us to swap implementations (e.g. mock for tests)
type DeviceRepository interface {
// Create inserts a new device and returns it with generated ID
Create(ctx context.Context, device *domain.Device) (*domain.Device, error)
// FindByID returns a device by MongoDB ObjectID
FindByID(ctx context.Context, id primitive.ObjectID) (*domain.Device, error)
// FindByDeviceID returns a device by its human-readable device_id
FindByDeviceID(ctx context.Context, deviceID string) (*domain.Device, error)
// List returns a paginated list of devices matching the filter
List(ctx context.Context, filter domain.DeviceFilter) ([]*domain.Device, int64, error)
// Update modifies an existing device
Update(ctx context.Context, id primitive.ObjectID, req *domain.UpdateDeviceRequest) (*domain.Device, error)
// UpdateStatus changes the device status and updates last_seen_at
UpdateStatus(ctx context.Context, deviceID string, status domain.DeviceStatus) error
// Delete soft-deletes a device
Delete(ctx context.Context, id primitive.ObjectID) error
// HardDelete permanently removes a device (admin only)
HardDelete(ctx context.Context, id primitive.ObjectID) error
// CountByOwner returns total device count for a user
CountByOwner(ctx context.Context, ownerID primitive.ObjectID) (int64, error)
// FindStaleDevices returns devices that haven't been seen since the given threshold
FindStaleDevices(ctx context.Context, filter domain.DeviceFilter) ([]*domain.Device, error)
}
internal/repository/mongo/device_repository.go
นี่คือ “คนงานในคลัง” ที่รู้จัก MongoDB จริงๆ — เวลาเขียน test เราจะ inject mock แทนตัวนี้ได้เลย
package mongo
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.uber.org/zap"
"github.com/kangana1024/iot-workshop/backend/internal/domain"
"github.com/kangana1024/iot-workshop/backend/internal/repository"
)
// deviceRepository is the MongoDB implementation of DeviceRepository
type deviceRepository struct {
coll *mongo.Collection
log *zap.Logger
}
// NewDeviceRepository creates a new MongoDB device repository
func NewDeviceRepository(db *mongo.Database, log *zap.Logger) repository.DeviceRepository {
return &deviceRepository{
coll: db.Collection("devices"),
log: log,
}
}
func (r *deviceRepository) Create(ctx context.Context, device *domain.Device) (*domain.Device, error) {
device.ID = primitive.NewObjectID()
device.CreatedAt = time.Now()
device.UpdatedAt = time.Now()
device.Status = domain.DeviceStatusOffline
result, err := r.coll.InsertOne(ctx, device)
if err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, fmt.Errorf("device_id '%s' already exists", device.DeviceID)
}
return nil, fmt.Errorf("failed to create device: %w", err)
}
device.ID = result.InsertedID.(primitive.ObjectID)
r.log.Debug("Device created", zap.String("device_id", device.DeviceID))
return device, nil
}
func (r *deviceRepository) FindByID(ctx context.Context, id primitive.ObjectID) (*domain.Device, error) {
filter := bson.M{
"_id": id,
"deleted_at": bson.M{"$exists": false},
}
var device domain.Device
if err := r.coll.FindOne(ctx, filter).Decode(&device); err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil // Not found — caller handles nil
}
return nil, fmt.Errorf("failed to find device: %w", err)
}
return &device, nil
}
func (r *deviceRepository) FindByDeviceID(ctx context.Context, deviceID string) (*domain.Device, error) {
filter := bson.M{
"device_id": deviceID,
"deleted_at": bson.M{"$exists": false},
}
var device domain.Device
if err := r.coll.FindOne(ctx, filter).Decode(&device); err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, fmt.Errorf("failed to find device by device_id: %w", err)
}
return &device, nil
}
func (r *deviceRepository) List(ctx context.Context, f domain.DeviceFilter) ([]*domain.Device, int64, error) {
// Build filter document
filter := bson.M{"deleted_at": bson.M{"$exists": false}}
if f.Status != "" {
filter["status"] = f.Status
}
if f.Type != "" {
filter["type"] = f.Type
}
if f.GroupID != "" {
groupOID, err := primitive.ObjectIDFromHex(f.GroupID)
if err == nil {
filter["group_id"] = groupOID
}
}
if f.Search != "" {
filter["$text"] = bson.M{"$search": f.Search}
}
// Count total matching documents
total, err := r.coll.CountDocuments(ctx, filter)
if err != nil {
return nil, 0, fmt.Errorf("failed to count devices: %w", err)
}
// Pagination
page := f.Page
if page < 1 {
page = 1
}
limit := f.Limit
if limit < 1 || limit > 100 {
limit = 20
}
skip := int64((page - 1) * limit)
// Sort
sortField := "created_at"
sortDir := -1 // default: newest first
if f.SortBy != "" {
sortField = f.SortBy
}
if f.SortDir == "asc" {
sortDir = 1
}
opts := options.Find().
SetSkip(skip).
SetLimit(int64(limit)).
SetSort(bson.D{{Key: sortField, Value: sortDir}})
cursor, err := r.coll.Find(ctx, filter, opts)
if err != nil {
return nil, 0, fmt.Errorf("failed to list devices: %w", err)
}
defer cursor.Close(ctx)
var devices []*domain.Device
if err := cursor.All(ctx, &devices); err != nil {
return nil, 0, fmt.Errorf("failed to decode devices: %w", err)
}
return devices, total, nil
}
func (r *deviceRepository) Update(ctx context.Context, id primitive.ObjectID, req *domain.UpdateDeviceRequest) (*domain.Device, error) {
updates := bson.M{"updated_at": time.Now()}
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.Location != nil {
updates["location"] = req.Location
}
if req.Tags != nil {
updates["tags"] = req.Tags
}
if req.Metadata != nil {
// Merge metadata — use dot notation to update individual keys
for k, v := range req.Metadata {
updates["metadata."+k] = v
}
}
if req.GroupID != nil {
if *req.GroupID == "" {
updates["group_id"] = nil // Unset group
} else {
groupOID, err := primitive.ObjectIDFromHex(*req.GroupID)
if err != nil {
return nil, fmt.Errorf("invalid group_id: %w", err)
}
updates["group_id"] = groupOID
}
}
filter := bson.M{"_id": id, "deleted_at": bson.M{"$exists": false}}
update := bson.M{"$set": updates}
opts := options.FindOneAndUpdate().
SetReturnDocument(options.After)
var device domain.Device
if err := r.coll.FindOneAndUpdate(ctx, filter, update, opts).Decode(&device); err != nil {
if err == mongo.ErrNoDocuments {
return nil, nil
}
return nil, fmt.Errorf("failed to update device: %w", err)
}
return &device, nil
}
func (r *deviceRepository) UpdateStatus(ctx context.Context, deviceID string, status domain.DeviceStatus) error {
now := time.Now()
filter := bson.M{"device_id": deviceID}
update := bson.M{
"$set": bson.M{
"status": status,
"last_seen_at": now,
"updated_at": now,
},
}
result, err := r.coll.UpdateOne(ctx, filter, update)
if err != nil {
return fmt.Errorf("failed to update device status: %w", err)
}
if result.MatchedCount == 0 {
return fmt.Errorf("device not found: %s", deviceID)
}
return nil
}
func (r *deviceRepository) Delete(ctx context.Context, id primitive.ObjectID) error {
now := time.Now()
filter := bson.M{"_id": id, "deleted_at": bson.M{"$exists": false}}
update := bson.M{"$set": bson.M{"deleted_at": now, "updated_at": now}}
result, err := r.coll.UpdateOne(ctx, filter, update)
if err != nil {
return fmt.Errorf("failed to soft-delete device: %w", err)
}
if result.MatchedCount == 0 {
return fmt.Errorf("device not found: %s", id.Hex())
}
return nil
}
func (r *deviceRepository) HardDelete(ctx context.Context, id primitive.ObjectID) error {
_, err := r.coll.DeleteOne(ctx, bson.M{"_id": id})
return err
}
func (r *deviceRepository) CountByOwner(ctx context.Context, ownerID primitive.ObjectID) (int64, error) {
return r.coll.CountDocuments(ctx, bson.M{
"owner_id": ownerID,
"deleted_at": bson.M{"$exists": false},
})
}
func (r *deviceRepository) FindStaleDevices(ctx context.Context, f domain.DeviceFilter) ([]*domain.Device, error) {
threshold := time.Now().Add(-5 * time.Minute)
filter := bson.M{
"status": domain.DeviceStatusOnline,
"deleted_at": bson.M{"$exists": false},
"last_seen_at": bson.M{
"$lt": threshold,
},
}
cursor, err := r.coll.Find(ctx, filter)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var devices []*domain.Device
return devices, cursor.All(ctx, &devices)
}
Soft Delete คืออะไร? แทนที่จะลบ document ทิ้ง เราแค่ set deleted_at = now — เหมือนการย้ายของเข้าถังขยะแทนลบทิ้งถาวร เวลา query ทั่วไปก็กรอง deleted_at: {$exists: false} ออก แต่ admin ยังดูประวัติได้
Validator Setup
pkg/validator/validator.go
Validation คือ “ยาม” ที่ประตูทางเข้า — ตรวจสอบ input ก่อนที่จะวิ่งเข้า database เพราะถ้าข้อมูลแย่เข้าไปแล้ว แก้ทีหลังยากกว่าเยอะ
package validator
import (
"reflect"
"strings"
"github.com/go-playground/validator/v10"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// Validator wraps go-playground/validator with custom rules
type Validator struct {
v *validator.Validate
}
// New creates a configured Validator instance
func New() *Validator {
v := validator.New()
// Use JSON field names in error messages instead of struct field names
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
// Custom validator: mongodb_id checks if string is a valid ObjectID hex
_ = v.RegisterValidation("mongodb_id", func(fl validator.FieldLevel) bool {
_, err := primitive.ObjectIDFromHex(fl.Field().String())
return err == nil
})
// Custom validator: alphanum_dash allows alphanumeric + dash + underscore
_ = v.RegisterValidation("alphanum_dash", func(fl validator.FieldLevel) bool {
s := fl.Field().String()
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
})
return &Validator{v: v}
}
// Validate validates a struct and returns formatted errors
func (vl *Validator) Validate(s interface{}) map[string]string {
err := vl.v.Struct(s)
if err == nil {
return nil
}
errors := make(map[string]string)
for _, e := range err.(validator.ValidationErrors) {
field := e.Field()
switch e.Tag() {
case "required":
errors[field] = field + " is required"
case "min":
errors[field] = field + " must be at least " + e.Param() + " characters"
case "max":
errors[field] = field + " must be at most " + e.Param() + " characters"
case "oneof":
errors[field] = field + " must be one of: " + e.Param()
case "mongodb_id":
errors[field] = field + " is not a valid ID"
case "alphanum_dash":
errors[field] = field + " can only contain letters, numbers, hyphens and underscores"
default:
errors[field] = field + " is invalid (" + e.Tag() + ")"
}
}
return errors
}
Custom Rule ที่เพิ่มมา 2 ตัวคือ:
mongodb_id— เช็คว่า string เป็น valid ObjectID hex หรือเปล่า (24 hex chars)alphanum_dash— เช็คว่า device_id ใช้แค่ตัวอักษร ตัวเลข ขีดกลาง และ underscore ไม่มีช่องว่างหรืออักขระแปลกๆ
การใช้งาน Repository ใน Use Case
ตรงนี้คือที่ชั้นต่างๆ มาพบกัน — UseCase รับ Repository ผ่าน interface แล้วทำ Business Logic:
// internal/usecase/device_usecase.go
package usecase
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
"github.com/kangana1024/iot-workshop/backend/internal/domain"
"github.com/kangana1024/iot-workshop/backend/internal/repository"
)
// DeviceUsecase handles device business logic
type DeviceUsecase struct {
deviceRepo repository.DeviceRepository
log *zap.Logger
}
// NewDeviceUsecase creates a new DeviceUsecase
func NewDeviceUsecase(deviceRepo repository.DeviceRepository, log *zap.Logger) *DeviceUsecase {
return &DeviceUsecase{deviceRepo: deviceRepo, log: log}
}
// Register creates a new device and generates its auth token
func (uc *DeviceUsecase) Register(ctx context.Context, ownerID primitive.ObjectID, req *domain.CreateDeviceRequest) (*domain.Device, error) {
// Check if device_id already exists
existing, err := uc.deviceRepo.FindByDeviceID(ctx, req.DeviceID)
if err != nil {
return nil, fmt.Errorf("failed to check existing device: %w", err)
}
if existing != nil {
return nil, fmt.Errorf("device_id '%s' is already registered", req.DeviceID)
}
// Parse optional group ID
var groupID *primitive.ObjectID
if req.GroupID != "" {
oid, err := primitive.ObjectIDFromHex(req.GroupID)
if err != nil {
return nil, fmt.Errorf("invalid group_id: %w", err)
}
groupID = &oid
}
// Generate secure device token
token, err := generateSecureToken(32)
if err != nil {
return nil, fmt.Errorf("failed to generate device token: %w", err)
}
device := &domain.Device{
DeviceID: req.DeviceID,
Name: req.Name,
Description: req.Description,
Type: req.Type,
Status: domain.DeviceStatusOffline,
GroupID: groupID,
OwnerID: ownerID,
Location: req.Location,
Metadata: req.Metadata,
Tags: req.Tags,
Token: token,
}
created, err := uc.deviceRepo.Create(ctx, device)
if err != nil {
return nil, err
}
uc.log.Info("Device registered",
zap.String("device_id", created.DeviceID),
zap.String("owner_id", ownerID.Hex()),
)
return created, nil
}
// GetByID returns a device by its MongoDB ID, ensuring it belongs to owner
func (uc *DeviceUsecase) GetByID(ctx context.Context, id string, ownerID primitive.ObjectID) (*domain.Device, error) {
oid, err := primitive.ObjectIDFromHex(id)
if err != nil {
return nil, fmt.Errorf("invalid device id: %w", err)
}
device, err := uc.deviceRepo.FindByID(ctx, oid)
if err != nil {
return nil, err
}
if device == nil {
return nil, nil // 404 case
}
// Ownership check (admins bypass this in handler)
if device.OwnerID != ownerID {
return nil, fmt.Errorf("access denied")
}
return device, nil
}
// generateSecureToken creates a cryptographically secure hex token
func generateSecureToken(bytes int) (string, error) {
b := make([]byte, bytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
สังเกตสิ่งที่ UseCase ทำ: เช็ค duplicate ก่อน insert, generate token ที่ปลอดภัย, ตรวจ ownership — พวกนี้คือ Business Logic ที่ไม่ควรอยู่ใน Repository หรือ Handler
ทดสอบ Repository
# สร้าง test file
# internal/repository/mongo/device_repository_test.go
# รัน integration tests (ต้องการ MongoDB running)
go test ./internal/repository/mongo/... -v -tags integration
# รัน unit tests เท่านั้น
go test ./... -v -short
ตัวอย่าง Test
func TestDeviceRepository_CreateAndFind(t *testing.T) {
// Setup test database
ctx := context.Background()
client := testutil.NewTestMongoDB(t)
db := client.Database("test_iot_" + t.Name())
defer db.Drop(ctx)
repo := mongo.NewDeviceRepository(db, zap.NewNop())
ownerID := primitive.NewObjectID()
// Create device
device := &domain.Device{
DeviceID: "test-sensor-001",
Name: "Test Sensor",
Type: domain.DeviceTypeSensor,
OwnerID: ownerID,
}
created, err := repo.Create(ctx, device)
require.NoError(t, err)
assert.NotEmpty(t, created.ID)
assert.Equal(t, "test-sensor-001", created.DeviceID)
// Find by ID
found, err := repo.FindByID(ctx, created.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, created.DeviceID, found.DeviceID)
// Find by DeviceID
byDeviceID, err := repo.FindByDeviceID(ctx, "test-sensor-001")
require.NoError(t, err)
require.NotNil(t, byDeviceID)
}
สรุปสิ่งที่ได้ในตอนนี้
มาลุยกันมาไกลมากนะครับ! สิ่งที่น้องๆ ได้ไปวันนี้:
- Domain Models ครบชุด — Device, User, DeviceGroup, AlertRule พร้อม BSON Tags และ helper methods
- MongoDB Connection ที่ตั้งค่า Connection Pooling และ Compression อย่างถูกต้อง
- Database Indexes ที่ออกแบบมาสำหรับ query ที่ใช้จริงในระบบ
- Repository Pattern ที่แยก Data Access ออกจาก Business Logic — ทำให้ test ง่าย และ swap implementation ได้
- Custom Validator พร้อม
mongodb_idและalphanum_dashrules - Soft Delete เพื่อรักษา audit trail โดยไม่ลบข้อมูลจริง
Architecture ที่ดี = เหนื่อยหน่อยตอนออกแบบ สบายมากตอนแก้บัค (ง •̀_•́)ง
ขั้นตอนถัดไป
- ก่อนหน้า: IoT Workshop: Go Fiber Bootstrap
- ถัดไป: IoT Workshop: Device Management API — เอา Repository ที่สร้างมาใส่ใน HTTP Handler จริงๆ เพิ่ม CRUD endpoints พร้อม Auth middleware