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>
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_serviceon 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-Versionheader)
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
ProductTypefrom Enumeration, converts attributes dict toJsonDocument, createsProductaggregate, optionally sets image, saves via repository + UnitOfWork - Domain Event:
ProductCreatedDomainEventraised 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
DomainExceptionif not found), callsUpdateInfo(),SetCategory(), optionallyUpdateAttributes()andUpdateImage(), 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
DomainExceptionif not found), callsDeactivate()(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
Categoryentity, optionally sets image, saves directly viaCatalogContext - 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(), optionallyUpdateImage(), 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/priceUpdateAttributes(JsonDocument?)- replaces JSONB attributesUpdateImage(string?)- sets image URLSetCategory(Guid?)- sets category referenceUpdateBarcode(string?)- sets barcode valueUpdateSku(string?)- sets SKU valueActivate()- throws if already activeDeactivate()- 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 nameUpdateImage(string?)- sets image URLUpdateParent(Guid?)- throws if self-referencingActivate()/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:
-
EF Core Global Query Filters: Both
ProductandCategoryentities have query filters that restrict results to the current shop, extracted from JWT claims (shop_idclaim) orX-Shop-Idheader. Bypassed for admin/system roles and service-to-service calls (X-Service-Call: internal). -
PostgreSQL RLS Session Variables:
TenantMiddlewaresetsSET LOCAL app.current_shop_idandapp.current_merchant_idon 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):
- LoggingBehavior - Logs request name, elapsed time, errors (Stopwatch)
- ValidatorBehavior - Runs all FluentValidation validators, throws
ValidationExceptionon failure - TransactionBehavior - Wraps Commands in DB transaction (skips Queries by name suffix
"Query"), usesExecutionStrategyfor 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 DBControllers/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
- 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.
- Category handlers bypass repository pattern — they use
CatalogContextdirectly instead of through anICategoryRepository. - ProductType table dropped in PhaseTwo migration. The
type_idcolumn has no FK constraint; type resolution is done in-memory viaEnumeration.GetAll<ProductType>(). - JSONB attributes use a custom
ValueConverter+ValueComparerfor EF Core change tracking onJsonDocument. - Auto-migration on startup —
dbContext.Database.MigrateAsync()runs at app start with error swallowing. - Sample aggregate (
SampleAggregate/) exists from the template but is not used by any controller, command, or query.