{
  "slug": "Building-a-Category--Config---Product-Management-System-in-Go--A-Deep-Dive-5100ef95f814",
  "title": "Building a Category, Config & Product Management System in Go: A Deep Dive",
  "subtitle": "Overview",
  "excerpt": "Overview",
  "date": "2026-02-22",
  "tags": [
    "Go"
  ],
  "readingTime": "4 min",
  "url": "https://medium.com/@mobinshaterian/building-a-category-config-product-management-system-in-go-a-deep-dive-5100ef95f814",
  "hero": "https://cdn-images-1.medium.com/max/800/1*1R3bFlysYaKs3jyf9MtVzg.png",
  "content": [
    {
      "type": "heading",
      "level": 2,
      "text": "Building a Category, Config & Product Management System in Go: A Deep Dive"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Overview"
    },
    {
      "type": "paragraph",
      "html": "This article walks through a significant feature addition to <a href=\"https://github.com/mobintmu/go-wordpress\" target=\"_blank\" rel=\"noreferrer noopener\">go-wordpress</a> — a Go-based headless CMS backend. The changes introduce full CRUD management for <strong>categories</strong>, <strong>website configs</strong>, and <strong>products</strong>, each backed by PostgreSQL (via sqlc), Redis caching, a RESTful HTTP API (Gin), and a gRPC endpoint for product lookups."
    },
    {
      "type": "image",
      "src": "https://cdn-images-1.medium.com/max/800/1*1R3bFlysYaKs3jyf9MtVzg.png",
      "alt": "Building a Category, Config & Product Management System in Go: A Deep Dive",
      "caption": "",
      "width": 2752,
      "height": 1536
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Go-Wordpress"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Git commits"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Database Schema"
    },
    {
      "type": "paragraph",
      "html": "Each new entity gets its own table with proper constraints and indexes."
    },
    {
      "type": "paragraph",
      "html": "Categories"
    },
    {
      "type": "code",
      "lang": "sql",
      "code": "CREATE TABLE categories (\n    id SERIAL PRIMARY KEY,\n    website_id INTEGER NOT NULL\n        REFERENCES websites(id) ON DELETE CASCADE,\n    name TEXT NOT NULL,\n    link TEXT NOT NULL UNIQUE,\n    status entity_status DEFAULT 'active' NOT NULL,\n    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL\n);"
    },
    {
      "type": "code",
      "lang": "sql",
      "code": "CREATE INDEX idx_categories_status     ON categories(status);\nCREATE INDEX idx_categories_website_id ON categories(website_id);"
    },
    {
      "type": "paragraph",
      "html": "A few design decisions worth noting here: the <code>link</code> field has a <code>UNIQUE</code> constraint to prevent duplicate slugs, <code>ON DELETE CASCADE</code> ensures categories are cleaned up when a website is deleted, and the <code>entity_status</code> enum (shared across the project) keeps status values consistent."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "SQL Queries with sqlc"
    },
    {
      "type": "paragraph",
      "html": "The queries cover every access pattern the application needs:"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "<code>CreateCategory</code>, <code>GetCategoryByID</code>, <code>UpdateCategory</code>, <code>DeleteCategory</code> — the standard CRUD set",
        "<code>ListAllCategories</code> — paginated, no filter",
        "<code>ListAllActiveCategories</code> — only <code>status = 'active'</code>",
        "<code>ListCategoriesByWebsiteID</code> — scoped to one website",
        "<code>ListCategoriesByStatus</code> — arbitrary status filter"
      ]
    },
    {
      "type": "paragraph",
      "html": "One thing to watch out for in the generated code — <code>ListActiveCategoriesByStatus</code> has a redundant condition:"
    },
    {
      "type": "code",
      "lang": "sql",
      "code": "WHERE status = $1 AND status = 'active'"
    },
    {
      "type": "paragraph",
      "html": "This is functionally correct (the parameter would always need to be <code>'active'</code>), but it's dead weight and should be simplified to <code>WHERE status = 'active'</code> with no parameter."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Redis Caching Layer"
    },
    {
      "type": "paragraph",
      "html": "The cache store uses a structured key naming convention to make invalidation predictable:"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "func (s *Store) KeyCategory(ID int32) string {\n    return s.prefix + \":category:\" + fmt.Sprint(ID)\n}\nfunc (s *Store) KeyAllCategories(limit, offset int64) string {\n    return s.prefix + \":categories:all:\" + fmt.Sprint(limit) + \":\" + fmt.Sprint(offset)\n}\nfunc (r *Store) DeleteAllCategoryCache(ctx context.Context) error {\n    return r.deleteByPattern(ctx, r.prefix+\":categories:all:*\")\n}"
    },
    {
      "type": "paragraph",
      "html": "The pattern-based deletion (<code>SCAN</code> + <code>DEL</code>) is a practical approach that invalidates all paginated list caches on any write. It's worth being aware that <code>SCAN</code> is non-blocking but iterative — in a very large keyspace, this could take multiple round-trips. For most CMS-scale deployments, it's perfectly fine."
    },
    {
      "type": "paragraph",
      "html": "The same pattern is applied consistently to products, configs, and websites, making the caching behavior predictable across all entities."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Service Layer"
    },
    {
      "type": "paragraph",
      "html": "The category service demonstrates the cache-aside pattern clearly:"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "func (s *Category) GetCategoryByID(ctx context.Context, id int32) (*sqlc.Category, error) {\n    var category sqlc.Category\n    err := s.memory.Get(ctx, s.memory.KeyCategory(id), &category)\n    if err != nil {\n        // Cache miss — go to DB category, err = s.query.GetCategoryByID(ctx, id) if err != nil {\n            return nil, err\n        }\n        s.memory.Set(ctx, s.memory.KeyCategory(category.ID), category, s.cfg.Redis.DefaultTTL)\n    }\n    return &category, nil\n}"
    },
    {
      "type": "paragraph",
      "html": "On writes (create/update), the individual key is updated, and the list caches are invalidated. On delete, both the item key and list caches are cleared before the DB deletion — a sensible ordering that avoids serving stale data between the DB operation and cache eviction."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "HTTP API"
    },
    {
      "type": "paragraph",
      "html": "The <code>AdminCategory</code> controller follows the same structure used for websites across the project. Routes are protected with JWT middleware:"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "func (c *AdminCategory) RegisterRoutes(rg *gin.RouterGroup, cfg *config.Config) {\n    auth := middleware.JWTAuth(cfg)\n    rg.POST(\"/\", auth, c.CreateCategory)\n    rg.PUT(\"/:id\", auth, c.UpdateCategory)\n    rg.DELETE(\"/:id\", auth, c.DeleteCategory)\n    rg.GET(\"/:id\", auth, c.GetCategoryByID)\n    rg.GET(\"/\", auth, c.ListCategories)\n}"
    },
    {
      "type": "paragraph",
      "html": "All handlers follow the same pattern: parse input → call service → return result or error. Swagger annotations are included on every handler, making the API self-documenting."
    },
    {
      "type": "embed",
      "provider": "youtube",
      "url": "https://www.youtube.com/embed/FJCQN4g8Ncw?feature=oembed"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "gRPC Support for Products"
    },
    {
      "type": "paragraph",
      "html": "Products get an additional gRPC endpoint alongside the REST API:"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "func TestProductGRPC(t *testing.T) {\n    //...\n    client := productv1.NewProductServiceClient(conn)\n    grpcGetProductByID(t, product, client)\n}"
    },
    {
      "type": "paragraph",
      "html": "This dual-protocol approach is common in systems where internal microservices communicate over gRPC for performance, while external clients use REST. The test sets up the full prerequisite chain (website → category → product) over HTTP, then validates gRPC retrieval — a clean integration test strategy."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Integration Tests"
    },
    {
      "type": "paragraph",
      "html": "Every entity follows the same integration test flow:"
    },
    {
      "type": "list",
      "ordered": true,
      "items": [
        "Verify unauthenticated requests return <code>401 Unauthorized</code>",
        "Create the entity (handling FK prerequisites first)",
        "List and confirm the new entity appears",
        "Fetch by ID and verify field values",
        "Update and verify the updated fields",
        "Delete and confirm <code>204 No Content</code>",
        "Attempt to fetch the deleted entity and confirm an error response"
      ]
    },
    {
      "type": "paragraph",
      "html": "The tests use <code>defer</code> for cleanup, ensuring the database is left clean even if assertions fail midway. The category test also serves as a helper for product and config tests, since both depend on a website and the category existing."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Summary"
    },
    {
      "type": "paragraph",
      "html": "The additions follow a clean, consistent architecture: schema → sqlc queries → cache keys → service layer → HTTP controller → integration tests. The same pattern that works for websites is applied uniformly to categories, configs, and products, making the codebase easy to extend and navigate."
    },
    {
      "type": "paragraph",
      "html": "The main areas to refine going forward would be the redundant SQL condition in <code>ListActiveCategoriesByStatus</code>, and potentially adding a <code>404 Not Found</code> response for missing resources (currently the service returns a generic 500 for DB errors, including <code>sql.ErrNoRows</code>)."
    }
  ]
}
