Skip to content

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.

  • 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

AuditedAggregateRoot, IWorkflowStateful, IHasMetadata — the catalog item.

Fields:

  • Sku (string, unique per Host catalog) — stable business identifier
  • Name, Description?
  • Type: Service | Metered | Physical | Digital
  • Unit (free text — "call", "GB", "seat", "license", …)
  • LifecycleStatus: DraftPublishedArchived (via WorkflowLifecycleStatus)
  • MetadataJson (string?) — Stripe-style metadata via IHasMetadata; use the framework extensions (GetMetadataValue, SetMetadataValue, …)
  • ExternalMappings — provider integrations (Stripe prod_xxx, Avalara tax codes, Odoo product ids, …)

Behaviors:

  • Create(id, sku, name, type, unit, description?) — factory, starts in Draft
  • Update(name, description, unit) — Draft only; Sku and Type are immutable post-creation (changing them reshapes downstream contracts and must be modeled as a fresh product)
  • Publish() — Draft → Published; unlocks references from Metering / Subscriptions
  • Archive() — Published → Archived; existing references unaffected
  • ReplaceMetadata(dict) — bulk replace; for granular updates, use the framework SetMetadataValue(name, value) extension
  • AddExternalMapping(mapping) / RemoveExternalMapping(mappingId) — allowed in any lifecycle state

Domain events: ProductCreatedEvent, ProductPublishedEvent, ProductArchivedEvent.

Drives downstream behavior — tax classification, shipping requirements, metering binding:

  • Service — consultancy, onboarding, training (no measurement)
  • Metered — usage-based; bound to a Granit.Metering.MeterDefinition via MeterDefinition.ProductId
  • Physical / Digital — placeholders for the future e-commerce phase

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.

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 Metadata and ExternalMappings; new meters and plan prices may reference it
  • Archived: tombstone; existing references keep working; new attachments are blocked at the application layer
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).

Reads (Catalog.Products.Read):

  • GET /catalog/products — list Published products
  • GET /catalog/products/{id} — get by id (any lifecycle status)
  • GET /catalog/products/by-sku/{sku} — reverse lookup for integration syncs
  • GET /catalog/product-records/... — QueryEngine surface (paginated, filterable, exportable)

Writes (Catalog.Products.Manage, all idempotent):

  • POST /catalog/products — create in Draft
  • PUT /catalog/products/{id} — update editable fields (Draft only)
  • PUT /catalog/products/{id}/metadata — bulk-replace extra properties
  • POST /catalog/products/{id}/publish — Draft → Published
  • POST /catalog/products/{id}/archive — Published → Archived
  • POST /catalog/products/{id}/external-mappings — add a Stripe / Avalara / Odoo mapping
  • DELETE /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.

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 link
await meterWriter.AddAsync(meter);
  • SaaS Overview — ecosystem architecture and choreography
  • SubscriptionsPlanPrice.ProductId consumer
  • MeteringMeterDefinition.ProductId consumer
  • ADR 032 — design decisions and the path to multi-tenant e-commerce