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,
Moneyprice (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
ProductImageentries. The aggregate guarantees exactly one thumbnail, contiguous sort order, and automatic promotion when the current thumbnail is removed. - Domain events —
ProductCreatedDomainEvent,ProductPriceChangedDomainEvent,ProductStockAdjustedDomainEventfor cross-module reactions. - Files-module integration —
ProductFileAccessPolicyregisters theProductowner 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 withVisibility=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/DTOsThe 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.
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:
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 levelEndpoints
28 endpoints under /api/v1/catalog. Highlights of the products subtree (the most interesting):
| Verb | Route | What it does |
|---|---|---|
| POST | /products | Create a product |
| GET | /products/{productId} | Fetch product with images |
| PUT | /products/{productId} | Update name, description, brand, category, active |
| PATCH | /products/{productId}/price | Change price, emit ProductPriceChangedDomainEvent |
| PATCH | /products/{productId}/stock | Adjust stock by delta, emit ProductStockAdjustedDomainEvent |
| DELETE | /products/{productId} | Soft delete |
| POST | /products/{productId}/restore | Restore soft-deleted product |
| GET | /products/trash | List soft-deleted products |
| GET | /products | Paginated search — search, brandId, categoryId, isActive, sortBy, sortDir |
| POST | /products/{productId}/images | Attach an image (with or without a FileAsset) |
| DELETE | /products/{productId}/images/{imageId} | Detach image (auto-promotes next thumbnail) |
| PUT | /products/{productId}/images/{imageId}/thumbnail | Promote image to thumbnail |
| PUT | /products/{productId}/images/order | Reorder 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 byProduct.Create.ProductPriceChangedDomainEvent(Guid ProductId, decimal OldAmount, decimal NewAmount, string Currency)— raised byProduct.ChangePrice.ProductStockAdjustedDomainEvent(Guid ProductId, int OldStock, int NewStock, int Delta)— raised byProduct.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— schemacatalog, tenant-aware viaBaseDbContext. 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-demoruns (opt-in verb, dev-only by design; the seeding lives in the migrator’sDemoSeedfolder).
How to extend
Add a new resource (e.g. Suppliers)
Mirror the brand structure:
- New aggregate at
Modules.Catalog/Domain/Supplier.csinheritingAggregateRoot<Guid>andISoftDeletable. - EF configuration at
Data/Configurations/SupplierConfiguration.cs. - Feature folders at
Features/v1/Suppliers/{Create,Update,Delete,Restore,Search,GetById,ListTrashed}/. - Permission constants in
Contracts/Authorization/CatalogPermissions.cs. - 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 resourceProductImagesTests.csandProductImageRemoveReorderTests.cs— image add/remove/reorder/thumbnail flowsUpdateProductBranchTests.cs— update edge casesProductFileAccessPolicyTests.cs— the Files-module policy hookCatalogTenantIsolationTests.cs— cross-tenant leakage guardsPermissionRegistrationTests.cs— verifies the permission constants are registeredRolePermissionSyncerTests.cs— verifies seeded role-permission mappings
This is the most thoroughly tested module — use it as the reference for integration-test patterns.
Related
- Files module — product images attach to a
FileAsset; the policy lives here. - Architecture: vertical slice — the feature-folder pattern this module uses.
- Modules overview — the other nine modules.