Contracts Package (contracts_v1)
The contracts_v1 package provides the foundational contracts that enable communication and data sharing across all domains in the CO2 target asset management system. It contains strongly-typed value objects, domain identifiers, and cross-domain payloads that prevent coupling between bounded contexts.
Overview
Section titled “Overview”The contracts package serves as the lingua franca of the system, defining:
- Domain Identifiers: Type-safe replacements for string IDs (UserId, EstateId, TrackableAssetId, etc.)
- Value Objects: Immutable, semantically meaningful data containers (Money, Distance, CustomField, etc.)
- Domain Names & Descriptions: Specialized text types for different entity names (EstateName, SiteName, etc.)
- Enumerations: Strongly-typed enums for status values, permissions, roles, and resource types
- Cross-Domain Payloads: Structured data for inter-domain events and commands
- JSON Type Aliases: Type-safe alternatives to
dynamicfor JSON documents
Package Location
Section titled “Package Location”dart_packages/co2/contracts/contracts_v1/├── lib/│ ├── contracts_v1.dart # Main export file│ └── src/│ ├── value_objects/ # All value objects and identifiers│ ├── payloads/ # Cross-domain communication payloads│ ├── shared_types.dart # JSON type aliases│ └── change/ # Change event models├── pubspec.yaml└── test/Core Principles
Section titled “Core Principles”1. Strong Typing Over Stringly-Typed Systems
Section titled “1. Strong Typing Over Stringly-Typed Systems”Instead of passing raw strings around, the contracts package enforces compile-time type safety:
// Before: error-prone and unclearvoid updateAsset(String assetId, String estateId) { }
// After: type-safe and self-documentingvoid updateAsset(TrackableAssetId assetId, EstateId estateId) { }2. Unresolved Value Handling
Section titled “2. Unresolved Value Handling”For optional or nullable identifiers, the package provides special unresolved constants:
// Instead of null/empty string, use explicit unresolved valuesfinal assetId = TrackableAssetId.unresolved;final estateId = EstateId.unresolved;final userId = UserId.unresolved;
// Special case: UserId also has a system valuefinal systemUser = UserId.system;3. Zero Cross-Domain Dependencies
Section titled “3. Zero Cross-Domain Dependencies”Payloads and value objects avoid importing domain packages to prevent circular dependencies. String representations are used for IDs when crossing domain boundaries.
4. Immutability
Section titled “4. Immutability”All value objects are immutable and use const constructors where possible for efficient comparison and storage.
Key Value Objects
Section titled “Key Value Objects”Domain Identifiers
Section titled “Domain Identifiers”These strongly-typed identifiers replace raw strings throughout the system:
| Identifier | Purpose | Unresolved Value |
|---|---|---|
UserId | User identity across all domains | UserId.unresolved, UserId.system |
TrackableAssetId | Asset reference across domains | TrackableAssetId.unresolved |
EstateId | Estate/workspace identifier | EstateId.unresolved |
SiteId | Site within an estate | SiteId.unresolved |
BuildingId | Building within a site | BuildingId.unresolved |
RoomId | Room within a building | RoomId.unresolved |
LayerId | Map layer identifier | LayerId.unresolved |
OrganizationId | Organization identifier | OrganizationId.unresolved |
AttachmentId | Attachment/file reference | N/A |
FolderId | Folder in attachments | N/A |
FileId | File identifier | N/A |
Creating and Converting Identifiers
Section titled “Creating and Converting Identifiers”All identifier types inherit from DomainText and support creation from strings:
import 'package:contracts_v1/contracts_v1.dart';
// Direct creationfinal assetId = TrackableAssetId('asset-123');final estateId = EstateId('estate-456');
// Safe parsing from untrusted stringsfinal userId = UserId.fromString('user-789');
// Unresolved placeholdersif (assetId == TrackableAssetId.unresolved) { print('Asset ID not yet determined');}
// Conversion to/from JSONfinal json = {'assetId': assetId.value};final roundtrip = TrackableAssetId.fromString(json['assetId'] as String);Estate Structures Hierarchy
Section titled “Estate Structures Hierarchy”The BuildingLevelLocation value object represents a specific floor within a building:
import 'package:contracts_v1/contracts_v1.dart';
// Create a location for a specific building levelfinal location = BuildingLevelLocation( buildingId: BuildingId('bldg-001'), level: BuildingLevel.fromLevel(3), // 3rd floor);
// Composite key format (for legacy migration)final compositeKey = location.toCompositeKey(); // "bldg-001:3"final restored = BuildingLevelLocation.fromCompositeKey(compositeKey);
// JSON serializationfinal json = location.toJson();final deserialized = BuildingLevelLocation.fromJson(json);Money and Currency
Section titled “Money and Currency”The Money value object provides type-safe monetary calculations and avoids floating-point precision issues by storing amounts as integers in minor units (cents):
import 'package:contracts_v1/contracts_v1.dart';
// Create from decimal amountsfinal price = Money(19.99, CurrencyCode.eur);final budget = Money.usd(5000.00);
// Access amount in different formatsprint(price.amount); // 19.99 (as double)print(price.amountInMinorUnits); // 1999 (cents)print(price.displayLabel); // "€19.99"
// Currency code constants for common currenciesfinal aud = Money.aud(100.00);final gbp = Money.gbp(50.00);
// Type-safe arithmetic (currencies must match)final total = price + budget; // OKfinal product = price * 2; // OKfinal invalid = price + aud; // ArgumentError: different currencies
// Comparisonsif (price > Money.zero(CurrencyCode.eur)) { print('Price is positive');}
// Create from minor units directlyfinal fromCents = Money.fromMinorUnits(1999, CurrencyCode.eur);assert(fromCents == price);Supported currency codes: AUD, USD, EUR, GBP, NZD, CAD, CHF, JPY, CNY, INR (with custom code support).
Custom Fields
Section titled “Custom Fields”The custom field system provides flexible, type-safe field definitions and values:
import 'package:contracts_v1/contracts_v1.dart';
// Define a custom fieldfinal nameField = CustomField.text( CustomFieldKey('asset_name'), 'HVAC Unit A', source: CustomFieldSource.userAdded,);
// Create fields of different typesfinal ageField = CustomField.number( CustomFieldKey('age_years'), 5,);
final activeField = CustomField.boolean( CustomFieldKey('is_active'), true,);
final costField = CustomField.money( CustomFieldKey('replacement_cost'), Money(15000.00, CurrencyCode.usd),);
final tagsField = CustomField.multiSelect( CustomFieldKey('asset_tags'), ['hvac', 'critical', 'old'],);
final dueDateField = CustomField.date( CustomFieldKey('maintenance_due'), DateTime(2025, 6, 15),);
// Type-safe value accessorsif (nameField.textValue != null) { print('Asset name: ${nameField.textValue}');}
if (costField.moneyValue?.isPositive ?? false) { print('Cost: ${costField.moneyValue?.displayLabel}');}
// Create from JSONfinal json = { 'key': 'asset_name', 'fieldType': 'text', 'value': 'HVAC Unit A', 'source': 'userAdded',};final restored = CustomField.fromJson(json);
// Serialize to JSONfinal serialized = nameField.toJson();Custom Field Types
Section titled “Custom Field Types”Supported field types with their value types:
| Field Type | Value Type | Example |
|---|---|---|
text | TextFieldValue | ”Asset description” |
number | NumberFieldValue | 42, 3.14 |
boolean | BooleanFieldValue | true, false |
percentage | PercentageFieldValue | 75 (clamped 0-100) |
date | DateFieldValue | DateTime(2025, 1, 1) |
select | SelectFieldValue | ”option_a” |
multiSelect | MultiSelectFieldValue | [“opt1”, “opt2”] |
attachment | AttachmentFieldValue | AttachmentReference(…) |
attachmentList | AttachmentListFieldValue | [ref1, ref2] |
money | MoneyFieldValue | Money(1000, CurrencyCode.usd) |
Custom Field Sources
Section titled “Custom Field Sources”Fields can originate from different sources:
enum CustomFieldSource { taxonomy, // From category/subcategory definition listing, // From catalogue listing userAdded, // Manually added by user}Distance
Section titled “Distance”Represents measurements with units:
import 'package:contracts_v1/contracts_v1.dart';
final distance = Distance(100.5, DistanceUnit.meters);final altitudeMeters = Distance(1500.0, DistanceUnit.meters);Domain Names and Descriptions
Section titled “Domain Names and Descriptions”Specialized text value objects for specific entity names and descriptions:
import 'package:contracts_v1/contracts_v1.dart';
// Names - all support unresolved valuesfinal estateName = EstateName('Main Office Complex');final siteName = SiteName('Building A');final buildingName = BuildingName('North Wing');final roomName = RoomName('Conference Room 3');final layerName = LayerName('Electrical Systems');final catalogueName = CatalogueName('HVAC Equipment');final listingTitle = ListingTitle('Standard Chiller Unit');
// Descriptionsfinal siteDesc = SiteDescription('Primary manufacturing facility');final roomDesc = RoomDescription('Large meeting space with AV equipment');
// Factory constructors handle null/empty gracefullyfinal safeName = EstateName.fromString(null); // EstateName.unresolvedfinal trimmed = SiteName.fromString(' spaces '); // SiteName('spaces')
// All support equality and JSON serializationif (estateName == EstateName('Main Office Complex')) { print('Same name');}
final json = estateName.toJson(); // {'value': 'Main Office Complex'}Roles and Permissions
Section titled “Roles and Permissions”The Role value object enforces type-safe permission hierarchies:
import 'package:contracts_v1/contracts_v1.dart';
// Predefined roles for different resource typesfinal estateOwner = Role.estateOwner; // Estate-level ownerfinal siteAdmin = Role.siteAdmin; // Site-level adminfinal layerWrite = Role.layerWrite; // Can write to a layerfinal featureRead = Role.featureRead; // Can read features
// Permission hierarchy within each typefinal hierarchy = Role.estateRead.hasMinimumPermission(Role.estateAdmin);// false - read is below admin in hierarchy
// Role delegation (only admins/owners can grant)if (estateOwner.canGrant(Role.estateWrite)) { print('Owner can delegate write role'); // true}
// Cannot grant roles of different typesif (Role.estateWrite.canGrant(Role.siteRead)) { print('This will not print'); // false - different types}
// Predefined role sets by type// Estate: read, write, admin, owner// Site: read, write, admin// Layer: none, read, write, admin// Feature: none, read, write, admin// Catalogue: read, write, admin
// Parsing and JSON serializationfinal roleFromString = Role.fromString('write', RoleType.site);final json = roleFromString.toJson();final restored = Role.fromJson(json);Attachment References
Section titled “Attachment References”Link to uploaded files and documents:
import 'package:contracts_v1/contracts_v1.dart';
// Create a reference to an attachmentfinal attachmentRef = AttachmentReference( attachmentId: AttachmentId('attach-001'),);
// JSON serializationfinal json = attachmentRef.toJson();final restored = AttachmentReference.fromJson(json);
// Use in custom fieldsfinal field = CustomField.attachment( CustomFieldKey('supporting_docs'), attachmentRef,);Cross-Domain Payloads
Section titled “Cross-Domain Payloads”Payloads enable communication between domains without creating dependencies. They use string IDs instead of domain-specific types to avoid circular imports.
AssetMovePayload
Section titled “AssetMovePayload”Represents moving an asset to a new geographic location:
import 'package:contracts_v1/contracts_v1.dart';
final payload = AssetMovePayload( assetId: 'asset-123', latitude: -33.8688, longitude: 151.2093, altitudeMeters: 100.5, horizontalAccuracyMeters: 5.0,);
// Serialize for inter-domain eventsfinal json = payload.toJson();
// Deserializefinal restored = AssetMovePayload.fromJson(json);ApprovalWorkflowPayload
Section titled “ApprovalWorkflowPayload”Represents approval workflow state across domains:
import 'package:contracts_v1/contracts_v1.dart';
// Structured approval datafinal approvalData = ApprovalData( approvalSteps: [ 'manager_review', 'director_approval', 'financial_sign_off', ], currentStep: 'director_approval', approversAssigned: true,);
final payload = ApprovalWorkflowPayload( workflowId: 'workflow-001', status: 'in_progress', approvalData: approvalData,);
final json = payload.toJson();final restored = ApprovalWorkflowPayload.fromJson(json);FileAttachmentPayload
Section titled “FileAttachmentPayload”References files uploaded through the attachments domain:
import 'package:contracts_v1/contracts_v1.dart';
final payload = FileAttachmentPayload( fileId: 'file-456', fileName: 'maintenance_report.pdf', mimeType: 'application/pdf', sizeBytes: 2048576, uploadedBy: 'user-789',);
final json = payload.toJson();final restored = FileAttachmentPayload.fromJson(json);LocationPayload
Section titled “LocationPayload”Represents geographic/structural location information:
import 'package:contracts_v1/contracts_v1.dart';
final payload = LocationPayload( estateId: 'estate-001', siteId: 'site-001', buildingId: 'bldg-001', floorLevel: 3, roomId: 'room-301',);
final json = payload.toJson();final restored = LocationPayload.fromJson(json);ResponsibilityAssignmentPayload
Section titled “ResponsibilityAssignmentPayload”Assigns responsibilities to users or roles:
import 'package:contracts_v1/contracts_v1.dart';
final payload = ResponsibilityAssignmentPayload( responsibilityId: 'resp-001', assignedTo: 'user-789', assignedBy: 'user-001', dueDate: DateTime(2025, 3, 15), priority: 'high',);
final json = payload.toJson();final restored = ResponsibilityAssignmentPayload.fromJson(json);NotificationPayload
Section titled “NotificationPayload”Delivers notifications across domains:
import 'package:contracts_v1/contracts_v1.dart';
final payload = NotificationPayload( recipientId: 'user-789', title: NotificationTitle('Approval Required'), message: NotificationMessage('Your review is pending'), type: NotificationType.approval, relatedEntityId: 'proposal-001',);
final json = payload.toJson();final restored = NotificationPayload.fromJson(json);PendingInvitation
Section titled “PendingInvitation”Manages pending invitations across domains:
import 'package:contracts_v1/contracts_v1.dart';
final invitation = PendingInvitation( invitationId: 'inv-001', invitedEmail: 'newuser@example.com', invitedBy: 'user-001', status: InvitationStatus.pending, expiresAt: DateTime.now().add(Duration(days: 7)),);
final json = invitation.toJson();final restored = PendingInvitation.fromJson(json);LayerPayload
Section titled “LayerPayload”Communicates topology layer changes:
import 'package:contracts_v1/contracts_v1.dart';
final payload = LayerPayload( layerId: 'layer-001', name: 'Electrical Systems', siteId: 'site-001', isVisible: true, order: 1,);
final json = payload.toJson();final restored = LayerPayload.fromJson(json);TrackableAssetReplacementSet
Section titled “TrackableAssetReplacementSet”Bundles asset replacements across domains:
import 'package:contracts_v1/contracts_v1.dart';
final replacementSet = TrackableAssetReplacementSet( assetToRemoveId: 'asset-old-123', assetToAddIds: ['asset-new-1', 'asset-new-2'], replacedBy: 'user-789', timestamp: DateTime.now(),);
final json = replacementSet.toJson();final restored = TrackableAssetReplacementSet.fromJson(json);Type-Safe JSON Handling
Section titled “Type-Safe JSON Handling”The package provides semantic type aliases to replace dynamic:
import 'package:contracts_v1/contracts_v1.dart';
// Semantic aliases for JSON typestypedef JsonMap = Map<String, Object?>; // Generic JSON objecttypedef JsonArray = List<Object?>; // Generic JSON arraytypedef Metadata = Map<String, Object?>; // Domain metadata
// Usagefinal data = JsonMap({'key': 'value', 'nested': 42});final metadata = Metadata({'created': '2025-01-18', 'version': 1});final items = JsonArray(['a', 'b', 42, null]);
// Still JSON-compatible, but types are clearervoid processMetadata(Metadata meta) { // No dynamic type - keys known, values guarded}Nomos Integration
Section titled “Nomos Integration”For event sourcing with Nomos, the package provides conversion utilities:
import 'package:contracts_v1/contracts_v1.dart';
// Convert between CO2 and Nomos value objectsfinal estateId = EstateId('estate-001');final nomosWorkspaceId = NomosConversionUtils.estateIdToWorkspaceId(estateId);
final userId = UserId('user-789');final nomosActorId = NomosConversionUtils.userIdToActorId(userId);
// Reverse conversionsfinal backToEstateId = NomosConversionUtils.workspaceIdToEstateId(nomosWorkspaceId);final backToUserId = NomosConversionUtils.actorIdToUserId(nomosActorId);Enumerations
Section titled “Enumerations”The package includes domain-specific enumerations for common statuses and types:
import 'package:contracts_v1/contracts_v1.dart';
// Examples of available enumsenum UserStatus { active, inactive, suspended }enum InvitationStatus { pending, accepted, rejected, expired }enum PermissionStatus { granted, denied, pending }enum ListingStatus { draft, active, archived }enum CatalogueStatus { draft, published, archived }enum SupplierStatus { active, inactive, archived }enum FundingApplicationStatus { draft, submitted, approved, rejected }enum ProposalStatus { draft, submitted, approved, rejected, implemented }enum InsightType { recommendation, alert, analysis }enum ClaimStatus { draft, submitted, approved, rejected }enum VerificationOutcome { approved, failed, conditional }enum EnergyCalculationType { actual, estimated, extrapolated }
// Enum usagefinal userStatus = UserStatus.active;if (userStatus != UserStatus.suspended) { print('User is active');}Change Event Models
Section titled “Change Event Models”The package includes models for tracking domain changes:
import 'package:contracts_v1/contracts_v1.dart';
// See change/change_models.dart for detailed change tracking structures// Used by Nomos for event sourcingBest Practices
Section titled “Best Practices”1. Always Use Value Objects Instead of Strings
Section titled “1. Always Use Value Objects Instead of Strings”// Badvoid updateAsset(String assetId) { }
// Goodvoid updateAsset(TrackableAssetId assetId) { }2. Use Typed Factories for Custom Fields
Section titled “2. Use Typed Factories for Custom Fields”// Badfinal field = CustomField( key: CustomFieldKey('cost'), fieldType: CustomFieldType.money, value: MoneyFieldValue(Money(1000, CurrencyCode.usd)),);
// Goodfinal field = CustomField.money( CustomFieldKey('cost'), Money(1000, CurrencyCode.usd),);3. Handle Unresolved Values Explicitly
Section titled “3. Handle Unresolved Values Explicitly”// Check for unresolvedif (assetId == TrackableAssetId.unresolved) { throw StateError('Asset ID must be resolved before proceeding');}
// Use unresolved as placeholdersfinal pending = TrackableAssetId.unresolved;4. Leverage Money for Financial Calculations
Section titled “4. Leverage Money for Financial Calculations”// Correct: avoids floating-point precision issuesfinal total = Money(100.00, CurrencyCode.usd) + Money(50.00, CurrencyCode.usd);
// Avoid: manual calculation with doublesfinal wrong = 100.0 + 50.0; // Floating-point arithmetic5. Use Metadata Type for Flexible Data
Section titled “5. Use Metadata Type for Flexible Data”final metadata = Metadata({ 'created': '2025-01-18', 'modified': '2025-01-20', 'tags': ['important', 'reviewed'], 'score': 85,});Integration with Other Packages
Section titled “Integration with Other Packages”The contracts package is consumed by all domain packages:
- estate_structures_v1: Uses EstateId, SiteId, BuildingId, LocationPayload
- trackable_asset_v1: Uses TrackableAssetId, AssetLocationRef, CustomField
- attachments_v1: Uses AttachmentId, FileAttachmentPayload
- permissions_v1: Uses UserId, Role, PermissionStatus
- catalogues_v1: Uses CatalogueId, ListingStatus, CatalogueReference
- identity_v1: Uses UserId, OrganizationId, UserProfile
- funding_programs_v1: Uses FundingApplicationId, Money, FundingApplicationStatus
Testing
Section titled “Testing”When testing code that uses contracts:
import 'package:contracts_v1/contracts_v1.dart';import 'package:test/test.dart';
void main() { test('EstateId creates type-safe identifiers', () { final estateId = EstateId('test-estate-1'); expect(estateId.value, equals('test-estate-1')); expect(estateId.toString(), contains('EstateId')); });
test('Money prevents operations on different currencies', () { final usd = Money(100, CurrencyCode.usd); final eur = Money(100, CurrencyCode.eur);
expect( () => usd + eur, throwsA(isA<ArgumentError>()), ); });
test('CustomField.text creates type-safe text fields', () { final field = CustomField.text( CustomFieldKey('name'), 'Test Value', );
expect(field.textValue, equals('Test Value')); expect(field.numberValue, isNull); });
test('Role.canGrant enforces permission delegation rules', () { final admin = Role.estateAdmin; final write = Role.estateWrite;
expect(admin.canGrant(write), isTrue); expect(write.canGrant(admin), isFalse); });}Documentation References
Section titled “Documentation References”- Nomos Integration: See
nomos_corepackage for event sourcing integration - Domain Models: Check individual domain packages for aggregate designs
- API Reference: Inline documentation in source files provides detailed method signatures
Version Information
Section titled “Version Information”- Package Version: 1.0.0
- Dart SDK:
>=3.6.0 <4.0.0 - Key Dependency:
nomos_corefor event sourcing primitives
Migration Notes
Section titled “Migration Notes”From String-Based IDs
Section titled “From String-Based IDs”If migrating from string-based IDs:
// Old codeString assetId = 'asset-123';
// New codeTrackableAssetId assetId = TrackableAssetId.fromString('asset-123');
// Update type signaturesvoid processAsset(String assetId); // Oldvoid processAsset(TrackableAssetId assetId); // NewFrom Dynamic JSON to Typed Contracts
Section titled “From Dynamic JSON to Typed Contracts”// Old: dynamic, error-pronefinal data = json['price'] as dynamic;
// New: use Money value objectfinal price = Money.fromJson(json['price']);Custom Fields Type Safety
Section titled “Custom Fields Type Safety”// Old: untypedfinal value = customField['value'];
// New: type-safe accessorsfinal value = customField.textValue; // Returns String?final money = customField.moneyValue; // Returns Money?