Skip to content

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.

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 dynamic for JSON documents
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/

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 unclear
void updateAsset(String assetId, String estateId) { }
// After: type-safe and self-documenting
void updateAsset(TrackableAssetId assetId, EstateId estateId) { }

For optional or nullable identifiers, the package provides special unresolved constants:

// Instead of null/empty string, use explicit unresolved values
final assetId = TrackableAssetId.unresolved;
final estateId = EstateId.unresolved;
final userId = UserId.unresolved;
// Special case: UserId also has a system value
final systemUser = UserId.system;

Payloads and value objects avoid importing domain packages to prevent circular dependencies. String representations are used for IDs when crossing domain boundaries.

All value objects are immutable and use const constructors where possible for efficient comparison and storage.

These strongly-typed identifiers replace raw strings throughout the system:

IdentifierPurposeUnresolved Value
UserIdUser identity across all domainsUserId.unresolved, UserId.system
TrackableAssetIdAsset reference across domainsTrackableAssetId.unresolved
EstateIdEstate/workspace identifierEstateId.unresolved
SiteIdSite within an estateSiteId.unresolved
BuildingIdBuilding within a siteBuildingId.unresolved
RoomIdRoom within a buildingRoomId.unresolved
LayerIdMap layer identifierLayerId.unresolved
OrganizationIdOrganization identifierOrganizationId.unresolved
AttachmentIdAttachment/file referenceN/A
FolderIdFolder in attachmentsN/A
FileIdFile identifierN/A

All identifier types inherit from DomainText and support creation from strings:

import 'package:contracts_v1/contracts_v1.dart';
// Direct creation
final assetId = TrackableAssetId('asset-123');
final estateId = EstateId('estate-456');
// Safe parsing from untrusted strings
final userId = UserId.fromString('user-789');
// Unresolved placeholders
if (assetId == TrackableAssetId.unresolved) {
print('Asset ID not yet determined');
}
// Conversion to/from JSON
final json = {'assetId': assetId.value};
final roundtrip = TrackableAssetId.fromString(json['assetId'] as String);

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 level
final 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 serialization
final json = location.toJson();
final deserialized = BuildingLevelLocation.fromJson(json);

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 amounts
final price = Money(19.99, CurrencyCode.eur);
final budget = Money.usd(5000.00);
// Access amount in different formats
print(price.amount); // 19.99 (as double)
print(price.amountInMinorUnits); // 1999 (cents)
print(price.displayLabel); // "€19.99"
// Currency code constants for common currencies
final aud = Money.aud(100.00);
final gbp = Money.gbp(50.00);
// Type-safe arithmetic (currencies must match)
final total = price + budget; // OK
final product = price * 2; // OK
final invalid = price + aud; // ArgumentError: different currencies
// Comparisons
if (price > Money.zero(CurrencyCode.eur)) {
print('Price is positive');
}
// Create from minor units directly
final 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).

The custom field system provides flexible, type-safe field definitions and values:

import 'package:contracts_v1/contracts_v1.dart';
// Define a custom field
final nameField = CustomField.text(
CustomFieldKey('asset_name'),
'HVAC Unit A',
source: CustomFieldSource.userAdded,
);
// Create fields of different types
final 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 accessors
if (nameField.textValue != null) {
print('Asset name: ${nameField.textValue}');
}
if (costField.moneyValue?.isPositive ?? false) {
print('Cost: ${costField.moneyValue?.displayLabel}');
}
// Create from JSON
final json = {
'key': 'asset_name',
'fieldType': 'text',
'value': 'HVAC Unit A',
'source': 'userAdded',
};
final restored = CustomField.fromJson(json);
// Serialize to JSON
final serialized = nameField.toJson();

Supported field types with their value types:

Field TypeValue TypeExample
textTextFieldValue”Asset description”
numberNumberFieldValue42, 3.14
booleanBooleanFieldValuetrue, false
percentagePercentageFieldValue75 (clamped 0-100)
dateDateFieldValueDateTime(2025, 1, 1)
selectSelectFieldValue”option_a”
multiSelectMultiSelectFieldValue[“opt1”, “opt2”]
attachmentAttachmentFieldValueAttachmentReference(…)
attachmentListAttachmentListFieldValue[ref1, ref2]
moneyMoneyFieldValueMoney(1000, CurrencyCode.usd)

Fields can originate from different sources:

enum CustomFieldSource {
taxonomy, // From category/subcategory definition
listing, // From catalogue listing
userAdded, // Manually added by user
}

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);

Specialized text value objects for specific entity names and descriptions:

import 'package:contracts_v1/contracts_v1.dart';
// Names - all support unresolved values
final 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');
// Descriptions
final siteDesc = SiteDescription('Primary manufacturing facility');
final roomDesc = RoomDescription('Large meeting space with AV equipment');
// Factory constructors handle null/empty gracefully
final safeName = EstateName.fromString(null); // EstateName.unresolved
final trimmed = SiteName.fromString(' spaces '); // SiteName('spaces')
// All support equality and JSON serialization
if (estateName == EstateName('Main Office Complex')) {
print('Same name');
}
final json = estateName.toJson(); // {'value': 'Main Office Complex'}

The Role value object enforces type-safe permission hierarchies:

import 'package:contracts_v1/contracts_v1.dart';
// Predefined roles for different resource types
final estateOwner = Role.estateOwner; // Estate-level owner
final siteAdmin = Role.siteAdmin; // Site-level admin
final layerWrite = Role.layerWrite; // Can write to a layer
final featureRead = Role.featureRead; // Can read features
// Permission hierarchy within each type
final 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 types
if (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 serialization
final roleFromString = Role.fromString('write', RoleType.site);
final json = roleFromString.toJson();
final restored = Role.fromJson(json);

Link to uploaded files and documents:

import 'package:contracts_v1/contracts_v1.dart';
// Create a reference to an attachment
final attachmentRef = AttachmentReference(
attachmentId: AttachmentId('attach-001'),
);
// JSON serialization
final json = attachmentRef.toJson();
final restored = AttachmentReference.fromJson(json);
// Use in custom fields
final field = CustomField.attachment(
CustomFieldKey('supporting_docs'),
attachmentRef,
);

Payloads enable communication between domains without creating dependencies. They use string IDs instead of domain-specific types to avoid circular imports.

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 events
final json = payload.toJson();
// Deserialize
final restored = AssetMovePayload.fromJson(json);

Represents approval workflow state across domains:

import 'package:contracts_v1/contracts_v1.dart';
// Structured approval data
final 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);

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);

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);

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);

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);

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);

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);

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);

The package provides semantic type aliases to replace dynamic:

import 'package:contracts_v1/contracts_v1.dart';
// Semantic aliases for JSON types
typedef JsonMap = Map<String, Object?>; // Generic JSON object
typedef JsonArray = List<Object?>; // Generic JSON array
typedef Metadata = Map<String, Object?>; // Domain metadata
// Usage
final 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 clearer
void processMetadata(Metadata meta) {
// No dynamic type - keys known, values guarded
}

For event sourcing with Nomos, the package provides conversion utilities:

import 'package:contracts_v1/contracts_v1.dart';
// Convert between CO2 and Nomos value objects
final estateId = EstateId('estate-001');
final nomosWorkspaceId = NomosConversionUtils.estateIdToWorkspaceId(estateId);
final userId = UserId('user-789');
final nomosActorId = NomosConversionUtils.userIdToActorId(userId);
// Reverse conversions
final backToEstateId = NomosConversionUtils.workspaceIdToEstateId(nomosWorkspaceId);
final backToUserId = NomosConversionUtils.actorIdToUserId(nomosActorId);

The package includes domain-specific enumerations for common statuses and types:

import 'package:contracts_v1/contracts_v1.dart';
// Examples of available enums
enum 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 usage
final userStatus = UserStatus.active;
if (userStatus != UserStatus.suspended) {
print('User is active');
}

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 sourcing

1. Always Use Value Objects Instead of Strings

Section titled “1. Always Use Value Objects Instead of Strings”
// Bad
void updateAsset(String assetId) { }
// Good
void updateAsset(TrackableAssetId assetId) { }
// Bad
final field = CustomField(
key: CustomFieldKey('cost'),
fieldType: CustomFieldType.money,
value: MoneyFieldValue(Money(1000, CurrencyCode.usd)),
);
// Good
final field = CustomField.money(
CustomFieldKey('cost'),
Money(1000, CurrencyCode.usd),
);
// Check for unresolved
if (assetId == TrackableAssetId.unresolved) {
throw StateError('Asset ID must be resolved before proceeding');
}
// Use unresolved as placeholders
final pending = TrackableAssetId.unresolved;

4. Leverage Money for Financial Calculations

Section titled “4. Leverage Money for Financial Calculations”
// Correct: avoids floating-point precision issues
final total = Money(100.00, CurrencyCode.usd) + Money(50.00, CurrencyCode.usd);
// Avoid: manual calculation with doubles
final wrong = 100.0 + 50.0; // Floating-point arithmetic
final metadata = Metadata({
'created': '2025-01-18',
'modified': '2025-01-20',
'tags': ['important', 'reviewed'],
'score': 85,
});

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

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);
});
}
  • Nomos Integration: See nomos_core package for event sourcing integration
  • Domain Models: Check individual domain packages for aggregate designs
  • API Reference: Inline documentation in source files provides detailed method signatures
  • Package Version: 1.0.0
  • Dart SDK: >=3.6.0 <4.0.0
  • Key Dependency: nomos_core for event sourcing primitives

If migrating from string-based IDs:

// Old code
String assetId = 'asset-123';
// New code
TrackableAssetId assetId = TrackableAssetId.fromString('asset-123');
// Update type signatures
void processAsset(String assetId); // Old
void processAsset(TrackableAssetId assetId); // New
// Old: dynamic, error-prone
final data = json['price'] as dynamic;
// New: use Money value object
final price = Money.fromJson(json['price']);
// Old: untyped
final value = customField['value'];
// New: type-safe accessors
final value = customField.textValue; // Returns String?
final money = customField.moneyValue; // Returns Money?