diff --git a/docker-compose.yml b/docker-compose.yml index 121d453..09df9d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -166,9 +166,13 @@ services: environment: - PORT=8085 - DATABASE_URL=postgres://auron:auron_pass@products-db:5432/products_db?sslmode=disable + - REDIS_URL=redis://redis:6379/0 + - KAFKA_BROKERS=kafka:29092 depends_on: products-db: condition: service_healthy + kafka: + condition: service_healthy networks: - auron-network restart: unless-stopped diff --git a/services/inventory-service/.env.example b/services/inventory-service/.env.example new file mode 100644 index 0000000..ecc0999 --- /dev/null +++ b/services/inventory-service/.env.example @@ -0,0 +1,6 @@ +# Inventory Service +PORT=8085 +DATABASE_URL=postgres://auron:auron_pass@localhost:5433/products_db?sslmode=disable +REDIS_URL=redis://localhost:6379/0 +KAFKA_BROKERS=localhost:9092 +GORM_LOG_LEVEL=warn diff --git a/services/inventory-service/Dockerfile b/services/inventory-service/Dockerfile new file mode 100644 index 0000000..e6d9ac1 --- /dev/null +++ b/services/inventory-service/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache git + +COPY go.mod go.sum ./ + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /inventory-service . + +FROM alpine:3.18 + +RUN apk add --no-cache ca-certificates curl + +WORKDIR /app + +COPY --from=builder /inventory-service . + +EXPOSE 8085 + +CMD ["./inventory-service"] diff --git a/services/inventory-service/IMPLEMENTATION_PLAN.md b/services/inventory-service/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..e9d9389 --- /dev/null +++ b/services/inventory-service/IMPLEMENTATION_PLAN.md @@ -0,0 +1,429 @@ +# Inventory Service — Implementation Plan + +## Overview + +The inventory service (port **8085**) manages stock levels for products. +It is a **Kafka consumer + HTTP API** hybrid service: + +- Exposes admin-only HTTP endpoints for viewing and manually setting stock +- Consumes `order.created` → reserves stock (`ReservedQuantity += n`) +- Consumes `order.cancelled` → releases reservation (`ReservedQuantity -= n`) +- Publishes `inventory.updated` on every stock change +- Publishes `inventory.low_stock` when available stock drops below threshold + +### Gateway Routes (already wired, admin-only) + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| `GET` | `/api/inventory/:product_id` | Admin | Get stock levels for a product | +| `PUT` | `/api/inventory/:product_id` | Admin | Set total stock (restocking) | + +### Database + +Inventory-service **shares `products_db`** with product-service. It does **not** create the `inventory` table — product-service's AutoMigrate owns it. Inventory-service connects to the same DB and operates directly on the `inventory` table. + +The table structure (from product-service): +``` +inventory +├── product_id UUID PRIMARY KEY +├── total_quantity INT NOT NULL DEFAULT 0 +├── reserved_quantity INT NOT NULL DEFAULT 0 +├── version INT NOT NULL DEFAULT 0 ← optimistic locking +└── updated_at TIMESTAMP NOT NULL DEFAULT NOW() +``` + +`AvailableQuantity = TotalQuantity - ReservedQuantity` + +### Stock Reservation Flow + +``` +order-service → publishes order.created + ↓ +inventory-service consumes order.created + ↓ +Increments ReservedQuantity for each item (optimistic lock on version) + ↓ +Publishes inventory.updated (and inventory.low_stock if available stock < threshold) + +If order is cancelled: +order-service → publishes order.cancelled + ↓ +inventory-service consumes order.cancelled + ↓ +Decrements ReservedQuantity for each item + ↓ +Publishes inventory.updated +``` + +> **Why not deduct TotalQuantity on order.created?** +> Reserved stock stays visible to admins as "committed but not shipped." Total stock only changes when an admin performs a restock (`PUT /inventory/:product_id`). This gives accurate available-stock visibility without needing inter-service HTTP calls. + +--- + +## Folder Structure + +``` +services/inventory-service/ +├── cmd/ +│ ├── config.go # env vars → appConfig +│ ├── dotenv.go # load .env in non-production +│ ├── infrastructure.go # setupDatabase, setupRedis (no AutoMigrate) +│ ├── kafka.go # setupKafkaPublisher, setupKafkaConsumer, startKafkaConsumer +│ ├── run.go # wire everything together +│ └── server.go # setupRouter, registerGracefulShutdown +├── db/ +│ └── NOTE.md # explains that product-service owns the inventory table +├── internal/ +│ ├── cache/ +│ │ └── inventory_cache.go +│ ├── domain/ +│ │ ├── inventory.go # Inventory entity, DTOs, event structs +│ │ ├── errors.go # sentinel errors +│ │ ├── repository.go # InventoryRepository interface +│ │ ├── service.go # InventoryService interface +│ │ ├── cache.go # InventoryCache interface +│ │ └── events.go # EventPublisher + topic constants +│ ├── events/ +│ │ ├── kafka_publisher.go +│ │ └── kafka_consumer.go # multi-topic consumer (2 topics) +│ ├── handler/ +│ │ └── inventory_handler.go +│ ├── repository/ +│ │ └── inventory_repository.go +│ ├── route/ +│ │ └── inventory_route.go +│ └── service/ +│ └── inventory_service.go +├── main.go +├── Dockerfile +├── go.mod +├── .env +└── .env.example +``` + +> `internal/middleware/` exists in the scaffold but is not used — the gateway enforces admin auth via `X-User-Role` header before proxying. + +--- + +## Tasks + +### Task 1 — Domain Layer + +**`internal/domain/inventory.go`** + +```go +type Inventory struct { + ProductID uuid.UUID `json:"product_id" gorm:"type:uuid;primaryKey"` + TotalQuantity int `json:"total_quantity" gorm:"not null;default:0"` + ReservedQuantity int `json:"reserved_quantity" gorm:"not null;default:0"` + Version int `json:"version" gorm:"not null;default:0"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` +} + +func (Inventory) TableName() string { return "inventory" } + +func (i *Inventory) AvailableQuantity() int { + return i.TotalQuantity - i.ReservedQuantity +} +``` + +- `InventoryResponse` DTO — adds computed `available_quantity` field +- `UpdateInventoryRequest` — `{ total_quantity int, binding:"required,min=0" }` +- `LowStockThreshold = 10` — constant; triggers `inventory.low_stock` event when available stock drops at or below this +- `OrderCreatedEvent` / `OrderCancelledEvent` — shape of messages from order-service: + ```go + type OrderCreatedEvent struct { + OrderID uuid.UUID `json:"id"` // matches Order.ID json tag + UserID uuid.UUID `json:"user_id"` + Items []OrderEventItem `json:"items"` + } + type OrderEventItem struct { + ProductID uuid.UUID `json:"product_id"` + Quantity int `json:"quantity"` + } + ``` + `OrderCancelledEvent` is identical in shape — same `Order` struct published by order-service. + +**`internal/domain/errors.go`** +- `ErrInventoryNotFound`, `ErrInsufficientStock`, `ErrInvalidQuantity` + +**`internal/domain/repository.go`** +```go +type InventoryRepository interface { + GetByProductID(productID uuid.UUID) (*Inventory, error) + SetTotalQuantity(productID uuid.UUID, quantity int) (*Inventory, error) + ReserveStock(productID uuid.UUID, quantity int) (*Inventory, error) + ReleaseStock(productID uuid.UUID, quantity int) (*Inventory, error) +} +``` + +**`internal/domain/service.go`** +```go +type InventoryService interface { + GetInventory(ctx context.Context, productID uuid.UUID) (*InventoryResponse, error) + SetInventory(ctx context.Context, productID uuid.UUID, req UpdateInventoryRequest) (*InventoryResponse, error) + HandleOrderCreated(ctx context.Context, event OrderCreatedEvent) error + HandleOrderCancelled(ctx context.Context, event OrderCreatedEvent) error +} +``` + +**`internal/domain/cache.go`** +```go +type InventoryCache interface { + GetInventory(ctx context.Context, productID uuid.UUID) (*Inventory, error) + SetInventory(ctx context.Context, inv *Inventory) error + InvalidateInventory(ctx context.Context, productID uuid.UUID) error +} +``` + +**`internal/domain/events.go`** +- `EventPublisher` interface: `Publish(ctx, topic string, payload any) error` + `Close() error` +- Consumed topics (constants, not published): + - `TopicOrderCreated = "order.created"` + - `TopicOrderCancelled = "order.cancelled"` +- Published topics: + - `TopicInventoryUpdated = "inventory.updated"` + - `TopicInventoryLowStock = "inventory.low_stock"` + +--- + +### Task 2 — Repository Layer + +**`internal/repository/inventory_repository.go`** + +- `GetByProductID` — returns `ErrInventoryNotFound` on GORM `ErrRecordNotFound` +- `SetTotalQuantity` — admin restock: uses `db.Save()` with upsert semantics (creates if not exists, updates if exists); bumps `Version` +- `ReserveStock` — uses optimistic locking: + ```go + result := db.Model(&Inventory{}). + Where("product_id = ? AND version = ? AND (total_quantity - reserved_quantity) >= ?", + productID, current.Version, quantity). + Updates(map[string]any{ + "reserved_quantity": gorm.Expr("reserved_quantity + ?", quantity), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + if result.RowsAffected == 0 { + return nil, domain.ErrInsufficientStock + } + ``` + Returns `ErrInsufficientStock` if the WHERE clause misses (concurrent update or not enough stock). +- `ReleaseStock` — similar pattern; clamps `reserved_quantity` to minimum 0 via `GREATEST`: + ```go + Updates(map[string]any{ + "reserved_quantity": gorm.Expr("GREATEST(reserved_quantity - ?, 0)", quantity), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + ``` + +> **No AutoMigrate** — the `inventory` table is owned by product-service. Inventory-service connects to the same DB and reads/writes the table without managing its schema. + +--- + +### Task 3 — Cache Layer + +**`internal/cache/inventory_cache.go`** +- Key: `inventory:` (TTL **5 minutes** — shorter than other caches since stock changes frequently) +- JSON marshal/unmarshal; Redis miss returns `nil, nil` + +--- + +### Task 4 — Kafka Events (Publisher + Consumer) + +**`internal/events/kafka_publisher.go`** +- Same pattern as other services: `kafkaPublisher` with `writers map[string]*kafka.Writer` + +**`internal/events/kafka_consumer.go`** + +Multi-topic consumer — subscribes to `order.created` AND `order.cancelled` with a single struct, two readers: + +```go +type KafkaConsumer struct { + readers []readerEntry + service domain.InventoryService +} + +type readerEntry struct { + reader *kafka.Reader + topic string +} + +func NewKafkaConsumer(brokers []string, service domain.InventoryService) *KafkaConsumer +``` + +- `Start(ctx)` — launches one goroutine per reader; each goroutine calls `handleMessage(topic, payload)` +- `handleMessage` — switches on topic, unmarshals the appropriate event type, calls the right service method +- `Close()` — closes all readers + +Group IDs: +- `order.created` → group `inventory-service-orders` +- `order.cancelled` → group `inventory-service-orders` (same group, different topic) + +--- + +### Task 5 — Service Layer + +**`internal/service/inventory_service.go`** + +**`GetInventory(ctx, productID)`** +1. Cache-aside: check `inventoryCache.GetInventory(ctx, productID)` +2. DB fallback on miss +3. Return `InventoryResponse` with computed `available_quantity` + +**`SetInventory(ctx, productID, req)`** +1. Call `repo.SetTotalQuantity(productID, req.TotalQuantity)` (upsert) +2. Invalidate + re-cache +3. Publish `inventory.updated` async +4. Check if available stock ≤ `LowStockThreshold` → publish `inventory.low_stock` async + +**`HandleOrderCreated(ctx, event)`** +- For each `event.Items`: + 1. Call `repo.ReserveStock(item.ProductID, item.Quantity)` + 2. On `ErrInsufficientStock`: log error, continue with remaining items (partial reservation is acceptable — could block order fulfillment in a real system, but acceptable for portfolio scope) + 3. Invalidate cache for affected product + 4. Publish `inventory.updated` async; if available stock ≤ threshold, also publish `inventory.low_stock` + +**`HandleOrderCancelled(ctx, event)`** +- For each `event.Items`: + 1. Call `repo.ReleaseStock(item.ProductID, item.Quantity)` + 2. Invalidate cache + 3. Publish `inventory.updated` async + +--- + +### Task 6 — Handler + Route Layers + +**`internal/handler/inventory_handler.go`** +- `InventoryHandler` struct with `service domain.InventoryService` +- `GetInventory(c)` — parse `:product_id` UUID param, call service, return 200/404 +- `SetInventory(c)` — parse `:product_id`, bind `UpdateInventoryRequest`, call service, return 200 +- `handleError(c, err)` — maps `ErrInventoryNotFound` → 404, `ErrInvalidQuantity` → 400, default → 500 +- No `getUserID` helper needed — admin identity is verified at the gateway; inventory-service trusts the request is admin + +**`internal/route/inventory_route.go`** +```go +func RegisterInventoryRoutes(router *gin.Engine, inventoryHandler *handler.InventoryHandler) { + api := router.Group("/") + api.GET("/inventory/:product_id", inventoryHandler.GetInventory) + api.PUT("/inventory/:product_id", inventoryHandler.SetInventory) +} +``` + +--- + +### Task 7 — cmd Bootstrap + +**`cmd/config.go`** +```go +type appConfig struct { + Port string + DatabaseURL string + RedisURL string + KafkaBrokers string +} +``` +Defaults: port 8085, `localhost:5433/products_db`, `localhost:6379`, `localhost:9092` + +> Note: default DATABASE_URL uses port 5433 (host-mapped port for products-db) for local dev. + +**`cmd/dotenv.go`** — identical pattern to all other services + +**`cmd/infrastructure.go`** +- `setupDatabase` with connection pooling +- **No `runMigrations`** — product-service owns the `inventory` table; inventory-service skips AutoMigrate +- `setupRedis` with 5s ping timeout +- `resolveGormLogLevel` + +**`cmd/kafka.go`** +- `inventoryPublishedTopics`: `TopicInventoryUpdated`, `TopicInventoryLowStock` +- `setupKafkaPublisher(brokers string) domain.EventPublisher` +- `setupKafkaConsumer(brokers string, svc domain.InventoryService) *events.KafkaConsumer` + - ensures `order.created` and `order.cancelled` topics exist (non-fatal if they already exist) +- `startKafkaConsumer(ctx, consumer)` +- `closeKafkaPublisher`, `parseBrokers`, `ensureTopics` — same helpers as other services + +**`cmd/run.go`** +```go +func Run() { + cfg := loadConfig() + db := setupDatabase(cfg.DatabaseURL) + redisClient := setupRedis(cfg.RedisURL) + publisher := setupKafkaPublisher(cfg.KafkaBrokers) + inventoryRepo := repository.NewInventoryRepository(db) + inventoryCache := cache.NewInventoryCache(redisClient) + inventorySvc := service.NewInventoryService(inventoryRepo, inventoryCache, publisher) + ctx, cancel := context.WithCancel(context.Background()) + consumer := setupKafkaConsumer(cfg.KafkaBrokers, inventorySvc) + startKafkaConsumer(ctx, consumer) + inventoryHandler := handler.NewInventoryHandler(inventorySvc) + router := setupRouter(inventoryHandler) + registerGracefulShutdown(db, redisClient, publisher, consumer, cancel) + router.Run(fmt.Sprintf(":%s", cfg.Port)) +} +``` + +**`cmd/server.go`** +- `setupRouter(*handler.InventoryHandler) *gin.Engine` — release mode, `/health`, `/metrics`, calls `RegisterInventoryRoutes` + +--- + +### Task 8 — Entry Point, Dockerfile, Env Files, docker-compose + +**`main.go`** — `cmd.Run()` + +**`Dockerfile`** — same multi-stage pattern: `golang:1.25-alpine` builder → `alpine:3.18` runtime; binary `inventory-service`; EXPOSE 8085 + +**`.env`** — local dev values: +``` +PORT=8085 +DATABASE_URL=postgres://auron:auron_pass@localhost:5433/products_db?sslmode=disable +REDIS_URL=redis://localhost:6379/0 +KAFKA_BROKERS=localhost:9092 +GORM_LOG_LEVEL=warn +``` + +**`.env.example`** — same with documented placeholders + +**`docker-compose.yml`** — update `inventory-service` block: +```yaml +environment: + - PORT=8085 + - DATABASE_URL=postgres://auron:auron_pass@products-db:5432/products_db?sslmode=disable + - REDIS_URL=redis://redis:6379/0 + - KAFKA_BROKERS=kafka:29092 +depends_on: + products-db: + condition: service_healthy + kafka: + condition: service_healthy +``` + +--- + +## Key Design Decisions + +| Decision | Choice | Reason | +|---|---|---| +| Shared database | inventory-service reads/writes `products_db` | Product and inventory are tightly coupled; avoids extra DB; consistent with docker-compose original design | +| No AutoMigrate | inventory-service skips it | product-service owns the `inventory` table schema; running AutoMigrate in both is safe but unnecessary | +| Reservation vs deduction | Reserve on `order.created`, release on `order.cancelled` | Available stock stays accurate; no HTTP call to order-service needed for `payment.completed` | +| Optimistic locking | `version` field incremented on every update | Prevents lost updates under concurrent order placement; `ErrInsufficientStock` returned if version mismatches | +| Multi-topic consumer | Single struct with two readers, one goroutine each | kafka-go Reader only supports one topic; two readers is the idiomatic approach | +| Low-stock threshold | Constant `LowStockThreshold = 10` | Simple; notification-service can consume `inventory.low_stock` to alert admins | +| go.mod module | `auron/inventory-service` | Matches pattern of all other services | + +--- + +## Dependencies + +``` +github.com/gin-gonic/gin v1.12.0 +github.com/google/uuid v1.6.0 +github.com/redis/go-redis/v9 v9.19.0 +github.com/segmentio/kafka-go v0.4.51 +gorm.io/driver/postgres v1.6.0 +gorm.io/gorm v1.31.1 +``` + +No Stripe SDK needed. Simpler dependency set than payment-service. diff --git a/services/inventory-service/cmd/config.go b/services/inventory-service/cmd/config.go new file mode 100644 index 0000000..704673e --- /dev/null +++ b/services/inventory-service/cmd/config.go @@ -0,0 +1,41 @@ +package cmd + +import "os" + +type appConfig struct { + Port string + DatabaseURL string + RedisURL string + KafkaBrokers string +} + +func loadConfig() appConfig { + loadDotEnvFile(".env") + + port := os.Getenv("PORT") + if port == "" { + port = "8085" + } + + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + databaseURL = "postgres://auron:auron_pass@localhost:5433/products_db?sslmode=disable" + } + + redisURL := os.Getenv("REDIS_URL") + if redisURL == "" { + redisURL = "redis://localhost:6379/0" + } + + kafkaBrokers := os.Getenv("KAFKA_BROKERS") + if kafkaBrokers == "" { + kafkaBrokers = "localhost:9092" + } + + return appConfig{ + Port: port, + DatabaseURL: databaseURL, + RedisURL: redisURL, + KafkaBrokers: kafkaBrokers, + } +} diff --git a/services/inventory-service/cmd/dotenv.go b/services/inventory-service/cmd/dotenv.go new file mode 100644 index 0000000..3c652db --- /dev/null +++ b/services/inventory-service/cmd/dotenv.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "bufio" + "os" + "strings" +) + +func loadDotEnvFile(path string) { + file, err := os.Open(path) + if err != nil { + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + value = strings.Trim(value, `"'`) + + if key == "" { + continue + } + + if _, exists := os.LookupEnv(key); exists { + continue + } + + _ = os.Setenv(key, value) + } +} diff --git a/services/inventory-service/cmd/infrastructure.go b/services/inventory-service/cmd/infrastructure.go new file mode 100644 index 0000000..2d56ad9 --- /dev/null +++ b/services/inventory-service/cmd/infrastructure.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "context" + "os" + "strings" + "time" + + "github.com/redis/go-redis/v9" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupDatabase(databaseURL string) (*gorm.DB, error) { + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{ + Logger: logger.Default.LogMode(resolveGormLogLevel()), + }) + if err != nil { + return nil, err + } + + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + sqlDB.SetConnMaxLifetime(time.Hour) + + return db, nil +} + +func setupRedis(redisURL string) (*redis.Client, error) { + opt, err := redis.ParseURL(redisURL) + if err != nil { + return nil, err + } + + client := redis.NewClient(opt) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := client.Ping(ctx).Err(); err != nil { + return nil, err + } + + return client, nil +} + +func resolveGormLogLevel() logger.LogLevel { + switch strings.ToLower(strings.TrimSpace(os.Getenv("GORM_LOG_LEVEL"))) { + case "silent": + return logger.Silent + case "error": + return logger.Error + case "warn", "warning": + return logger.Warn + case "info": + return logger.Info + default: + return logger.Warn + } +} diff --git a/services/inventory-service/cmd/kafka.go b/services/inventory-service/cmd/kafka.go new file mode 100644 index 0000000..6c4dfdb --- /dev/null +++ b/services/inventory-service/cmd/kafka.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "context" + "log/slog" + "strconv" + "strings" + "time" + + "auron/inventory-service/internal/domain" + "auron/inventory-service/internal/events" + + "github.com/segmentio/kafka-go" +) + +var inventoryTopics = []string{ + domain.TopicInventoryUpdated, + domain.TopicInventoryLowStock, +} + +func setupKafkaPublisher(kafkaBrokers string) domain.EventPublisher { + brokers := parseBrokers(kafkaBrokers) + ensureTopics(brokers, inventoryTopics) + + writers := make(map[string]*kafka.Writer, len(inventoryTopics)) + for _, topic := range inventoryTopics { + writers[topic] = &kafka.Writer{ + Addr: kafka.TCP(brokers...), + Topic: topic, + Balancer: &kafka.LeastBytes{}, + RequiredAcks: kafka.RequireOne, + BatchTimeout: 10 * time.Millisecond, + } + } + + return events.NewKafkaPublisher(writers) +} + +func setupKafkaConsumer(kafkaBrokers string, svc domain.InventoryService) *events.KafkaConsumer { + brokers := parseBrokers(kafkaBrokers) + return events.NewKafkaConsumer(brokers, svc) +} + +func startKafkaConsumer(ctx context.Context, consumer *events.KafkaConsumer) { + consumer.Start(ctx) + slog.Info("kafka consumer started", + "topics", []string{domain.TopicOrderCreated, domain.TopicOrderCancelled}, + "group", "inventory-service-orders", + ) +} + +func parseBrokers(kafkaBrokers string) []string { + parts := strings.Split(kafkaBrokers, ",") + brokers := make([]string, 0, len(parts)) + for _, b := range parts { + if trimmed := strings.TrimSpace(b); trimmed != "" { + brokers = append(brokers, trimmed) + } + } + if len(brokers) == 0 { + return []string{"localhost:9092"} + } + return brokers +} + +func ensureTopics(brokers []string, topics []string) { + if len(brokers) == 0 || len(topics) == 0 { + return + } + + conn, err := kafka.Dial("tcp", brokers[0]) + if err != nil { + slog.Warn("kafka topic init skipped: cannot connect", "broker", brokers[0], "error", err) + return + } + defer conn.Close() + + controller, err := conn.Controller() + if err != nil { + slog.Warn("kafka topic init skipped: cannot get controller", "error", err) + return + } + + controllerConn, err := kafka.Dial("tcp", controller.Host+":"+strconv.Itoa(controller.Port)) + if err != nil { + slog.Warn("kafka topic init skipped: cannot connect to controller", "error", err) + return + } + defer controllerConn.Close() + + configs := make([]kafka.TopicConfig, 0, len(topics)) + for _, topic := range topics { + configs = append(configs, kafka.TopicConfig{ + Topic: topic, + NumPartitions: 3, + ReplicationFactor: 1, + }) + } + + if err := controllerConn.CreateTopics(configs...); err != nil { + slog.Warn("kafka topic init failed", "topics", topics, "error", err) + return + } + + slog.Info("kafka topics ensured", "topics", topics) +} + +func closeKafkaPublisher(publisher domain.EventPublisher) { + if err := publisher.Close(); err != nil { + slog.Warn("failed to close kafka publisher", "error", err) + } +} diff --git a/services/inventory-service/cmd/run.go b/services/inventory-service/cmd/run.go new file mode 100644 index 0000000..0e69a40 --- /dev/null +++ b/services/inventory-service/cmd/run.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "context" + "fmt" + "log" + "log/slog" + "os" + "os/signal" + "syscall" + + "auron/inventory-service/internal/cache" + "auron/inventory-service/internal/domain" + "auron/inventory-service/internal/events" + "auron/inventory-service/internal/handler" + "auron/inventory-service/internal/repository" + "auron/inventory-service/internal/service" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +func Run() { + cfg := loadConfig() + + db, err := setupDatabase(cfg.DatabaseURL) + if err != nil { + log.Fatalf("failed to connect to database: %v", err) + } + log.Println("database connected") + + redisClient, err := setupRedis(cfg.RedisURL) + if err != nil { + log.Fatalf("failed to connect to Redis: %v", err) + } + + publisher := setupKafkaPublisher(cfg.KafkaBrokers) + + inventoryRepo := repository.NewInventoryRepository(db) + inventoryCache := cache.NewInventoryCache(redisClient) + inventorySvc := service.NewInventoryService(inventoryRepo, inventoryCache, publisher) + + ctx, cancel := context.WithCancel(context.Background()) + consumer := setupKafkaConsumer(cfg.KafkaBrokers, inventorySvc) + startKafkaConsumer(ctx, consumer) + + inventoryHandler := handler.NewInventoryHandler(inventorySvc) + router := setupRouter(inventoryHandler) + + registerGracefulShutdown(db, redisClient, publisher, consumer, cancel) + + addr := fmt.Sprintf(":%s", cfg.Port) + log.Printf("starting inventory-service on %s", addr) + if err := router.Run(addr); err != nil { + log.Fatalf("failed to start server: %v", err) + } +} + +func registerGracefulShutdown( + db *gorm.DB, + redisClient *redis.Client, + publisher domain.EventPublisher, + consumer *events.KafkaConsumer, + cancel context.CancelFunc, +) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-quit + fmt.Println("\nshutting down inventory-service...") + cancel() + if err := consumer.Close(); err != nil { + slog.Warn("error closing kafka consumer", "error", err) + } + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + _ = redisClient.Close() + closeKafkaPublisher(publisher) + os.Exit(0) + }() +} diff --git a/services/inventory-service/cmd/server.go b/services/inventory-service/cmd/server.go new file mode 100644 index 0000000..64b5f60 --- /dev/null +++ b/services/inventory-service/cmd/server.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "time" + + "auron/inventory-service/internal/handler" + "auron/inventory-service/internal/route" + + "github.com/gin-gonic/gin" +) + +func setupRouter(inventoryHandler *handler.InventoryHandler) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + router := gin.New() + + router.Use(gin.Logger()) + router.Use(gin.Recovery()) + + router.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "healthy", + "service": "inventory-service", + "timestamp": time.Now().UTC(), + }) + }) + + router.GET("/metrics", func(c *gin.Context) { + c.String(200, "# Prometheus metrics endpoint\n") + }) + + route.RegisterInventoryRoutes(router, inventoryHandler) + + return router +} diff --git a/services/inventory-service/go.mod b/services/inventory-service/go.mod new file mode 100644 index 0000000..b747470 --- /dev/null +++ b/services/inventory-service/go.mod @@ -0,0 +1,55 @@ +module auron/inventory-service + +go 1.25.8 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/google/uuid v1.6.0 + github.com/redis/go-redis/v9 v9.19.0 + github.com/segmentio/kafka-go v0.4.51 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.6 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pierrec/lz4/v4 v4.1.15 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/services/inventory-service/go.sum b/services/inventory-service/go.sum new file mode 100644 index 0000000..0e9a0de --- /dev/null +++ b/services/inventory-service/go.sum @@ -0,0 +1,134 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= +github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno= +github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/services/inventory-service/internal/cache/inventory_cache.go b/services/inventory-service/internal/cache/inventory_cache.go new file mode 100644 index 0000000..6e62266 --- /dev/null +++ b/services/inventory-service/internal/cache/inventory_cache.go @@ -0,0 +1,55 @@ +package cache + +import ( + "context" + "encoding/json" + "time" + + "auron/inventory-service/internal/domain" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +const ( + inventoryPrefix = "inventory:" + inventoryTTL = 5 * time.Minute +) + +type InventoryCache struct { + redis *redis.Client +} + +func NewInventoryCache(redisClient *redis.Client) domain.InventoryCache { + return &InventoryCache{redis: redisClient} +} + +func (c *InventoryCache) GetInventory(ctx context.Context, productID uuid.UUID) (*domain.Inventory, error) { + key := inventoryPrefix + productID.String() + cached, err := c.redis.Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return nil, nil + } + return nil, err + } + + var inv domain.Inventory + if err := json.Unmarshal([]byte(cached), &inv); err != nil { + return nil, err + } + return &inv, nil +} + +func (c *InventoryCache) SetInventory(ctx context.Context, inv *domain.Inventory) error { + key := inventoryPrefix + inv.ProductID.String() + data, err := json.Marshal(inv) + if err != nil { + return err + } + return c.redis.Set(ctx, key, data, inventoryTTL).Err() +} + +func (c *InventoryCache) InvalidateInventory(ctx context.Context, productID uuid.UUID) error { + return c.redis.Del(ctx, inventoryPrefix+productID.String()).Err() +} diff --git a/services/inventory-service/internal/domain/cache.go b/services/inventory-service/internal/domain/cache.go new file mode 100644 index 0000000..b135404 --- /dev/null +++ b/services/inventory-service/internal/domain/cache.go @@ -0,0 +1,13 @@ +package domain + +import ( + "context" + + "github.com/google/uuid" +) + +type InventoryCache interface { + GetInventory(ctx context.Context, productID uuid.UUID) (*Inventory, error) + SetInventory(ctx context.Context, inv *Inventory) error + InvalidateInventory(ctx context.Context, productID uuid.UUID) error +} diff --git a/services/inventory-service/internal/domain/errors.go b/services/inventory-service/internal/domain/errors.go new file mode 100644 index 0000000..f49fe03 --- /dev/null +++ b/services/inventory-service/internal/domain/errors.go @@ -0,0 +1,9 @@ +package domain + +import "errors" + +var ( + ErrInventoryNotFound = errors.New("inventory not found") + ErrInsufficientStock = errors.New("insufficient stock") + ErrInvalidQuantity = errors.New("quantity must be >= 0") +) diff --git a/services/inventory-service/internal/domain/events.go b/services/inventory-service/internal/domain/events.go new file mode 100644 index 0000000..d9f2112 --- /dev/null +++ b/services/inventory-service/internal/domain/events.go @@ -0,0 +1,18 @@ +package domain + +import "context" + +type EventPublisher interface { + Publish(ctx context.Context, topic string, payload any) error + Close() error +} + +const ( + // Consumed topics + TopicOrderCreated = "order.created" + TopicOrderCancelled = "order.cancelled" + + // Published topics + TopicInventoryUpdated = "inventory.updated" + TopicInventoryLowStock = "inventory.low_stock" +) diff --git a/services/inventory-service/internal/domain/inventory.go b/services/inventory-service/internal/domain/inventory.go new file mode 100644 index 0000000..5fc699f --- /dev/null +++ b/services/inventory-service/internal/domain/inventory.go @@ -0,0 +1,61 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +const LowStockThreshold = 10 + +type Inventory struct { + ProductID uuid.UUID `json:"product_id" gorm:"type:uuid;primaryKey"` + TotalQuantity int `json:"total_quantity" gorm:"not null;default:0"` + ReservedQuantity int `json:"reserved_quantity" gorm:"not null;default:0"` + Version int `json:"version" gorm:"not null;default:0"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` +} + +func (Inventory) TableName() string { + return "inventory" +} + +func (i *Inventory) AvailableQuantity() int { + return i.TotalQuantity - i.ReservedQuantity +} + +func (i *Inventory) ToResponse() *InventoryResponse { + return &InventoryResponse{ + ProductID: i.ProductID, + TotalQuantity: i.TotalQuantity, + ReservedQuantity: i.ReservedQuantity, + AvailableQuantity: i.AvailableQuantity(), + UpdatedAt: i.UpdatedAt, + } +} + +type InventoryResponse struct { + ProductID uuid.UUID `json:"product_id"` + TotalQuantity int `json:"total_quantity"` + ReservedQuantity int `json:"reserved_quantity"` + AvailableQuantity int `json:"available_quantity"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UpdateInventoryRequest struct { + TotalQuantity int `json:"total_quantity" binding:"required,min=0"` +} + +// OrderCreatedEvent is the shape of the Kafka message from order-service. +// Used for both order.created and order.cancelled — order-service publishes +// the full Order struct for both events, which has json:"id" for the order ID. +type OrderCreatedEvent struct { + OrderID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Items []OrderEventItem `json:"items"` +} + +type OrderEventItem struct { + ProductID uuid.UUID `json:"product_id"` + Quantity int `json:"quantity"` +} diff --git a/services/inventory-service/internal/domain/repository.go b/services/inventory-service/internal/domain/repository.go new file mode 100644 index 0000000..b66e4f0 --- /dev/null +++ b/services/inventory-service/internal/domain/repository.go @@ -0,0 +1,10 @@ +package domain + +import "github.com/google/uuid" + +type InventoryRepository interface { + GetByProductID(productID uuid.UUID) (*Inventory, error) + SetTotalQuantity(productID uuid.UUID, quantity int) (*Inventory, error) + ReserveStock(productID uuid.UUID, quantity int) (*Inventory, error) + ReleaseStock(productID uuid.UUID, quantity int) (*Inventory, error) +} diff --git a/services/inventory-service/internal/domain/service.go b/services/inventory-service/internal/domain/service.go new file mode 100644 index 0000000..6c4e4f8 --- /dev/null +++ b/services/inventory-service/internal/domain/service.go @@ -0,0 +1,14 @@ +package domain + +import ( + "context" + + "github.com/google/uuid" +) + +type InventoryService interface { + GetInventory(ctx context.Context, productID uuid.UUID) (*InventoryResponse, error) + SetInventory(ctx context.Context, productID uuid.UUID, req UpdateInventoryRequest) (*InventoryResponse, error) + HandleOrderCreated(ctx context.Context, event OrderCreatedEvent) error + HandleOrderCancelled(ctx context.Context, event OrderCreatedEvent) error +} diff --git a/services/inventory-service/internal/events/kafka_consumer.go b/services/inventory-service/internal/events/kafka_consumer.go new file mode 100644 index 0000000..b5538c9 --- /dev/null +++ b/services/inventory-service/internal/events/kafka_consumer.go @@ -0,0 +1,96 @@ +package events + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "auron/inventory-service/internal/domain" + + "github.com/segmentio/kafka-go" +) + +type readerEntry struct { + reader *kafka.Reader + topic string +} + +type KafkaConsumer struct { + readers []readerEntry + service domain.InventoryService +} + +func NewKafkaConsumer(brokers []string, service domain.InventoryService) *KafkaConsumer { + topics := []string{domain.TopicOrderCreated, domain.TopicOrderCancelled} + entries := make([]readerEntry, 0, len(topics)) + for _, topic := range topics { + reader := kafka.NewReader(kafka.ReaderConfig{ + Brokers: brokers, + Topic: topic, + GroupID: "inventory-service-orders", + MinBytes: 10e3, + MaxBytes: 10e6, + }) + entries = append(entries, readerEntry{reader: reader, topic: topic}) + } + return &KafkaConsumer{readers: entries, service: service} +} + +// Start launches one consumer goroutine per subscribed topic. +func (c *KafkaConsumer) Start(ctx context.Context) { + for _, entry := range c.readers { + go c.consumeTopic(ctx, entry) + } +} + +func (c *KafkaConsumer) consumeTopic(ctx context.Context, entry readerEntry) { + for { + msg, err := entry.reader.FetchMessage(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + slog.Error("kafka consumer: fetch error", "topic", entry.topic, "error", err) + continue + } + + if err := c.handleMessage(ctx, entry.topic, msg.Value); err != nil { + slog.Error("kafka consumer: handle error", "topic", entry.topic, "offset", msg.Offset, "error", err) + } + + if err := entry.reader.CommitMessages(ctx, msg); err != nil { + slog.Warn("kafka consumer: commit failed", "topic", entry.topic, "error", err) + } + } +} + +func (c *KafkaConsumer) handleMessage(ctx context.Context, topic string, payload []byte) error { + switch topic { + case domain.TopicOrderCreated: + var event domain.OrderCreatedEvent + if err := json.Unmarshal(payload, &event); err != nil { + return fmt.Errorf("unmarshal order.created: %w", err) + } + return c.service.HandleOrderCreated(ctx, event) + case domain.TopicOrderCancelled: + var event domain.OrderCreatedEvent + if err := json.Unmarshal(payload, &event); err != nil { + return fmt.Errorf("unmarshal order.cancelled: %w", err) + } + return c.service.HandleOrderCancelled(ctx, event) + default: + slog.Warn("kafka consumer: unhandled topic", "topic", topic) + return nil + } +} + +func (c *KafkaConsumer) Close() error { + var closeErr error + for _, entry := range c.readers { + if err := entry.reader.Close(); err != nil && closeErr == nil { + closeErr = err + } + } + return closeErr +} diff --git a/services/inventory-service/internal/events/kafka_publisher.go b/services/inventory-service/internal/events/kafka_publisher.go new file mode 100644 index 0000000..d54f3b8 --- /dev/null +++ b/services/inventory-service/internal/events/kafka_publisher.go @@ -0,0 +1,52 @@ +package events + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "auron/inventory-service/internal/domain" + + "github.com/segmentio/kafka-go" +) + +type kafkaPublisher struct { + writers map[string]*kafka.Writer +} + +func NewKafkaPublisher(writers map[string]*kafka.Writer) domain.EventPublisher { + return &kafkaPublisher{writers: writers} +} + +func (p *kafkaPublisher) Publish(ctx context.Context, topic string, payload any) error { + writer, ok := p.writers[topic] + if !ok { + return fmt.Errorf("publisher: no writer registered for topic %q", topic) + } + + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("publisher: marshal payload: %w", err) + } + + if err := writer.WriteMessages(ctx, kafka.Message{Value: data}); err != nil { + return fmt.Errorf("publisher: write to topic %q: %w", topic, err) + } + + slog.Debug("event published", slog.String("topic", topic)) + return nil +} + +func (p *kafkaPublisher) Close() error { + var closeErr error + for _, writer := range p.writers { + if writer == nil { + continue + } + if err := writer.Close(); err != nil && closeErr == nil { + closeErr = err + } + } + return closeErr +} diff --git a/services/inventory-service/internal/handler/inventory_handler.go b/services/inventory-service/internal/handler/inventory_handler.go new file mode 100644 index 0000000..8ce7544 --- /dev/null +++ b/services/inventory-service/internal/handler/inventory_handler.go @@ -0,0 +1,70 @@ +package handler + +import ( + "errors" + "net/http" + + "auron/inventory-service/internal/domain" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type InventoryHandler struct { + service domain.InventoryService +} + +func NewInventoryHandler(service domain.InventoryService) *InventoryHandler { + return &InventoryHandler{service: service} +} + +func (h *InventoryHandler) GetInventory(c *gin.Context) { + productID, err := uuid.Parse(c.Param("product_id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid product id"}) + return + } + + inv, err := h.service.GetInventory(c.Request.Context(), productID) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": inv}) +} + +func (h *InventoryHandler) SetInventory(c *gin.Context) { + productID, err := uuid.Parse(c.Param("product_id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid product id"}) + return + } + + var req domain.UpdateInventoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + return + } + + inv, err := h.service.SetInventory(c.Request.Context(), productID, req) + if err != nil { + h.handleError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": inv}) +} + +func (h *InventoryHandler) handleError(c *gin.Context, err error) { + switch { + case errors.Is(err, domain.ErrInventoryNotFound): + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrInsufficientStock): + c.JSON(http.StatusConflict, gin.H{"success": false, "error": err.Error()}) + case errors.Is(err, domain.ErrInvalidQuantity): + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"}) + } +} diff --git a/services/inventory-service/internal/repository/inventory_repository.go b/services/inventory-service/internal/repository/inventory_repository.go new file mode 100644 index 0000000..b841de9 --- /dev/null +++ b/services/inventory-service/internal/repository/inventory_repository.go @@ -0,0 +1,88 @@ +package repository + +import ( + "time" + + "auron/inventory-service/internal/domain" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type InventoryRepository struct { + db *gorm.DB +} + +func NewInventoryRepository(db *gorm.DB) domain.InventoryRepository { + return &InventoryRepository{db: db} +} + +func (r *InventoryRepository) GetByProductID(productID uuid.UUID) (*domain.Inventory, error) { + var inv domain.Inventory + if err := r.db.First(&inv, "product_id = ?", productID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, domain.ErrInventoryNotFound + } + return nil, err + } + return &inv, nil +} + +func (r *InventoryRepository) SetTotalQuantity(productID uuid.UUID, quantity int) (*domain.Inventory, error) { + now := time.Now() + inv := &domain.Inventory{ + ProductID: productID, + TotalQuantity: quantity, + UpdatedAt: now, + } + if err := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "product_id"}}, + DoUpdates: clause.Assignments(map[string]any{ + "total_quantity": quantity, + "version": gorm.Expr("inventory.version + 1"), + "updated_at": now, + }), + }).Create(inv).Error; err != nil { + return nil, err + } + return r.GetByProductID(productID) +} + +func (r *InventoryRepository) ReserveStock(productID uuid.UUID, quantity int) (*domain.Inventory, error) { + result := r.db.Model(&domain.Inventory{}). + Where("product_id = ? AND (total_quantity - reserved_quantity) >= ?", productID, quantity). + Updates(map[string]any{ + "reserved_quantity": gorm.Expr("reserved_quantity + ?", quantity), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + // Distinguish between not found and insufficient stock + if _, err := r.GetByProductID(productID); err != nil { + return nil, err + } + return nil, domain.ErrInsufficientStock + } + return r.GetByProductID(productID) +} + +func (r *InventoryRepository) ReleaseStock(productID uuid.UUID, quantity int) (*domain.Inventory, error) { + result := r.db.Model(&domain.Inventory{}). + Where("product_id = ?", productID). + Updates(map[string]any{ + "reserved_quantity": gorm.Expr("GREATEST(reserved_quantity - ?, 0)", quantity), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, domain.ErrInventoryNotFound + } + return r.GetByProductID(productID) +} diff --git a/services/inventory-service/internal/route/inventory_route.go b/services/inventory-service/internal/route/inventory_route.go new file mode 100644 index 0000000..2807f67 --- /dev/null +++ b/services/inventory-service/internal/route/inventory_route.go @@ -0,0 +1,13 @@ +package route + +import ( + "auron/inventory-service/internal/handler" + + "github.com/gin-gonic/gin" +) + +func RegisterInventoryRoutes(router *gin.Engine, inventoryHandler *handler.InventoryHandler) { + api := router.Group("/") + api.GET("/inventory/:product_id", inventoryHandler.GetInventory) + api.PUT("/inventory/:product_id", inventoryHandler.SetInventory) +} diff --git a/services/inventory-service/internal/service/inventory_service.go b/services/inventory-service/internal/service/inventory_service.go new file mode 100644 index 0000000..d2c7ea9 --- /dev/null +++ b/services/inventory-service/internal/service/inventory_service.go @@ -0,0 +1,120 @@ +package service + +import ( + "context" + "log/slog" + + "auron/inventory-service/internal/domain" + + "github.com/google/uuid" +) + +type InventoryService struct { + repo domain.InventoryRepository + cache domain.InventoryCache + publisher domain.EventPublisher +} + +func NewInventoryService( + repo domain.InventoryRepository, + cache domain.InventoryCache, + publisher domain.EventPublisher, +) domain.InventoryService { + return &InventoryService{ + repo: repo, + cache: cache, + publisher: publisher, + } +} + +func (s *InventoryService) GetInventory(ctx context.Context, productID uuid.UUID) (*domain.InventoryResponse, error) { + if cached, err := s.cache.GetInventory(ctx, productID); err == nil && cached != nil { + return cached.ToResponse(), nil + } + + inv, err := s.repo.GetByProductID(productID) + if err != nil { + return nil, err + } + + if err := s.cache.SetInventory(ctx, inv); err != nil { + slog.Warn("failed to cache inventory", "product_id", productID, "error", err) + } + + return inv.ToResponse(), nil +} + +func (s *InventoryService) SetInventory(ctx context.Context, productID uuid.UUID, req domain.UpdateInventoryRequest) (*domain.InventoryResponse, error) { + inv, err := s.repo.SetTotalQuantity(productID, req.TotalQuantity) + if err != nil { + return nil, err + } + + if err := s.cache.SetInventory(ctx, inv); err != nil { + slog.Warn("failed to cache inventory after set", "product_id", productID, "error", err) + } + + go s.publishUpdated(inv) + if inv.AvailableQuantity() <= domain.LowStockThreshold { + go s.publishLowStock(inv) + } + + return inv.ToResponse(), nil +} + +func (s *InventoryService) HandleOrderCreated(ctx context.Context, event domain.OrderCreatedEvent) error { + for _, item := range event.Items { + inv, err := s.repo.ReserveStock(item.ProductID, item.Quantity) + if err != nil { + slog.Error("failed to reserve stock", + "order_id", event.OrderID, + "product_id", item.ProductID, + "quantity", item.Quantity, + "error", err) + continue + } + + if err := s.cache.InvalidateInventory(ctx, item.ProductID); err != nil { + slog.Warn("failed to invalidate inventory cache", "product_id", item.ProductID, "error", err) + } + + go s.publishUpdated(inv) + if inv.AvailableQuantity() <= domain.LowStockThreshold { + go s.publishLowStock(inv) + } + } + return nil +} + +func (s *InventoryService) HandleOrderCancelled(ctx context.Context, event domain.OrderCreatedEvent) error { + for _, item := range event.Items { + inv, err := s.repo.ReleaseStock(item.ProductID, item.Quantity) + if err != nil { + slog.Error("failed to release stock", + "order_id", event.OrderID, + "product_id", item.ProductID, + "quantity", item.Quantity, + "error", err) + continue + } + + if err := s.cache.InvalidateInventory(ctx, item.ProductID); err != nil { + slog.Warn("failed to invalidate inventory cache", "product_id", item.ProductID, "error", err) + } + + go s.publishUpdated(inv) + } + return nil +} + +func (s *InventoryService) publishUpdated(inv *domain.Inventory) { + if err := s.publisher.Publish(context.Background(), domain.TopicInventoryUpdated, inv.ToResponse()); err != nil { + slog.Warn("failed to publish inventory.updated", "product_id", inv.ProductID, "error", err) + } +} + +func (s *InventoryService) publishLowStock(inv *domain.Inventory) { + if err := s.publisher.Publish(context.Background(), domain.TopicInventoryLowStock, inv.ToResponse()); err != nil { + slog.Warn("failed to publish inventory.low_stock", "product_id", inv.ProductID, "error", err) + } +} diff --git a/services/inventory-service/main.go b/services/inventory-service/main.go new file mode 100644 index 0000000..65ae388 --- /dev/null +++ b/services/inventory-service/main.go @@ -0,0 +1,7 @@ +package main + +import "auron/inventory-service/cmd" + +func main() { + cmd.Run() +}