In enterprise B2B SaaS and commerce platforms, product and service catalogs are far more than simple online store listings:
- Configurable Offerings & Add-Ons: SaaS subscriptions contain base user seats, extra storage packages, and dynamic AI token addons;
- Tiered & Custom Enterprise Pricing: Different tenant organizations negotiate private contract pricing and custom discount matrices;
- Immutable Version Snapshotting: When orders are placed, the historical offering specification must be permanently frozen to prevent retroactive pricing tampering!
BitzOrcas.Modern Catalog Module implements “Domain Aggregates + Version Snapshots + Dynamic Pricing”:
Catalog Lifecycle and Publishing Topology
Step 1: Core Domain Entity OfferingAggregate
The offering entity adopts the Unified Aggregate pattern, mapping directly to CatOffering:
using System;using System.ComponentModel;using BitzOrcas.Domain.Entities;using BitzOrcas.Domain.Results;using BitzOrcas.Persistence.Metadata;
namespace BitzOrcas.Catalog.Domain;
public static class CatalogErrors{ public static readonly Error Archived = Error.Conflict("Catalog.Archived", "Archived offering cannot publish new versions.");}
[BitzTable("CatOffering", IsTenant = true, IsSoftDelete = true, Description = "Service Offerings Table")][BitzIndex("IX_CatOffering_Code", nameof(Code), nameof(TenantId), IsUnique = true)]public sealed class OfferingAggregate : TenantAggregateRoot<string>{ [BitzColumn(Length = 64, IsRequired = true)] public string Code { get; private set; } = string.Empty;
[BitzColumn(Length = 120, IsRequired = true)] public string Name { get; private set; } = string.Empty;
[BitzColumn(Length = 2000)] public string Description { get; private set; } = string.Empty;
// Active version identifier [BitzColumn(IsRequired = true)] public int ActiveVersion { get; private set; } = 1;
// Base offering price [BitzColumn(Precision = 18, Scale = 4, IsRequired = true)] public decimal BasePrice { get; private set; }
[BitzColumn(IsRequired = true)] public OfferingStatus Status { get; private set; } = OfferingStatus.Draft;
[Obsolete("For ORM materialization only. Use Create.", error: true)] [EditorBrowsable(EditorBrowsableState.Never)] public OfferingAggregate() : base("0") { }
// Domain method publishing new offering version public Result PublishNewVersion(decimal newPrice, string operatorId) { // 1. Invariant check: archived offerings cannot be republished if (Status == OfferingStatus.Archived) { return Result.Failure(CatalogErrors.Archived); }
// 2. State transition BasePrice = newPrice; ActiveVersion++; Status = OfferingStatus.Active;
// 3. Emit published domain event AddDomainEvent(new OfferingPublishedDomainEvent(Id, Code, ActiveVersion, BasePrice, operatorId));
return Result.Success(); }}Step 2: Read Query Slice with QueryShape
List queries use IDeclareQueryShape for zero-boilerplate paginated projections:
using BitzOrcas.Application.Abstractions.Queries;using BitzOrcas.Domain.Results;using BitzOrcas.Endpoint.Attributes;using Mediator;
namespace BitzOrcas.Catalog.Application.Queries;
// 1. Declarative query endpoint: compile-time Minimal API mapping and OpenAPI annotations[GenerateEndpoint(HttpRoute.Query, "/api/catalog/offerings", Tag = "Catalog")]// 2. Strongly typed query contract with standardized pagination parameterspublic sealed record ListOfferingsQuery( string? Keyword, OfferingStatus? Status, int PageIndex = 1, int PageSize = 20) : IQuery<Result<PagedList<OfferingDto>>>;Summary
The Catalog Module guarantees enterprise robustness:
- Version Snapshotting: Preserves contract fidelity across order lifecycles;
- Dual ORM Mapping: EF Core handles transactional writes while SqlSugar accelerates high-throughput reads;
- Event-Driven Search: Real-time asynchronous index synchronization.