Files
pos-system/services/catalog-service-net/SERVICE_DOCS.md
Ho Ngoc Hai f3779c4ebe docs: add SERVICE_DOCS.md for all 24 microservices from per-service code audit
Each SERVICE_DOCS.md documents: Overview, API Endpoints, Commands, Queries,
Domain Model, Database Schema, Integration Events, Dependencies, Configuration.
Generated by 23 parallel audit agents reading actual source code.

Key corrections from audit:
- inventory-service: 12 commands/6 queries (was listed as scaffold)
- promotion-service: 12 commands/10 queries (was listed as 0)
- mission-service: 4 commands/7 queries (was listed as 0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:54:53 +07:00

18 KiB

CatalogService - Service Documentation

Auto-generated from source code audit on 2026-03-13.

Overview

CatalogService is a product catalog microservice supporting multi-vertical (Retail, F&B, Spa/Salon) polymorphic products and hierarchical categories. It provides CRUD operations for products and categories, scoped per shop (multi-tenant). Products use a type-discriminator pattern (ProductType DDD Enumeration) with JSONB attributes for type-specific data.

  • Port: 5016 (Development)
  • Database: PostgreSQL (catalog_service on Neon)
  • Architecture: Clean Architecture + CQRS (MediatR)
  • Multi-tenancy: EF Core global query filters (shop-level) + PostgreSQL RLS session variables
  • Auth: JWT Bearer via IAM IdentityServer OIDC discovery
  • API Version: v1 (URL segment + X-Api-Version header)

API Endpoints

Products (ProductsController)

Method Route Description Auth Request Response
GET /api/v1/products?shopId=&isActive=&type=&categoryId=&page=&pageSize= Get products with filtering + pagination No Query params PagedResult<ProductDto>
GET /api/v1/shops/{shopId}/products Get shop products (RESTful alias) No Path + query params PagedResult<ProductDto>
GET /api/v1/products/lookup?shopId=&barcode= Lookup product by barcode/SKU (POS scanner) No Query params { success, data: ProductDto } or 404
GET /api/v1/products/{id} Get product by ID No Path param ProductDto or 404
POST /api/v1/products Create product No CreateProductCommand body 201 + Guid
PUT /api/v1/products/{id} Update product No UpdateProductCommand body 204
DELETE /api/v1/products/{id} Soft-delete (deactivate) product No Path param 204

Categories (CategoriesController)

Method Route Description Auth Request Response
GET /api/v1/categories?shopId=&parentId= Get categories (hierarchical) No Query params List<CategoryDto>
GET /api/v1/shops/{shopId}/categories Get shop categories (RESTful alias) No Path + query params List<CategoryDto>
POST /api/v1/categories Create category No CreateCategoryCommand body 201 + Guid
PUT /api/v1/categories/{categoryId} Update category No UpdateCategoryCommand body 200 or 404
DELETE /api/v1/categories/{categoryId} Soft-delete (deactivate) category No Path param 200 or 404

Health Checks

Route Description
/health Full health check (includes PostgreSQL)
/health/live Liveness probe (app running)
/health/ready Readiness probe (PostgreSQL reachable)

Commands

CreateProductCommand

  • Input: ShopId (Guid), Name (string), Description (string?), Price (decimal), Type (string: Physical/Service/PreparedFood), Attributes (Dictionary?), Sku (string?), ImageUrl (string?), CategoryId (Guid?)
  • Returns: Guid (new product ID)
  • Logic: Resolves ProductType from Enumeration, converts attributes dict to JsonDocument, creates Product aggregate, optionally sets image, saves via repository + UnitOfWork
  • Domain Event: ProductCreatedDomainEvent raised in constructor
  • Validator: CreateProductCommandValidator
    • ShopId: NotEmpty
    • Name: NotEmpty, MaxLength(200)
    • Description: MaxLength(2000) when not null
    • Price: >= 0
    • Type: NotEmpty, must be Physical/Service/PreparedFood
    • Sku: MaxLength(100) when not null
    • ImageUrl: MaxLength(2048) when not null

UpdateProductCommand

  • Input: ProductId (Guid), Name (string), Description (string?), Price (decimal), Attributes (Dictionary?), ImageUrl (string?), CategoryId (Guid?)
  • Returns: bool
  • Logic: Loads product by ID (throws DomainException if not found), calls UpdateInfo(), SetCategory(), optionally UpdateAttributes() and UpdateImage(), saves via repository + UnitOfWork
  • Validator: UpdateProductCommandValidator
    • ProductId: NotEmpty
    • Name: NotEmpty, MaxLength(200)
    • Description: MaxLength(2000) when not null
    • Price: >= 0
    • ImageUrl: MaxLength(2048) when not null

DeleteProductCommand

  • Input: ProductId (Guid)
  • Returns: bool
  • Logic: Loads product by ID (throws DomainException if not found), calls Deactivate() (soft delete), saves via repository + UnitOfWork
  • Validator: DeleteProductCommandValidator - ProductId: NotEmpty

CreateCategoryCommand

  • Input: ShopId (Guid), Name (string), Description (string?), ParentId (Guid?), DisplayOrder (int), ImageUrl (string?)
  • Returns: Guid (new category ID)
  • Logic: Creates Category entity, optionally sets image, saves directly via CatalogContext
  • Validator: CreateCategoryCommandValidator
    • ShopId: NotEmpty
    • Name: NotEmpty, MaxLength(200)
    • Description: MaxLength(1000) when not null
    • DisplayOrder: >= 0
    • ImageUrl: MaxLength(2048) when not null

UpdateCategoryCommand

  • Input: CategoryId (Guid), Name (string), Description (string?), DisplayOrder (int), ImageUrl (string?)
  • Returns: bool
  • Logic: Loads category from context, calls UpdateInfo(), optionally UpdateImage(), saves via context
  • Validator: UpdateCategoryCommandValidator
    • CategoryId: NotEmpty
    • Name: NotEmpty, MaxLength(200)
    • Description: MaxLength(1000) when not null
    • DisplayOrder: >= 0
    • ImageUrl: MaxLength(2048) when not null

DeleteCategoryCommand

  • Input: CategoryId (Guid)
  • Returns: bool
  • Logic: Loads category from context, calls Deactivate() (soft delete), saves via context
  • Validator: DeleteCategoryCommandValidator - CategoryId: NotEmpty

Queries

GetProductsQuery

  • Input: ShopId (Guid), IsActive (bool?), Type (string?), CategoryId (Guid?), Page (int, default 1), PageSize (int, default 20)
  • Returns: PagedResult<ProductDto>
  • Logic: Filters by ShopId (required), then optionally by IsActive, TypeId (resolved from Enumeration name), CategoryId. Orders by Name. Resolves type name from Enumeration.GetAll<ProductType>() and category name from batch lookup. Server-side pagination via Skip/Take.

GetProductByIdQuery

  • Input: ProductId (Guid)
  • Returns: ProductDto? (null if not found)
  • Logic: Finds product by ID, resolves type name from Enumeration, resolves category name if CategoryId exists.

GetProductByBarcodeQuery

  • Input: ShopId (Guid), Barcode (string)
  • Returns: ProductDto? (null if not found)
  • Logic: Uses IProductRepository.GetByBarcodeOrSkuAsync() to find active product matching barcode or SKU within shop. Resolves type name from Enumeration.

GetCategoriesQuery

  • Input: ShopId (Guid), ParentId (Guid?)
  • Returns: List<CategoryDto>
  • Logic: Filters by ShopId. If ParentId provided, returns children; otherwise returns root categories (ParentId == null). Orders by DisplayOrder then Name.

Domain Model

Product (Aggregate Root)

Entity: CatalogService.Domain.AggregatesModel.ProductAggregate.Product extends Entity, implements IAggregateRoot

Private Fields / Public Getters:

Field Type Description
_shopId / ShopId Guid Owning shop ID
_name / Name string Product name
_description / Description string? Product description
_price / Price decimal Product price
TypeId int ProductType enumeration ID (private set)
_attributes / Attributes JsonDocument? Type-specific attributes (JSONB)
_imageUrl / ImageUrl string? Image URL
_sku / Sku string? Stock Keeping Unit
_barcode / Barcode string? Barcode (EAN-13, UPC) for POS scanner
_categoryId / CategoryId Guid? Category FK
_isActive / IsActive bool Active status (default true)
_createdAt / CreatedAt DateTime Creation timestamp (UTC)
_updatedAt / UpdatedAt DateTime? Last update timestamp (UTC)

Constructor Validation: ShopId not empty, Name not blank, Price >= 0, Type not null. Raises ProductCreatedDomainEvent.

Behavior Methods:

  • UpdateInfo(name, description, price) - validates name/price
  • UpdateAttributes(JsonDocument?) - replaces JSONB attributes
  • UpdateImage(string?) - sets image URL
  • SetCategory(Guid?) - sets category reference
  • UpdateBarcode(string?) - sets barcode value
  • UpdateSku(string?) - sets SKU value
  • Activate() - throws if already active
  • Deactivate() - throws if already inactive

Category (Entity, not Aggregate Root)

Entity: CatalogService.Domain.AggregatesModel.ProductAggregate.Category extends Entity

Private Fields / Public Getters:

Field Type Description
_shopId / ShopId Guid Owning shop ID
_name / Name string Category name
_description / Description string? Description
_parentId / ParentId Guid? Parent category (hierarchical)
_displayOrder / DisplayOrder int Sort order
_imageUrl / ImageUrl string? Image URL
_isActive / IsActive bool Active status (default true)
_createdAt / CreatedAt DateTime Creation timestamp (UTC)
_updatedAt / UpdatedAt DateTime? Last update timestamp (UTC)

Constructor Validation: ShopId not empty, Name not blank.

Behavior Methods:

  • UpdateInfo(name, description, displayOrder) - validates name
  • UpdateImage(string?) - sets image URL
  • UpdateParent(Guid?) - throws if self-referencing
  • Activate() / Deactivate() - toggle active state

ProductType (DDD Enumeration)

Type-safe enum resolved in-memory (not via EF Core navigation):

Id Name Description
1 Physical Retail products requiring inventory
2 Service Spa/Salon services requiring booking
3 PreparedFood F&B products requiring kitchen

Domain Events

Event Trigger
ProductCreatedDomainEvent(Product) Product constructor

Database Schema

Table: products

Column Type Nullable Default Description
id uuid NO - PK, app-generated
shop_id uuid NO - Owning shop
name varchar(255) NO - Product name
description varchar(2000) YES - Description
price decimal(18,2) NO - Price
type_id integer NO - ProductType enum ID
attributes jsonb YES - Type-specific attributes
image_url varchar(500) YES - Image URL
sku varchar(100) YES - Stock Keeping Unit
barcode varchar(100) YES - Barcode/EAN-13/UPC
category_id uuid YES - Category FK (no DB constraint)
is_active boolean NO true Soft delete flag
created_at timestamptz NO - Creation time
updated_at timestamptz YES - Last update time

Indexes:

  • ix_products_shop_id (shop_id)
  • ix_products_type_id (type_id)
  • ix_products_sku (sku)
  • ix_products_barcode (barcode)
  • ix_products_is_active (is_active)
  • ix_products_category_id (category_id)

Table: categories

Column Type Nullable Default Description
id uuid NO - PK, app-generated
shop_id uuid NO - Owning shop
name varchar(200) NO - Category name
description varchar(1000) YES - Description
parent_id uuid YES - Parent category (self-ref)
display_order integer NO 0 Sort order
image_url varchar(500) YES - Image URL
is_active boolean NO true Soft delete flag
created_at timestamptz NO - Creation time
updated_at timestamptz YES - Last update time

Indexes:

  • ix_categories_shop_id (shop_id)
  • ix_categories_parent_id (parent_id)
  • ix_categories_display_order (display_order)

Table: product_types (seed-only, dropped in PhaseTwo migration)

Initially created with seed data (Physical=1, Service=2, PreparedFood=3) in InitialCatalog migration. Dropped in PhaseTwo migration — type is now resolved in-memory via Enumeration pattern. The type_id column in products remains as a plain integer (no FK constraint).

Migrations

Migration Date Changes
20260117173734_InitialCatalog 2026-01-17 Create products, categories, product_types tables with indexes and seed data
20260306175523_PhaseTwo 2026-03-06 Drop product_types table, add barcode + category_id to products, add image_url to categories, add indexes

Multi-Tenancy

The service implements shop-level tenant isolation via two layers:

  1. EF Core Global Query Filters: Both Product and Category entities have query filters that restrict results to the current shop, extracted from JWT claims (shop_id claim) or X-Shop-Id header. Bypassed for admin/system roles and service-to-service calls (X-Service-Call: internal).

  2. PostgreSQL RLS Session Variables: TenantMiddleware sets SET LOCAL app.current_shop_id and app.current_merchant_id on the Npgsql connection for defense-in-depth (requires RLS policies configured at DB level).

Tenant Provider Chain: HttpContextTenantProvider (API layer) -> CatalogTenantProviderAdapter -> ICatalogTenantProvider (Infrastructure layer, consumed by CatalogContext).


MediatR Pipeline

Request flow through behaviors (in order):

  1. LoggingBehavior - Logs request name, elapsed time, errors (Stopwatch)
  2. ValidatorBehavior - Runs all FluentValidation validators, throws ValidationException on failure
  3. TransactionBehavior - Wraps Commands in DB transaction (skips Queries by name suffix "Query"), uses ExecutionStrategy for retry-on-failure

Repository

IProductRepository

Method Returns Description
Add(Product) Product Add new product
Update(Product) void Mark product as modified
GetByIdAsync(Guid, CancellationToken) Product? Get by ID
GetByShopIdAsync(Guid, CancellationToken) IEnumerable<Product> Get all by shop
GetByTypeAsync(Guid, ProductType, CancellationToken) IEnumerable<Product> Get by shop + type
GetByBarcodeOrSkuAsync(Guid, string, CancellationToken) Product? Lookup active product by barcode or SKU in shop

Implementation: ProductRepository uses CatalogContext. UnitOfWork exposed via context.

Note: Category CRUD handlers use CatalogContext directly (no separate ICategoryRepository).


DTOs

ProductDto

Id, ShopId, Name, Description, Price, Type (resolved name), Attributes (Dictionary), ImageUrl, Sku, Barcode, CategoryId, CategoryName (resolved), IsActive, CreatedAt, UpdatedAt

CategoryDto

Id, ShopId, Name, Description, ParentId, DisplayOrder, ImageUrl, IsActive, CreatedAt, UpdatedAt

PagedResult<T>

Items (IReadOnlyList<T>), TotalCount, Page, PageSize, TotalPages (computed), HasPrevious (computed), HasNext (computed)


Dependencies (NuGet)

Package Version Purpose
MediatR 12.4.1 CQRS pipeline
FluentValidation 11.11.0 Command validation
FluentValidation.DependencyInjectionExtensions 11.11.0 Auto-registration
Microsoft.EntityFrameworkCore.Design 10.0.0 EF migrations tooling
Microsoft.AspNetCore.Authentication.JwtBearer 10.0.1 JWT auth
Swashbuckle.AspNetCore 7.2.0 Swagger/OpenAPI
Asp.Versioning.Mvc 8.1.0 API versioning
Asp.Versioning.Mvc.ApiExplorer 8.1.0 Version discovery
AspNetCore.HealthChecks.NpgSql 8.0.2 PostgreSQL health check
AspNetCore.HealthChecks.Redis 8.0.1 Redis health check
Hellang.Middleware.ProblemDetails 6.5.1 RFC 7807 errors
Serilog.AspNetCore 8.0.3 Structured logging
Serilog.Sinks.Console 6.0.0 Console output
Serilog.Sinks.Seq 8.0.0 Seq sink

Project References: CatalogService.Domain, CatalogService.Infrastructure


Configuration

appsettings.json

Key Value/Description
ConnectionStrings:DefaultConnection Neon PostgreSQL connection string
Redis:ConnectionString localhost:6379
Jwt:Authority IAM IdentityServer URL (default http://localhost:5001)
Jwt:Secret JWT signing key
Jwt:Issuer goodgo-platform
Jwt:Audience goodgo-services
Jwt:AccessTokenExpiryMinutes 15
Jwt:RefreshTokenExpiryDays 7
Serilog Console output with structured template

Environment Variables

Variable Description
DATABASE_URL Fallback connection string (overrides DefaultConnection)
ASPNETCORE_ENVIRONMENT Development enables Swagger, sensitive data logging

Tests

Unit Tests (tests/CatalogService.UnitTests/)

  • Domain/ProductAggregateTests.cs - Tests for Product entity behavior

Functional Tests (tests/CatalogService.FunctionalTests/)

  • CustomWebApplicationFactory.cs - WebApplicationFactory with InMemory DB
  • Controllers/ProductsControllerTests.cs - API endpoint tests

Idempotency

IRequestManager / RequestManager provides duplicate request detection via ClientRequest entity (Id, Name, Time). Registered in DI but not currently wired into any command handler.


Notes & Observations

  1. No [Authorize] attributes on any controller endpoint. Authentication middleware is registered but authorization is not enforced at the controller level. Tenant filtering via global query filters provides shop-level isolation.
  2. Category handlers bypass repository pattern — they use CatalogContext directly instead of through an ICategoryRepository.
  3. ProductType table dropped in PhaseTwo migration. The type_id column has no FK constraint; type resolution is done in-memory via Enumeration.GetAll<ProductType>().
  4. JSONB attributes use a custom ValueConverter + ValueComparer for EF Core change tracking on JsonDocument.
  5. Auto-migration on startupdbContext.Database.MigrateAsync() runs at app start with error swallowing.
  6. Sample aggregate (SampleAggregate/) exists from the template but is not used by any controller, command, or query.