Catalog — Shared Product Aggregate
Granit.Catalog introduces a single source of truth for “what is being sold” —
a Product aggregate referenced by Granit.Metering.MeterDefinition.ProductId
and Granit.Subscriptions.PlanPrice.ProductId. This is the join key that makes
ORB-style invoice line item provenance possible: the audit chain
event → meter → product → invoice line becomes queryable in SQL instead of
relying on convention-based name matching.
Package structure
Section titled “Package structure”DirectoryGranit.Catalog/ Domain: Product, ProductExternalMapping, ProductType, events, CQRS interfaces
- Granit.Catalog.EntityFrameworkCore Isolated DbContext, Reader/Writer
- Granit.Catalog.Endpoints Product CRUD, lifecycle, external mappings, QueryEngine
Domain model
Section titled “Domain model”Product (aggregate root)
Section titled “Product (aggregate root)”AuditedAggregateRoot, IWorkflowStateful, IHasMetadata — the catalog item.
Fields:
Sku(string, unique per Host catalog) — stable business identifierName,Description?Type:Service|Metered|Physical|DigitalUnit(free text —"call","GB","seat","license", …)LifecycleStatus:Draft→Published→Archived(viaWorkflowLifecycleStatus)MetadataJson(string?) — Stripe-style metadata viaIHasMetadata; use the framework extensions (GetMetadataValue,SetMetadataValue, …)ExternalMappings— provider integrations (Stripeprod_xxx, Avalara tax codes, Odoo product ids, …)
Behaviors:
Create(id, sku, name, type, unit, description?)— factory, starts inDraftUpdate(name, description, unit)— Draft only;SkuandTypeare immutable post-creation (changing them reshapes downstream contracts and must be modeled as a fresh product)Publish()— Draft → Published; unlocks references from Metering / SubscriptionsArchive()— Published → Archived; existing references unaffectedReplaceMetadata(dict)— bulk replace; for granular updates, use the frameworkSetMetadataValue(name, value)extensionAddExternalMapping(mapping)/RemoveExternalMapping(mappingId)— allowed in any lifecycle state
Domain events: ProductCreatedEvent, ProductPublishedEvent, ProductArchivedEvent.
ProductType
Section titled “ProductType”Drives downstream behavior — tax classification, shipping requirements, metering binding:
Service— consultancy, onboarding, training (no measurement)Metered— usage-based; bound to aGranit.Metering.MeterDefinitionviaMeterDefinition.ProductIdPhysical/Digital— placeholders for the future e-commerce phase
ProductExternalMapping
Section titled “ProductExternalMapping”Child entity, one row per external system:
ProviderName("stripe","avalara","odoo", …)ExternalId(the provider’s identifier —"prod_1234","PS080100", …)
(ProviderName, ExternalId) is globally unique across all products — one
external identifier maps to exactly one product.
Lifecycle
Section titled “Lifecycle”stateDiagram-v2
[*] --> Draft : Product.Create()
Draft --> Published : Publish()
Published --> Archived : Archive()
Draft --> [*] : delete (future)
Archived --> [*] : delete (audit-protected — never)
- Draft: editable; not yet usable by Metering or Subscriptions
- Published: immutable except for
MetadataandExternalMappings; new meters and plan prices may reference it - Archived: tombstone; existing references keep working; new attachments are blocked at the application layer
Cross-module wiring
Section titled “Cross-module wiring”graph LR
PROD[Product] -.referenced by.-> METER[MeterDefinition.ProductId]
PROD -.referenced by.-> PRICE[PlanPrice.ProductId]
METER -->|usage events| AGG[UsageAggregate]
AGG -->|UsageSummaryReadyEto| SUB[Subscriptions orchestrator]
PRICE -->|fixed line items| SUB
SUB -->|CreateInvoiceCommand| INV[Invoicing]
style PROD fill:#7d5fff,color:#fff
style METER fill:#a55eea,color:#fff
style PRICE fill:#4a9eff,color:#fff
References across modules are soft (no SQL FK across module schemas) — the application layer is responsible for catalog deletion safety (refuse to delete a product if any current meter or price still references it).
Admin API
Section titled “Admin API”Reads (Catalog.Products.Read):
GET /catalog/products— list Published productsGET /catalog/products/{id}— get by id (any lifecycle status)GET /catalog/products/by-sku/{sku}— reverse lookup for integration syncsGET /catalog/product-records/...— QueryEngine surface (paginated, filterable, exportable)
Writes (Catalog.Products.Manage, all idempotent):
POST /catalog/products— create in DraftPUT /catalog/products/{id}— update editable fields (Draft only)PUT /catalog/products/{id}/metadata— bulk-replace extra propertiesPOST /catalog/products/{id}/publish— Draft → PublishedPOST /catalog/products/{id}/archive— Published → ArchivedPOST /catalog/products/{id}/external-mappings— add a Stripe / Avalara / Odoo mappingDELETE /catalog/products/{id}/external-mappings/{mappingId}— remove a mapping
The /catalog/product-records route exposes the full Granit QueryEngine
surface (/, /meta, /saved-views/*) — filter, sort, group, paginate, export
to CSV/XLSX, and save per-user views. Mounted on a dedicated sub-path to avoid
colliding with the resource endpoints above (mirrors /metering/meter-definitions).
All endpoints are Host-side only (MultiTenancySide.Host) — only the SaaS
provider’s host admin manages the catalog.
Quick start
Section titled “Quick start”Wire the module into a Granit app:
builder.AddGranitCatalog();builder.AddGranitCatalogEntityFrameworkCore(opts => opts.UseNpgsql(builder.Configuration.GetConnectionString("Catalog")));
// Then in MapEndpoints:app.MapGranitCatalog();Reference a Catalog product from a meter:
var apiCallsProduct = Product.Create( productId, sku: "API-CALLS", name: "API Calls", type: ProductType.Metered, unit: "call");apiCallsProduct.Publish();await productWriter.AddAsync(apiCallsProduct);
var meter = MeterDefinition.Create( meterId, name: "api.calls", unit: "call", aggregationType: AggregationType.Count, description: null, productId: apiCallsProduct.Id); // ← cross-module linkawait meterWriter.AddAsync(meter);See also
Section titled “See also”- SaaS Overview — ecosystem architecture and choreography
- Subscriptions —
PlanPrice.ProductIdconsumer - Metering —
MeterDefinition.ProductIdconsumer - ADR 032 — design decisions and the path to multi-tenant e-commerce