Skip to content
fullstackhero

Reference

Catalog module

Production-grade product catalogue — brands, hierarchical categories, products with multi-image support, price + stock domain events, and soft delete.

views 0 Last updated

The Catalog module is fullstackhero’s most feature-complete reference implementation. It ships brands, a self-referential category tree, products with money-valued prices, a multi-image aggregate, full soft-delete plumbing, price + stock domain events, and paginated search — across 123 source files with 28 endpoints. Use it as the canonical example of a tenant-scoped CRUD module that’s not a stub.

What ships in v10

  • Brands — flat list, name + slug + description + logo. Full CRUD plus soft delete, restore, search.
  • Categories — self-referential hierarchical tree, optional parent, lookup via GetCategoryTreeQuery. Full CRUD plus soft delete, restore, search.
  • Products — SKU, name, slug, description, brand, category, Money price (value object), stock, active flag. Full CRUD plus soft delete, restore, paginated search with filters.
  • Multi-image products — each product owns an ordered collection of ProductImage entries. The aggregate guarantees exactly one thumbnail, contiguous sort order, and automatic promotion when the current thumbnail is removed.
  • Domain eventsProductCreatedDomainEvent, ProductPriceChangedDomainEvent, ProductStockAdjustedDomainEvent for cross-module reactions.
  • Files-module integrationProductFileAccessPolicy registers the Product owner type with the Files module: attach requires an authenticated caller (the durable gate is the product-update permission when the image lands on the product), read is public (product images ship with Visibility=Public), delete is uploader-only. Orphaned uploads that never attach are reaped by the Files orphan-purge job.
  • 16 fine-grained permissions — View, Create, Update, Delete, Restore per resource (brand / category / product), plus Catalog.Products.AdjustStock.
  • 28 endpoints under /api/v1/catalog/....
  • Recycle bin — the soft-delete trash/restore endpoints back the tenant dashboard’s Trash page (products, categories, and brands tabs, permission-gated).

Architecture at a glance

src/Modules/Catalog/
├── Modules.Catalog/
│ ├── CatalogModule.cs IModule entry — order 600
│ ├── Domain/
│ │ ├── Brand.cs ISoftDeletable
│ │ ├── Category.cs Self-referential, ISoftDeletable
│ │ ├── Product.cs AggregateRoot, ISoftDeletable, image owner
│ │ ├── ProductImage.cs Owned by Product
│ │ └── Money.cs Value object — EF persisted as owned type
│ ├── Data/
│ │ ├── CatalogDbContext.cs Tenant-aware (extends BaseDbContext)
│ │ ├── Configurations/ EF type configs
│ │ └── CatalogDbInitializer.cs Seeds demo data when DemoSeeder runs
│ ├── Features/v1/
│ │ ├── Brands/ 7 handlers + endpoints
│ │ ├── Categories/ 8 handlers + endpoints
│ │ └── Products/ 13 handlers + endpoints (including image ops)
│ ├── Events/ Domain event handlers
│ └── Authorization/
│ └── ProductFileAccessPolicy.cs Gates Files-module access
└── Modules.Catalog.Contracts/ Public commands/queries/events/DTOs

The module loads at order 600, after Identity, Multitenancy, Auditing, and Billing. Its DbContext is tenant-aware (each tenant has its own catalogue), so multitenancy is enforced through the EF Core global query filter automatically.

The product + image aggregate

The most interesting code in this module is Product enforcing image invariants. Images are an owned collection, not a separate aggregate. That’s a deliberate choice: the rules (“exactly one thumbnail”, “the product always has a cover while it has images”) are aggregate-level invariants that belong inside the root.

src/Modules/Catalog/Modules.Catalog/Domain/Product.cs
public sealed class Product : AggregateRoot<Guid>, ISoftDeletable
{
private readonly List<ProductImage> _images = [];
public IReadOnlyList<ProductImage> Images => _images;
public ProductImage AddImage(Guid? fileAssetId, string url)
{
ArgumentException.ThrowIfNullOrWhiteSpace(url);
bool isFirst = _images.Count == 0; // first image is auto-thumbnail
int order = isFirst ? 0 : _images.Max(i => i.SortOrder) + 1;
var image = ProductImage.Create(Id, fileAssetId, url, isThumbnail: isFirst, sortOrder: order);
_images.Add(image);
UpdatedAtUtc = DateTime.UtcNow;
return image;
}
public void RemoveImage(Guid imageId)
{
var image = _images.FirstOrDefault(i => i.Id == imageId)
?? throw new InvalidOperationException($"Image {imageId} not found on product {Id}.");
bool wasThumbnail = image.IsThumbnail;
_images.Remove(image);
if (wasThumbnail && _images.Count > 0)
{
// lowest-sorted remaining image becomes the cover
_images.OrderBy(i => i.SortOrder).First().MarkThumbnail(true);
}
UpdatedAtUtc = DateTime.UtcNow;
}
}

The four image endpoints (AddProductImage, RemoveProductImage, SetProductThumbnail, ReorderProductImages) each call exactly one aggregate method. The handler is one or two lines.

Public API

The contracts assembly has 28 commands and queries grouped into three areas. The full list is in Modules.Catalog.Contracts/v1/. Highlights:

src/Modules/Catalog/Modules.Catalog.Contracts/v1/Products/CreateProductCommand.cs
public sealed record CreateProductCommand(
string Sku,
string Name,
string? Description,
Guid BrandId,
Guid CategoryId,
decimal PriceAmount,
string PriceCurrency,
int Stock) : ICommand<Guid>;
// Price + stock get dedicated commands so callers can adjust either without
// rewriting the product object — and so the handler can emit the right event.
public sealed record ChangeProductPriceCommand(
Guid ProductId,
decimal Amount,
string Currency) : ICommand<Guid>;
public sealed record AdjustProductStockCommand(
Guid ProductId,
int Delta) : ICommand<int>; // returns the new stock level

Endpoints

28 endpoints under /api/v1/catalog. Highlights of the products subtree (the most interesting):

VerbRouteWhat it does
POST/productsCreate a product
GET/products/{productId}Fetch product with images
PUT/products/{productId}Update name, description, brand, category, active
PATCH/products/{productId}/priceChange price, emit ProductPriceChangedDomainEvent
PATCH/products/{productId}/stockAdjust stock by delta, emit ProductStockAdjustedDomainEvent
DELETE/products/{productId}Soft delete
POST/products/{productId}/restoreRestore soft-deleted product
GET/products/trashList soft-deleted products
GET/productsPaginated search — search, brandId, categoryId, isActive, sortBy, sortDir
POST/products/{productId}/imagesAttach an image (with or without a FileAsset)
DELETE/products/{productId}/images/{imageId}Detach image (auto-promotes next thumbnail)
PUT/products/{productId}/images/{imageId}/thumbnailPromote image to thumbnail
PUT/products/{productId}/images/orderReorder via orderedImageIds[]

Similar full CRUD + soft-delete + search shapes exist for /brands and /categories. Categories add a /categories/tree endpoint for the hierarchical view.

Domain events

Three events ship with the module (each also carries an EventId and OccurredOnUtc from the DomainEvent base):

  • ProductCreatedDomainEvent(Guid ProductId, string Sku, string Name) — raised by Product.Create.
  • ProductPriceChangedDomainEvent(Guid ProductId, decimal OldAmount, decimal NewAmount, string Currency) — raised by Product.ChangePrice.
  • ProductStockAdjustedDomainEvent(Guid ProductId, int OldStock, int NewStock, int Delta) — raised by Product.AdjustStock.

The Modules.Catalog/Events/ folder contains in-module handlers. To react from another module, declare an integration event in your contracts and bridge it in the catalog event handler — keep cross-module dependencies one-way and contracts-only.

Configuration

The Catalog module is tenant-aware out of the box and reads no IOptions<T>. Configure it through:

  • CatalogDbContext — schema catalog, tenant-aware via BaseDbContext. Each tenant gets its own products/brands/categories.
  • Connection string — uses the per-tenant connection resolved by UseHeroMultiTenantDatabases() in the composition root.
  • Demo seed — sample brands, categories, and products are populated when FSH.Starter.DbMigrator seed-demo runs (opt-in verb, dev-only by design; the seeding lives in the migrator’s DemoSeed folder).

How to extend

Add a new resource (e.g. Suppliers)

Mirror the brand structure:

  1. New aggregate at Modules.Catalog/Domain/Supplier.cs inheriting AggregateRoot<Guid> and ISoftDeletable.
  2. EF configuration at Data/Configurations/SupplierConfiguration.cs.
  3. Feature folders at Features/v1/Suppliers/{Create,Update,Delete,Restore,Search,GetById,ListTrashed}/.
  4. Permission constants in Contracts/Authorization/CatalogPermissions.cs.
  5. Register endpoints in CatalogModule.MapEndpoints.

Architecture tests will fail the build if you skip the Contracts separation or use the wrong base.

Wire price-change reactions

Add an integration event in Modules.Catalog.Contracts/IntegrationEvents/:

public sealed record ProductRepricedIntegrationEvent(
Guid ProductId,
string Sku,
decimal NewPriceAmount,
string NewPriceCurrency) : IIntegrationEvent;

Publish from the in-module domain event handler:

public sealed class ProductPriceChangedDomainEventHandler(IEventBus bus)
: INotificationHandler<ProductPriceChangedDomainEvent>
{
public async ValueTask Handle(ProductPriceChangedDomainEvent evt, CancellationToken ct)
=> await bus.PublishAsync(new ProductRepricedIntegrationEvent(/* ... */), ct).ConfigureAwait(false);
}

(In-module domain event handlers are Mediator INotificationHandler<T> implementations — see Modules.Catalog/Events/CatalogEventHandlers.cs for the shipped ones, which currently just log.)

Then any other module can subscribe via IIntegrationEventHandler<ProductRepricedIntegrationEvent>.

Replace search with full-text

SearchProductsQuery today is a Contains() filter. To upgrade to PostgreSQL full-text search, add a generated tsvector column plus a GIN index in a raw-SQL migration and query it with websearch_to_tsquery. The Chat module already does this — copy the pattern from the AddMessagesFullTextSearch migration in src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/ and the FromSql query in Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs.

Tests

Ten integration test files in src/Tests/Integration.Tests/Tests/Catalog/ exercise every endpoint against Testcontainers Postgres + MinIO:

  • BrandsEndpointTests.cs, CategoriesEndpointTests.cs, ProductsEndpointTests.cs — CRUD + soft-delete/restore per resource
  • ProductImagesTests.cs and ProductImageRemoveReorderTests.cs — image add/remove/reorder/thumbnail flows
  • UpdateProductBranchTests.cs — update edge cases
  • ProductFileAccessPolicyTests.cs — the Files-module policy hook
  • CatalogTenantIsolationTests.cs — cross-tenant leakage guards
  • PermissionRegistrationTests.cs — verifies the permission constants are registered
  • RolePermissionSyncerTests.cs — verifies seeded role-permission mappings

This is the most thoroughly tested module — use it as the reference for integration-test patterns.