Skip to content

Catalogues Domain

The Catalogues domain (catalogues_v1) is a bounded context that manages the complete specification and cataloging of assets available in the CO2 asset management system. This domain handles catalogue creation and lifecycle, asset type specifications through listings, supplier information, and taxonomies for categorizing equipment.

The Catalogues domain serves as the central repository for asset specifications and definitions. It bridges the gap between administrative configuration and operational asset tracking. While the Trackable Assets domain manages actual asset instances deployed in estates, the Catalogues domain defines what types of assets can exist and their specification templates.

  • Catalogues: Groups of asset specifications organized by workspace or owner (estate/supplier)
  • Listings: Individual asset specifications with custom fields, pricing, supplier info, and physical dimensions
  • Taxonomies: Hierarchical classification systems (categories and subcategories) used to organize listings
  • Suppliers: Vendor management with membership tracking
  • Custom Fields: Extensible field definitions for capturing asset-specific data

The root aggregate representing a collection of asset specifications.

Identity:

  • CatalogueId: Unique identifier for the catalogue

Core Attributes:

final CatalogueName name;
final CatalogueDescription description;
final CatalogueStatus status; // draft, active, archived
final EstateId? ownerEstateId; // Optional: estate ownership
final SupplierId? ownerSupplierId; // Optional: supplier ownership
final TaxonomyId? taxonomyId; // Associated taxonomy for listings
final DomainText referenceCodePrefix; // Prefix for generated reference codes
final int nextReferenceNumber; // Counter for reference code generation

Metadata:

final CatalogueMetadata catalogueMetadata; // Type-safe metadata storage
final UserId createdByUserId; // Creator audit
final DateTime createdAt; // Timestamps
final DateTime updatedAt;

Key Methods:

  • isActive, isDraft, isArchived: Status convenience properties
  • hasOwner, isEstateOwned, isSupplierOwned: Ownership checking
  • listingsWorkspace: Returns the workspace ID for querying listings in this catalogue
  • ownsListing(listing): Predicate to check listing ownership

Notes on Ownership:

  • A catalogue can be globally accessible (no owner), owned by a single estate, or owned by a single supplier
  • A catalogue cannot be owned by both estate and supplier simultaneously
  • Ownership affects access control and visibility in the UI

Represents a single asset specification within a catalogue.

Identity:

  • ListingId: Unique identifier for the listing

Core Attributes:

final CatalogueId catalogueId; // Parent catalogue
final ListingTitle name;
final ListingDescription description;
final ListingStatus status; // draft, submitted, approved, rejected
final TaxonomyReference taxonomyRef; // Classification within catalogue's taxonomy
final SupplierId? supplierId; // Optional supplier association
final SupplierName? supplierName;
final bool isCertified; // Certification flag

Physical Properties:

final double scaleX; // Width in meters (required)
final double scaleY; // Height/depth in meters (required)
final ListingShape shape; // rectangle or circle
final bool isHidden; // Visibility toggle (approved but hidden from users)

Specifications:

final CustomFieldMap customFields; // Taxonomy-defined custom fields
final List<DomainText> tags; // User-defined tags
final Pricing? pricing; // Optional pricing information
final FileUrl? thumbnailUrl; // Optional product image
final TimelineName? timelineName; // Optional timeline for lifecycle tracking

Reference Tracking:

final int? referenceNumber; // Auto-generated from catalogue prefix
final String? referenceCode; // Human-readable code (e.g., "HVAC-001")
final int? publishedAtSequence; // Sequence number when published (for time-travel)

Audit:

final UserId createdByUserId;
final DateTime createdAt;
final DateTime updatedAt;

Key Methods:

  • Status predicates: isDraft, isSubmitted, isApproved, isRejected, isActive
  • canBeSubmitted(): Validates required fields before submission
  • canBeApproved(): Checks approval eligibility
  • isValidForCatalogue(): Comprehensive validation

Publishing System: When a listing is first approved (status transitions to approved), it is automatically published. The publishedAtSequence records the workspace intent sequence at publication, allowing retrieval of the published snapshot via Nomos time-travel queries.

Manages hierarchical classification systems for listings.

Identity:

  • TaxonomyId: Unique identifier for the taxonomy

Core Attributes:

final TaxonomyName name;
final TaxonomyDescription description;
final List<TaxonomyCategory> categories; // Top-level categories
final UserId createdByUserId;
final DateTime createdAt;
final DateTime updatedAt;

Hierarchy:

Taxonomy
├── Category (e.g., "HVAC Equipment")
│ ├── SubCategory (e.g., "Air Handling Units")
│ │ └── CustomFields
│ └── SubCategory (e.g., "Boilers")
└── Category (e.g., "Electrical")
├── SubCategory

Key Methods:

  • getCategory(categoryId): Retrieve category by ID
  • getSubCategory(categoryId, subCategoryId): Nested lookup
  • getCustomFieldSchema(categoryId, subCategoryId): Returns merged custom field definitions
  • getRequiredCustomFields(...): Filters required fields only
  • validateCustomFields(...): Validates field values against schema

Custom Field Support: Custom fields can be defined at both category and subcategory levels. Subcategory fields override category fields with the same key, allowing specialization.

Manages supplier information and membership.

Identity:

  • SupplierId: Unique identifier for the supplier

Core Attributes:

final SupplierName name;
final SupplierStatus status; // active or inactive
final Set<UserId> memberUserIds; // Team members
final UserId createdByUserId;
final DateTime createdAt;
final DateTime updatedAt;

Key Methods:

  • Status predicates: isActive, isInactive
  • hasMember(userId): Check membership
  • memberCount: Get team size

The domain publishes events capturing all significant state changes.

CatalogueCreatedEvent Fired when a new catalogue is created.

CatalogueCreatedEvent(
catalogueId: CatalogueId('CAT-001'),
name: CatalogueName('Estate HVAC Equipment'),
description: CatalogueDescription('HVAC specs for main estate'),
ownerEstateId: EstateId('EST-001'),
createdByUserId: UserId('USER-123'),
createdAt: DateTime.now(),
metadata: {'region': 'north', 'type': 'specialized'},
referenceCodePrefix: DomainText('HVAC'),
taxonomyId: TaxonomyId('TAX-HVAC'),
)

CatalogueUpdatedEvent Fired when catalogue properties change (name, description, metadata, ownership, taxonomy).

CatalogueStatusChangedEvent Fired when status transitions (draft ↔ active ↔ archived).

CatalogueReferenceCounterIncrementedEvent Fired when listing reference numbers are generated, incrementing the internal counter.

ListingCreatedEvent Fired when a new listing is created in draft status.

ListingCreatedEvent(
listingId: ListingId('LST-001'),
catalogueId: CatalogueId('CAT-001'),
name: ListingTitle('LED Panel 200W'),
description: ListingDescription('High efficiency LED panel'),
taxonomyRef: TaxonomyReference(
catalogueId: CatalogueId('CAT-001'),
taxonomyId: TaxonomyId('TAX-001'),
categoryId: TaxonomyCategoryId('lighting'),
subCategoryId: TaxonomySubCategoryId('panels'),
),
supplierId: SupplierId('SUP-001'),
createdByUserId: UserId('USER-123'),
isCertified: false,
customFields: CustomFieldMap.fromJson({
'wattage': 200,
'lumens': 2000,
'efficiency_rating': 'A+',
}),
tags: [DomainText('LED'), DomainText('energy-efficient')],
scaleX: 0.6,
scaleY: 0.6,
shape: ListingShape.rectangle,
createdAt: DateTime.now(),
)

ListingStatusChangedEvent Fired when listing status transitions through the approval workflow: draft → submitted → approved/rejected.

ListingCustomFieldsUpdatedEvent Fired when custom field values change.

ListingUpdatedEvent Fired when listing properties change (name, description, pricing, supplier, physical dimensions, etc.).

ListingVisibilityChangedEvent Fired when the isHidden flag toggles.

ListingVersionPublishedEvent Fired when listing is published (automatically on first approval, or manually).

TaxonomyCreatedEvent Fired when a new taxonomy is created.

TaxonomyUpdatedEvent Fired when taxonomy name/description changes.

TaxonomyDeletedEvent Fired when taxonomy is deleted.

TaxonomyCategoryAddedEvent Fired when a category is added to a taxonomy.

TaxonomyCategoryUpdatedEvent Fired when category properties change (name, description, color, icon).

TaxonomyCategoryRemovedEvent Fired when a category is removed.

TaxonomySubCategoryAddedEvent Fired when a subcategory is added to a category.

TaxonomySubCategoryUpdatedEvent Fired when subcategory properties change.

TaxonomySubCategoryRemovedEvent Fired when a subcategory is removed.

TaxonomyCategoryCustomFieldAddedEvent Fired when a custom field is added to a category.

TaxonomySubCategoryCustomFieldAddedEvent Fired when a custom field is added to a subcategory.

TaxonomyCustomFieldUpdatedEvent Fired when a custom field definition changes (name, type, required flag, options, etc.).

TaxonomyCustomFieldRemovedEvent Fired when a custom field is removed.

SupplierCreatedEvent Fired when a new supplier is created.

SupplierMembershipChangedEvent Fired when team members are added to or removed from a supplier.

SupplierStatusChangedEvent Fired when supplier status changes (active ↔ inactive).

Listings follow a strict approval workflow:

draft
↓ (user submits)
submitted
↓ (admin approves OR rejects)
approved ←── rejected
↓ (auto-publish on first approval)
published

Status Details:

  • draft: Initial creation state; editable by creator
  • submitted: Submitted for approval; awaiting admin review
  • approved: Approved by admin; automatically published; can be hidden without losing published state
  • rejected: Rejected during review; can return to draft for revision

Auto-Publishing: On the first status transition to approved, the listing is automatically published. The publishedAtSequence field captures the workspace intent sequence at publication, enabling time-travel queries to retrieve the approved state.

Catalogues and Trackable Assets are complementary domains:

AspectCataloguesTrackable Assets
PurposeDefine asset types and specificationsTrack actual asset instances
LifecycleAdministrative (specs change rarely)Operational (assets move through sites)
OwnershipAdministrative (catalogues managed by ops)User-managed (assets assigned by field teams)
ScopeGlobal templatesPer-estate or per-site instances
Change FrequencyInfrequent (new specs added periodically)Frequent (assets created/updated daily)

Creating Assets from Listings: When a user creates a trackable asset, they:

  1. Select a Catalogue (filters available listings)
  2. Select a Listing from that catalogue (defines asset type and custom fields)
  3. The listing’s custom field schema is inherited by the new asset
  4. A CatalogueReference in the asset tracks which listing was used

Custom Field Inheritance: Assets created from listings inherit the listing’s custom field definitions. If the listing belongs to a taxonomy subcategory, the asset inherits merged custom fields from both the category and subcategory.

Field Extension: After creation, users can add additional custom fields to assets beyond those inherited from the listing.

Directives are command objects that drive state changes. The domain implements directives for all major operations:

  • CreateListingDirective: Create new listing
  • UpdateListingDirective: Update listing properties
  • UpdateListingStatusDirective: Change status (submit/approve/reject)
  • ToggleListingVisibilityDirective: Hide/unhide approved listing
  • PublishListingVersionDirective: Manually publish
  • CreateCatalogueDirective: Create new catalogue
  • UpdateCatalogueDirective: Update catalogue metadata
  • UpdateCatalogueStatusDirective: Change status
  • IncrementCatalogueReferenceCounterDirective: Generate reference numbers
  • CreateTaxonomyDirective: Create taxonomy
  • UpdateTaxonomyDirective: Update taxonomy
  • DeleteTaxonomyDirective: Delete taxonomy
  • AddTaxonomyCategoryDirective: Add category
  • UpdateTaxonomyCategoryDirective: Update category
  • RemoveTaxonomyCategoryDirective: Remove category
  • AddTaxonomySubCategoryDirective: Add subcategory
  • UpdateTaxonomySubCategoryDirective: Update subcategory
  • RemoveTaxonomySubCategoryDirective: Remove subcategory
  • AddTaxonomyCategoryCustomFieldDirective: Add custom field to category
  • AddTaxonomySubCategoryCustomFieldDirective: Add custom field to subcategory
  • UpdateTaxonomyCustomFieldDirective: Update custom field definition
  • RemoveTaxonomyCustomFieldDirective: Remove custom field
  • CreateSupplierDirective: Create supplier
  • ChangeSupplierStatusDirective: Change supplier status
  • ChangeSupplierMembershipDirective: Add/remove team members
import 'package:catalogues_v1/catalogues_v1.dart';
import 'package:nomos_core/nomos_core.dart';
import 'package:contracts_v1/contracts_v1.dart';
// Register domain types
registerCataloguesV1();
// Create a new catalogue
final engine = TestDirectiveEngine();
final payload = CreateCataloguePayload(
catalogueId: CatalogueId('CAT-HVAC-001'),
name: CatalogueName('HVAC Equipment Catalogue'),
description: CatalogueDescription('All HVAC equipment specifications'),
ownerEstateId: EstateId('EST-001'),
createdByUserId: UserId('USER-ADMIN'),
referenceCodePrefix: DomainText('HVAC'),
taxonomyId: TaxonomyId('TAX-HVAC'),
metadata: {'region': 'north', 'version': '2024-01-01'},
);
final result = await engine.execute<CatalogueAggregate, CreateCataloguePayload>(
directive: CreateCatalogueDirective(payload: payload),
timelineId: const NomosTimelineId('main'),
workspaceId: CatalogueWorkspace(CatalogueId('CAT-HVAC-001')).toNomos,
);
final catalogue = result.updatedAggregate;
print('Created catalogue: ${catalogue.name.value}');
import 'package:catalogues_v1/catalogues_v1.dart';
final payload = CreateListingPayload(
listingId: ListingId.fromString('LST-LED-001'),
catalogueId: CatalogueId('CAT-HVAC-001'),
name: const ListingTitle('LED Panel 200W'),
description: const ListingDescription('High efficiency LED panel for indoor use'),
taxonomyRef: TaxonomyReference(
catalogueId: CatalogueId('CAT-HVAC-001'),
taxonomyId: TaxonomyId('TAX-HVAC'),
categoryId: TaxonomyCategoryId('lighting'),
),
supplierId: SupplierId.fromString('SUP-LED-CORP'),
createdByUserId: UserId.fromString('USER-001'),
isCertified: false,
customFields: CustomFieldMap.fromJson({
'wattage': 200,
'lumens': 2000,
'color_temp_k': 4000,
}),
tags: [DomainText('LED'), DomainText('energy-efficient')],
scaleX: 0.6, // 60cm width
scaleY: 0.6, // 60cm height
shape: ListingShape.rectangle,
);
final directive = CreateListingDirective(payload: payload);
final result = await engine.execute<ListingAggregate, CreateListingPayload>(
directive: directive,
timelineId: const NomosTimelineId('main'),
workspaceId: CatalogueWorkspace(CatalogueId('CAT-HVAC-001')).toNomos,
);
final listing = result.updatedAggregate;
print('Created listing: ${listing.name.value}');
final taxonomyPayload = CreateTaxonomyPayload(
taxonomyId: TaxonomyId('TAX-HVAC'),
name: const TaxonomyName('HVAC Equipment Classification'),
description: const TaxonomyDescription('Hierarchical classification for HVAC equipment'),
createdByUserId: UserId.fromString('USER-ADMIN'),
);
final taxonomyResult = await engine.execute<TaxonomyAggregate, CreateTaxonomyPayload>(
directive: CreateTaxonomyDirective(payload: taxonomyPayload),
timelineId: CatalogueWorkspace.defaultTimeline,
workspaceId: CatalogueWorkspace(CatalogueId('CAT-HVAC-001')).toNomos,
);
// Add a category to the taxonomy
final categoryPayload = AddTaxonomyCategoryPayload(
taxonomyId: TaxonomyId('TAX-HVAC'),
categoryId: TaxonomyCategoryId('air-handling'),
name: const TaxonomyCategoryName('Air Handling Units'),
description: const TaxonomyCategoryDescription('Equipment for air circulation'),
icon: 'air',
color: TaxonomyColor.fromHex('#FF5722'),
addedByUserId: UserId.fromString('USER-ADMIN'),
);
await engine.execute<TaxonomyAggregate, AddTaxonomyCategoryPayload>(
directive: AddTaxonomyCategoryDirective(payload: categoryPayload),
timelineId: CatalogueWorkspace.defaultTimeline,
workspaceId: CatalogueWorkspace(CatalogueId('CAT-HVAC-001')).toNomos,
);
// Add custom fields to the category
final fieldPayload = AddTaxonomyCategoryCustomFieldPayload(
taxonomyId: TaxonomyId('TAX-HVAC'),
categoryId: TaxonomyCategoryId('air-handling'),
customField: CustomFieldDefinition(
fieldId: CustomFieldKey.fromString('airflow_capacity'),
name: 'Airflow Capacity',
fieldType: CustomFieldType.number,
isRequired: true,
description: 'CFM (Cubic Feet per Minute)',
),
addedByUserId: UserId.fromString('USER-ADMIN'),
);
await engine.execute<TaxonomyAggregate, AddTaxonomyCategoryCustomFieldPayload>(
directive: AddTaxonomyCategoryCustomFieldDirective(payload: fieldPayload),
timelineId: CatalogueWorkspace.defaultTimeline,
workspaceId: CatalogueWorkspace(CatalogueId('CAT-HVAC-001')).toNomos,
);

Validating Custom Fields Against Taxonomy Schema

Section titled “Validating Custom Fields Against Taxonomy Schema”
// Get the taxonomy aggregate
final taxonomy = /* retrieved from storage */;
// Get the schema for a specific category/subcategory
final schema = taxonomy.getCustomFieldSchema(
TaxonomyCategoryId('air-handling'),
null, // No subcategory in this example
);
// Create custom field values for a listing
final customFields = CustomFieldMap.fromJson({
'airflow_capacity': 5000,
'noise_level': 65,
'efficiency_rating': 'A',
});
// Validate against schema
final errors = taxonomy.validateCustomFields(
TaxonomyCategoryId('air-handling'),
null,
customFields,
);
if (errors.isNotEmpty) {
print('Validation errors: $errors');
} else {
print('Custom fields are valid');
}
// Submit the listing for approval
final submitPayload = UpdateListingStatusPayload(
listingId: ListingId.fromString('LST-LED-001'),
newStatus: ListingStatus.submitted,
changedByUserId: UserId.fromString('USER-001'),
);
await engine.execute<ListingAggregate, UpdateListingStatusPayload>(
directive: UpdateListingStatusDirective(payload: submitPayload),
timelineId: const NomosTimelineId('main'),
workspaceId: CatalogueWorkspace(CatalogueId('CAT-HVAC-001')).toNomos,
);
// Admin approves the listing (triggers auto-publish)
final approvePayload = UpdateListingStatusPayload(
listingId: ListingId.fromString('LST-LED-001'),
newStatus: ListingStatus.approved,
changedByUserId: UserId.fromString('USER-ADMIN'),
);
final result = await engine.execute<ListingAggregate, UpdateListingStatusPayload>(
directive: UpdateListingStatusDirective(payload: approvePayload),
timelineId: const NomosTimelineId('main'),
workspaceId: CatalogueWorkspace(CatalogueId('CAT-HVAC-001')).toNomos,
);
final listing = result.updatedAggregate;
// listing.isApproved == true
// listing.publishedAtSequence != null (auto-published)

The domain depends on:

  • nomos_core: Core event sourcing and DDD infrastructure
  • contracts_v1: Shared value objects and contracts (IDs, enums, etc.)

The domain exports all aggregates, events, directives, and value objects for use by applications and other domains.

Catalogues use the CatalogueWorkspace pattern for organizing data:

// Each catalogue has its own workspace for storing listings
final workspace = CatalogueWorkspace(catalogueId).toNomos;
// Listings are queried using this workspace
final listings = await nomos.watchQuery<ListingAggregate>(
workspace,
timelineId,
null, // Query all listings in this catalogue
);

This allows efficient querying of all listings within a catalogue without scanning global data.

  1. Listing Auto-Publishing: On first approval, listings are automatically published and a sequence number is captured. This enables time-travel queries to retrieve the approved snapshot.

  2. Custom Field Merging: Taxonomy subcategories can override category-level custom fields. The getCustomFieldSchema() method handles merging with subcategory fields taking precedence.

  3. Ownership Constraints: A catalogue cannot be owned by both an estate and supplier. Global catalogues have no owner. This prevents ambiguous access control.

  4. Reference Code Generation: Catalogues provide a prefix and counter mechanism for generating human-readable reference codes (e.g., “HVAC-001”).

  5. Supplier Agnostic Listings: While listings can reference a supplier, they are not exclusive to that supplier. Suppliers are tracked for audit and business intelligence.

  6. Visibility Toggle: Approved listings can be hidden without losing their published state or custom field definitions. This allows temporary removal from UI without resetting the approval workflow.

The domain integrates with:

  • Trackable Assets Domain: Assets inherit custom fields and metadata from listings
  • Estate Structures Domain: Catalogues can be owned by estates
  • Identity Domain: User audit trails (createdBy, updatedBy)
  • Attachments Domain: Listings can reference thumbnail images
  • Admin Frontend: UI for catalogue and listing management

Each aggregate enforces business rules during state transitions:

CatalogueAggregate:

  • Catalogue ID must be resolved (not unresolved)
  • Name and description must be non-empty
  • Cannot be owned by both estate and supplier
  • Status must be valid (draft, active, archived)

ListingAggregate:

  • Listing ID and catalogue ID must be resolved
  • Name, description, and taxonomy reference required
  • Scale dimensions (scaleX, scaleY) must be positive
  • Custom fields must conform to taxonomy schema (if applicable)

TaxonomyAggregate:

  • Taxonomy ID must be resolved
  • Name must be non-empty
  • All categories must pass internal validation
  • Custom field definitions must be unique within category/subcategory