IoT Workshop: MongoDB Models & Repository Pattern

IoT Workshop: MongoDB Models & Repository Pattern

Showkhun · Workshop ·

Branch: workshop/dev-02-mongodb-models Phase: 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 ก็ต้องแก้ทุกที่ — เจ็บปวดมาก

Mermaid Diagram

จะเห็นว่า 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_dash rules
  • Soft Delete เพื่อรักษา audit trail โดยไม่ลบข้อมูลจริง
Architecture ที่ดี = เหนื่อยหน่อยตอนออกแบบ  สบายมากตอนแก้บัค  (ง •̀_•́)ง

ขั้นตอนถัดไป