Tutorial

Build an E-Commerce Store

Build a fully functional e-commerce platform with products, categories, orders, order items, image uploads, stock validation, order confirmation emails, a revenue dashboard, and Redis caching for popular products. This is the most comprehensive Grit tutorial and covers nearly every feature of the framework.

Prerequisites

  • Go 1.21+ installed
  • Node.js 18+ and pnpm installed
  • Docker and Docker Compose installed
  • Grit CLI installed globally (go install github.com/MUKE-coder/grit/cmd/grit@latest)
1

Create the project

Scaffold a new Grit project called shopgrit.

terminal
$ grit new shopgrit
$ cd shopgrit
2

Start Docker services

Start PostgreSQL, Redis, MinIO, and Mailhog. MinIO is especially important for this project because we will use it for product image uploads.

terminal
$ docker compose up -d
3

Generate the Product resource

Products are the core of the store. Generate a resource with name, description, price, SKU (unique stock-keeping unit), stock count, and a published flag.

terminal
$ grit generate resource Product --fields "name:string,description:text,price:float,sku:string:unique,stock:int,published:bool"
apps/api/internal/models/product.go
package models

import (
    "time"
    "gorm.io/gorm"
)

type Product struct {
    ID          uint           `gorm:"primarykey" json:"id"`
    Name        string         `gorm:"size:255;not null" json:"name" binding:"required"`
    Description string         `gorm:"type:text" json:"description"`
    Price       float64        `gorm:"not null" json:"price" binding:"required"`
    SKU         string         `gorm:"size:100;uniqueIndex;not null" json:"sku" binding:"required"`
    Stock       int            `gorm:"default:0" json:"stock"`
    Published   bool           `gorm:"default:false" json:"published"`
    CreatedAt   time.Time      `json:"created_at"`
    UpdatedAt   time.Time      `json:"updated_at"`
    DeletedAt   gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}
4

Generate the Category resource

Products are organized into categories. Generate a Category resource and add the relationship to Product.

terminal
$ grit generate resource Category --fields "name:string:unique,slug:string:unique,description:text"

Now add the category relationship to the Product model. Also add an ImageURL field for product images (we will handle uploads in Step 8):

apps/api/internal/models/product.go — updated
package models

import (
    "time"
    "gorm.io/gorm"
)

type Product struct {
    ID          uint           `gorm:"primarykey" json:"id"`
    Name        string         `gorm:"size:255;not null" json:"name" binding:"required"`
    Description string         `gorm:"type:text" json:"description"`
    Price       float64        `gorm:"not null" json:"price" binding:"required"`
    SKU         string         `gorm:"size:100;uniqueIndex;not null" json:"sku" binding:"required"`
    Stock       int            `gorm:"default:0" json:"stock"`
    Published   bool           `gorm:"default:false" json:"published"`
    ImageURL    string         `gorm:"size:500" json:"image_url"`

    // Belongs to Category
    CategoryID  uint           `gorm:"index;not null" json:"category_id" binding:"required"`
    Category    Category       `gorm:"foreignKey:CategoryID" json:"category,omitempty"`

    CreatedAt   time.Time      `json:"created_at"`
    UpdatedAt   time.Time      `json:"updated_at"`
    DeletedAt   gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}
5

Generate the Order resource

Orders track customer purchases. Generate an Order resource with status, total, and notes fields. An order belongs to the authenticated user.

terminal
$ grit generate resource Order --fields "status:string,total:float,notes:text"
apps/api/internal/models/order.go — with User relationship
package models

import (
    "time"
    "gorm.io/gorm"
)

// Order status constants
const (
    OrderStatusPending    = "pending"
    OrderStatusProcessing = "processing"
    OrderStatusShipped    = "shipped"
    OrderStatusDelivered  = "delivered"
    OrderStatusCancelled  = "cancelled"
)

type Order struct {
    ID        uint           `gorm:"primarykey" json:"id"`
    Status    string         `gorm:"size:50;default:pending" json:"status"`
    Total     float64        `gorm:"not null" json:"total"`
    Notes     string         `gorm:"type:text" json:"notes"`

    // Belongs to User (the customer)
    UserID    uint           `gorm:"index;not null" json:"user_id"`
    User      User           `gorm:"foreignKey:UserID" json:"user,omitempty"`

    // Has many order items
    Items     []OrderItem    `gorm:"foreignKey:OrderID" json:"items,omitempty"`

    CreatedAt time.Time      `json:"created_at"`
    UpdatedAt time.Time      `json:"updated_at"`
    DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}
6

Generate the OrderItem resource

Each order contains one or more items. An OrderItem records the quantity and the price at the time of purchase (so price changes do not affect past orders).

terminal
$ grit generate resource OrderItem --fields "quantity:int,price:float"
apps/api/internal/models/order_item.go — with relationships
package models

import (
    "time"
    "gorm.io/gorm"
)

type OrderItem struct {
    ID        uint           `gorm:"primarykey" json:"id"`
    Quantity  int            `gorm:"not null" json:"quantity" binding:"required,min=1"`
    Price     float64        `gorm:"not null" json:"price"`

    // Belongs to Order
    OrderID   uint           `gorm:"index;not null" json:"order_id"`
    Order     Order          `gorm:"foreignKey:OrderID" json:"order,omitempty"`

    // References a Product
    ProductID uint           `gorm:"index;not null" json:"product_id" binding:"required"`
    Product   Product        `gorm:"foreignKey:ProductID" json:"product,omitempty"`

    CreatedAt time.Time      `json:"created_at"`
    UpdatedAt time.Time      `json:"updated_at"`
    DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}

Run grit sync to regenerate all TypeScript types with the new relationships across all four resources.

7

Set up all relationships

Let's review the complete relationship diagram. Add the inverse relationships to the Category model:

relationship diagram
Category
  └── has many Products

Product
  └── belongs to Category

Order
  ├── belongs to User (customer)
  └── has many OrderItems

OrderItem
  ├── belongs to Order
  └── belongs to Product

User (built-in)
  └── has many Orders
apps/api/internal/models/category.go
package models

import (
    "time"
    "gorm.io/gorm"
)

type Category struct {
    ID          uint           `gorm:"primarykey" json:"id"`
    Name        string         `gorm:"size:255;uniqueIndex;not null" json:"name" binding:"required"`
    Slug        string         `gorm:"size:255;uniqueIndex;not null" json:"slug" binding:"required"`
    Description string         `gorm:"type:text" json:"description"`
    Products    []Product      `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
    CreatedAt   time.Time      `json:"created_at"`
    UpdatedAt   time.Time      `json:"updated_at"`
    DeletedAt   gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}
8

File uploads for product images

Use Grit's built-in storage service to upload product images to MinIO (S3-compatible). The storage service is already configured — you just need to add an upload handler that saves the file URL to the product.

apps/api/internal/handlers/product.go — add upload method
// UploadImage handles product image upload.
func (h *ProductHandler) UploadImage(c *gin.Context) {
    id, err := strconv.ParseUint(c.Param("id"), 10, 64)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": gin.H{"code": "INVALID_ID", "message": "Invalid product ID"},
        })
        return
    }

    // Get the file from the multipart form
    file, header, err := c.Request.FormFile("image")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": gin.H{"code": "NO_FILE", "message": "No image file provided"},
        })
        return
    }
    defer file.Close()

    // Validate file type
    contentType := header.Header.Get("Content-Type")
    if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": gin.H{
                "code":    "INVALID_TYPE",
                "message": "Only JPEG, PNG, and WebP images are allowed",
            },
        })
        return
    }

    // Upload to storage (MinIO / S3)
    path := fmt.Sprintf("products/%d/%s", id, header.Filename)
    url, err := h.storage.Upload(c, path, file, contentType)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": gin.H{"code": "UPLOAD_FAILED", "message": "Failed to upload image"},
        })
        return
    }

    // Update the product record with the image URL
    result := h.db.Model(&models.Product{}).Where("id = ?", id).Update("image_url", url)
    if result.Error != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": gin.H{"code": "UPDATE_FAILED", "message": "Failed to update product"},
        })
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "data":    gin.H{"image_url": url},
        "message": "Image uploaded successfully",
    })
}

Register the upload route:

apps/api/internal/routes/routes.go
// Inside the authenticated products group
products.POST("/:id/upload-image", productHandler.UploadImage)

Update the admin resource definition to include the file upload field and display the product image in the table:

apps/admin/resources/products.ts
import { defineResource } from '@grit/admin'

export default defineResource({
  name: 'Product',
  endpoint: '/api/products',
  icon: 'Package',

  table: {
    columns: [
      { key: 'id', label: 'ID', sortable: true },
      { key: 'image_url', label: 'Image', format: 'image' },
      { key: 'name', label: 'Name', sortable: true, searchable: true },
      { key: 'sku', label: 'SKU', sortable: true },
      { key: 'category.name', label: 'Category', relation: 'category' },
      { key: 'price', label: 'Price', sortable: true, format: 'currency' },
      { key: 'stock', label: 'Stock', sortable: true },
      { key: 'published', label: 'Status', badge: {
        true: { color: 'green', label: 'Published' },
        false: { color: 'yellow', label: 'Draft' },
      }},
    ],
    filters: [
      { key: 'published', type: 'select', options: [
        { label: 'Published', value: 'true' },
        { label: 'Draft', value: 'false' },
      ]},
      { key: 'category_id', type: 'select', resource: 'categories',
        displayKey: 'name', label: 'Category' },
      { key: 'price', type: 'number-range' },
    ],
    actions: ['create', 'edit', 'delete', 'export'],
    bulkActions: ['delete', 'export'],
  },

  form: {
    fields: [
      { key: 'name', label: 'Name', type: 'text', required: true },
      { key: 'sku', label: 'SKU', type: 'text', required: true },
      { key: 'description', label: 'Description', type: 'textarea' },
      { key: 'category_id', label: 'Category', type: 'relation',
        resource: 'categories', displayKey: 'name', required: true },
      { key: 'price', label: 'Price', type: 'number', prefix: '$', required: true },
      { key: 'stock', label: 'Stock', type: 'number', default: 0 },
      { key: 'image', label: 'Product Image', type: 'file',
        accept: 'image/*', uploadEndpoint: '/api/products/{id}/upload-image' },
      { key: 'published', label: 'Published', type: 'toggle', default: false },
    ],
  },
})
9

Custom order creation with stock validation

The default CRUD handler is not sufficient for order creation. We need to validate stock availability, calculate totals, decrement stock, and create order items — all within a database transaction.

apps/api/internal/handlers/order.go — CreateOrder
type CreateOrderInput struct {
    Notes string `json:"notes"`
    Items []struct {
        ProductID uint `json:"product_id" binding:"required"`
        Quantity  int  `json:"quantity" binding:"required,min=1"`
    } `json:"items" binding:"required,min=1"`
}

func (h *OrderHandler) CreateOrder(c *gin.Context) {
    user, _ := c.Get("user")
    currentUser := user.(*models.User)

    var input CreateOrderInput
    if err := c.ShouldBindJSON(&input); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": gin.H{"code": "VALIDATION_ERROR", "message": err.Error()},
        })
        return
    }

    // Use a transaction to ensure atomicity
    tx := h.db.Begin()
    defer func() {
        if r := recover(); r != nil {
            tx.Rollback()
        }
    }()

    var total float64
    var orderItems []models.OrderItem

    for _, item := range input.Items {
        // Lock the product row for update
        var product models.Product
        if err := tx.Set("gorm:query_option", "FOR UPDATE").
            First(&product, item.ProductID).Error; err != nil {
            tx.Rollback()
            c.JSON(http.StatusBadRequest, gin.H{
                "error": gin.H{
                    "code":    "PRODUCT_NOT_FOUND",
                    "message": fmt.Sprintf("Product %d not found", item.ProductID),
                },
            })
            return
        }

        // Validate stock
        if product.Stock < item.Quantity {
            tx.Rollback()
            c.JSON(http.StatusBadRequest, gin.H{
                "error": gin.H{
                    "code": "INSUFFICIENT_STOCK",
                    "message": fmt.Sprintf(
                        "Insufficient stock for %s: requested %d, available %d",
                        product.Name, item.Quantity, product.Stock,
                    ),
                },
            })
            return
        }

        // Decrement stock
        tx.Model(&product).Update("stock", product.Stock-item.Quantity)

        // Calculate line total
        lineTotal := product.Price * float64(item.Quantity)
        total += lineTotal

        orderItems = append(orderItems, models.OrderItem{
            ProductID: product.ID,
            Quantity:  item.Quantity,
            Price:     product.Price, // snapshot the price at time of purchase
        })
    }

    // Create the order
    order := models.Order{
        UserID: currentUser.ID,
        Status: models.OrderStatusPending,
        Total:  total,
        Notes:  input.Notes,
    }

    if err := tx.Create(&order).Error; err != nil {
        tx.Rollback()
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": gin.H{"code": "CREATE_FAILED", "message": "Failed to create order"},
        })
        return
    }

    // Create order items
    for i := range orderItems {
        orderItems[i].OrderID = order.ID
    }
    if err := tx.Create(&orderItems).Error; err != nil {
        tx.Rollback()
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": gin.H{"code": "CREATE_FAILED", "message": "Failed to create order items"},
        })
        return
    }

    tx.Commit()

    // Enqueue confirmation email (non-blocking)
    _ = h.jobClient.EnqueueOrderConfirmation(jobs.OrderConfirmationPayload{
        OrderID:    order.ID,
        UserEmail:  currentUser.Email,
        UserName:   currentUser.Name,
        Total:      total,
        ItemCount:  len(orderItems),
    })

    // Reload with relationships
    h.db.Preload("Items.Product").Preload("User").First(&order, order.ID)

    c.JSON(http.StatusCreated, gin.H{
        "data":    order,
        "message": "Order created successfully",
    })
}

Register the custom order creation route:

apps/api/internal/routes/routes.go
// Replace the default POST /api/orders with the custom handler
orders := auth.Group("/orders")
{
    orders.POST("", orderHandler.CreateOrder)    // custom creation
    orders.GET("", orderHandler.GetAll)           // generated
    orders.GET("/:id", orderHandler.GetByID)      // generated
    // No PUT or DELETE — orders are immutable once created
}
10

Background job for order confirmation emails

When an order is placed, queue a background job that sends a confirmation email. This keeps the API response fast.

apps/api/internal/jobs/order_confirmation.go
package jobs

import (
    "context"
    "encoding/json"
    "fmt"

    "github.com/hibiken/asynq"
    "shopgrit/apps/api/internal/mail"
)

const TypeOrderConfirmation = "order:confirmation"

type OrderConfirmationPayload struct {
    OrderID   uint    `json:"order_id"`
    UserEmail string  `json:"user_email"`
    UserName  string  `json:"user_name"`
    Total     float64 `json:"total"`
    ItemCount int     `json:"item_count"`
}

// EnqueueOrderConfirmation creates a new order confirmation email job.
func (c *Client) EnqueueOrderConfirmation(payload OrderConfirmationPayload) error {
    data, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("failed to marshal payload: %w", err)
    }

    task := asynq.NewTask(TypeOrderConfirmation, data)
    _, err = c.client.Enqueue(task, asynq.MaxRetry(5), asynq.Queue("critical"))
    return err
}

// HandleOrderConfirmation processes the order confirmation email.
func HandleOrderConfirmation(mailer *mail.Mailer) asynq.HandlerFunc {
    return func(ctx context.Context, t *asynq.Task) error {
        var payload OrderConfirmationPayload
        if err := json.Unmarshal(t.Payload(), &payload); err != nil {
            return fmt.Errorf("failed to unmarshal payload: %w", err)
        }

        return mailer.Send(ctx, mail.SendOptions{
            To:       payload.UserEmail,
            Subject:  fmt.Sprintf("Order #%d Confirmed", payload.OrderID),
            Template: "order-confirmation",
            Data: map[string]interface{}{
                "Name":      payload.UserName,
                "OrderID":   payload.OrderID,
                "Total":     fmt.Sprintf("$%.2f", payload.Total),
                "ItemCount": payload.ItemCount,
            },
        })
    }
}
apps/api/internal/mail/templates/order-confirmation.html
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: 'DM Sans', sans-serif; background: #0a0a0f; color: #e8e8f0; padding: 40px;">
  <div style="max-width: 500px; margin: 0 auto; background: #111118; border-radius: 12px; padding: 32px; border: 1px solid #2a2a3a;">
    <h2 style="color: #00b894; margin-top: 0;">Order Confirmed</h2>
    <p>Hi {{.Name}},</p>
    <p>Your order has been confirmed and is being processed.</p>
    <div style="background: #1a1a24; border-radius: 8px; padding: 16px; margin: 16px 0; border: 1px solid #2a2a3a;">
      <p style="margin: 0;">
        <strong>Order #{{.OrderID}}</strong>
      </p>
      <p style="margin: 4px 0 0; color: #9090a8; font-size: 14px;">
        {{.ItemCount}} item(s) &bull; Total: {{.Total}}
      </p>
    </div>
    <p style="color: #9090a8; font-size: 14px;">
      We'll send you another email when your order ships.
    </p>
  </div>
</body>
</html>
11

Admin dashboard with revenue stats

Create a stats endpoint that returns revenue totals, order counts, and product inventory stats. Then build a dashboard widget for the admin panel.

apps/api/internal/handlers/order.go — revenue stats endpoint
// GetRevenueStats returns store-wide revenue and order statistics.
func (h *OrderHandler) GetRevenueStats(c *gin.Context) {
    var totalRevenue float64
    var totalOrders int64
    var pendingOrders int64
    var totalProducts int64
    var lowStockProducts int64

    // Revenue from non-cancelled orders
    h.db.Model(&models.Order{}).
        Where("status != ?", models.OrderStatusCancelled).
        Select("COALESCE(SUM(total), 0)").
        Scan(&totalRevenue)

    h.db.Model(&models.Order{}).Count(&totalOrders)

    h.db.Model(&models.Order{}).
        Where("status = ?", models.OrderStatusPending).
        Count(&pendingOrders)

    h.db.Model(&models.Product{}).
        Where("published = ?", true).
        Count(&totalProducts)

    // Products with stock below 10
    h.db.Model(&models.Product{}).
        Where("stock < ? AND published = ?", 10, true).
        Count(&lowStockProducts)

    // Revenue by day for the last 7 days
    type DailyRevenue struct {
        Date    string  `json:"date"`
        Revenue float64 `json:"revenue"`
    }
    var dailyRevenue []DailyRevenue
    h.db.Model(&models.Order{}).
        Select("DATE(created_at) as date, SUM(total) as revenue").
        Where("created_at >= NOW() - INTERVAL '7 days' AND status != ?",
            models.OrderStatusCancelled).
        Group("DATE(created_at)").
        Order("date ASC").
        Scan(&dailyRevenue)

    c.JSON(http.StatusOK, gin.H{
        "data": gin.H{
            "total_revenue":     totalRevenue,
            "total_orders":      totalOrders,
            "pending_orders":    pendingOrders,
            "total_products":    totalProducts,
            "low_stock_products": lowStockProducts,
            "daily_revenue":     dailyRevenue,
        },
    })
}
apps/admin/components/widgets/revenue-stats.tsx
'use client'

import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/api-client'
import { DollarSign, ShoppingCart, Package, AlertTriangle } from 'lucide-react'

interface RevenueStats {
  total_revenue: number
  total_orders: number
  pending_orders: number
  total_products: number
  low_stock_products: number
  daily_revenue: { date: string; revenue: number }[]
}

export function RevenueStatsWidget() {
  const { data } = useQuery<{ data: RevenueStats }>({
    queryKey: ['revenue-stats'],
    queryFn: async () => {
      const { data } = await apiClient.get('/api/orders/stats')
      return data
    },
    refetchInterval: 60000,
  })

  const stats = data?.data

  const cards = [
    {
      label: 'Total Revenue',
      value: stats ? `$${stats.total_revenue.toLocaleString('en-US', {
        minimumFractionDigits: 2 })}` : '$0.00',
      icon: DollarSign,
      color: 'text-emerald-400',
    },
    {
      label: 'Total Orders',
      value: stats?.total_orders ?? 0,
      icon: ShoppingCart,
      color: 'text-primary',
    },
    {
      label: 'Products Listed',
      value: stats?.total_products ?? 0,
      icon: Package,
      color: 'text-blue-400',
    },
    {
      label: 'Low Stock',
      value: stats?.low_stock_products ?? 0,
      icon: AlertTriangle,
      color: 'text-red-400',
    },
  ]

  return (
    <div className="space-y-6">
      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
        {cards.map((card) => (
          <div
            key={card.label}
            className="rounded-xl border border-border/40 bg-card/50 p-5"
          >
            <div className="flex items-center justify-between mb-3">
              <span className="text-xs font-medium text-muted-foreground/60
                              uppercase tracking-wider">
                {card.label}
              </span>
              <card.icon className={`h-4 w-4 ${card.color}`} />
            </div>
            <p className="text-2xl font-bold tracking-tight">{card.value}</p>
          </div>
        ))}
      </div>

      {/* Pending orders callout */}
      {stats && stats.pending_orders > 0 && (
        <div className="rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4
                        flex items-center gap-3">
          <ShoppingCart className="h-5 w-5 text-yellow-500" />
          <p className="text-sm text-yellow-200/80">
            You have <strong>{stats.pending_orders}</strong> pending order(s) waiting
            to be processed.
          </p>
        </div>
      )}
    </div>
  )
}
12

Cache popular products with Redis

High-traffic product pages should not hit the database on every request. Use Grit's built-in Redis cache service to cache popular products and invalidate the cache when a product is updated.

apps/api/internal/services/product.go — cached GetByID
package services

import (
    "encoding/json"
    "fmt"
    "time"

    "gorm.io/gorm"
    "shopgrit/apps/api/internal/cache"
    "shopgrit/apps/api/internal/models"
)

type ProductService struct {
    db    *gorm.DB
    cache *cache.Cache
}

func NewProductService(db *gorm.DB, c *cache.Cache) *ProductService {
    return &ProductService{db: db, cache: c}
}

// GetByID retrieves a product by ID, using the Redis cache.
func (s *ProductService) GetByID(id uint) (*models.Product, error) {
    cacheKey := fmt.Sprintf("product:%d", id)

    // Try cache first
    cached, err := s.cache.Get(cacheKey)
    if err == nil && cached != "" {
        var product models.Product
        if err := json.Unmarshal([]byte(cached), &product); err == nil {
            return &product, nil
        }
    }

    // Cache miss — query the database
    var product models.Product
    result := s.db.Preload("Category").First(&product, id)
    if result.Error != nil {
        return nil, fmt.Errorf("product not found: %w", result.Error)
    }

    // Store in cache for 5 minutes
    data, _ := json.Marshal(product)
    _ = s.cache.Set(cacheKey, string(data), 5*time.Minute)

    return &product, nil
}

// Update updates a product and invalidates the cache.
func (s *ProductService) Update(id uint, input map[string]interface{}) (*models.Product, error) {
    result := s.db.Model(&models.Product{}).Where("id = ?", id).Updates(input)
    if result.Error != nil {
        return nil, fmt.Errorf("failed to update product: %w", result.Error)
    }

    // Invalidate the cache for this product
    cacheKey := fmt.Sprintf("product:%d", id)
    _ = s.cache.Delete(cacheKey)

    // Also invalidate the published products list cache
    _ = s.cache.DeletePattern("products:published:*")

    return s.GetByID(id)
}

// GetPublished returns published products with caching.
func (s *ProductService) GetPublished(page, pageSize int) ([]models.Product, int64, error) {
    cacheKey := fmt.Sprintf("products:published:%d:%d", page, pageSize)

    // Try cache first
    type cachedResult struct {
        Products []models.Product `json:"products"`
        Total    int64            `json:"total"`
    }

    cached, err := s.cache.Get(cacheKey)
    if err == nil && cached != "" {
        var result cachedResult
        if err := json.Unmarshal([]byte(cached), &result); err == nil {
            return result.Products, result.Total, nil
        }
    }

    // Cache miss — query the database
    var products []models.Product
    var total int64

    query := s.db.Model(&models.Product{}).Where("published = ?", true)
    query.Count(&total)

    offset := (page - 1) * pageSize
    query.Preload("Category").
        Order("created_at DESC").
        Offset(offset).
        Limit(pageSize).
        Find(&products)

    // Cache for 2 minutes
    data, _ := json.Marshal(cachedResult{Products: products, Total: total})
    _ = s.cache.Set(cacheKey, string(data), 2*time.Minute)

    return products, total, nil
}

The cache is automatically invalidated when a product is updated through the admin panel or API. Published product listings are cached for 2 minutes, and individual product pages are cached for 5 minutes. This dramatically reduces database load under high traffic.

13

Run and test everything

Start all services and test the complete e-commerce workflow.

terminal
$ grit dev

Walk through this testing checklist:

  1. Open the admin panel at http://localhost:3001 and register an admin account.
  2. Create a category: "Electronics" with slug "electronics".
  3. Create a product: "Wireless Keyboard", SKU "WK-001", price $49.99, stock 50, category "Electronics", published.
  4. Upload an image for the product using the image upload field.
  5. Verify the image appears in the product table and in MinIO at http://localhost:9001.
  6. Place an order via the API:
    terminal
    curl -X POST http://localhost:8080/api/orders \
      -H "Authorization: Bearer <your-token>" \
      -H "Content-Type: application/json" \
      -d '{
        "items": [
          { "product_id": 1, "quantity": 2 }
        ],
        "notes": "Please gift wrap"
      }'
  7. Check that the product stock decreased from 50 to 48 in the admin panel.
  8. Check Mailhog at http://localhost:8025 for the order confirmation email.
  9. Try ordering more than the available stock — the API should return an INSUFFICIENT_STOCK error.
  10. View the revenue dashboard in the admin panel — it should show $99.98 in revenue.
  11. Request the same product twice via API and verify the second request is served from the Redis cache (check response times).
  12. Browse all tables in GORM Studio at http://localhost:8080/studio.

What you've built

  • A complete e-commerce store with Go API and Next.js frontend
  • Product catalog with categories, pricing, SKU tracking, and stock management
  • Product image uploads to S3-compatible storage (MinIO in development)
  • Transactional order creation with stock validation and atomic database operations
  • Order items that snapshot product prices at time of purchase
  • Background job queue for order confirmation emails
  • HTML email templates styled to match the Grit dark theme
  • Revenue dashboard with daily revenue chart, order counts, and low-stock alerts
  • Redis caching for product pages and published product listings
  • Cache invalidation on product updates to keep data fresh
  • Admin panel with product image previews, currency formatting, and status badges
  • Five resources total: Product, Category, Order, OrderItem, and the built-in User

Next steps

You have a solid e-commerce foundation. Here are ideas to take it further:

  • Stripe integration — add a payment processing step to the order creation flow using the Stripe Go SDK.
  • Product variants — generate a Variant resource (size, color) linked to products with separate stock tracking.
  • Wishlist — generate a Wishlist resource linked to User and Product for saved items.
  • Search — add full-text search on product names and descriptions using PostgreSQL's tsvector.
  • Reviews — generate a Review resource with a rating field and link it to Product and User.
  • Discount codes — add a Coupon resource and apply discounts during order creation.
  • Shipping tracking — update order status through the workflow and send shipping notification emails.
  • Analytics — use the Grit admin chart widgets to show sales by category, top-selling products, and customer retention.