# Domain Layer Documentation **Feature**: 001-modbus-relay-control **Phase**: 2 (Domain Layer - Type-Driven Development) **Status**: Complete **Last Updated**: 2026-01-04 ## Overview The domain layer implements pure business logic with zero external dependencies, following Type-Driven Development (TyDD) principles and hexagonal architecture. This layer provides type-safe domain types that make illegal states unrepresentable through smart constructors and validation. ## Architecture Principles ### Type-Driven Development (TyDD) All domain types follow the TyDD approach: 1. **Make illegal states unrepresentable**: Use newtype pattern with validation 2. **Parse, don't validate**: Validate once at construction, trust types internally 3. **Zero-cost abstractions**: `#[repr(transparent)]` for single-field wrappers 4. **Smart constructors**: Return `Result` for fallible validation ### Test-First Development All types were implemented following strict TDD (Red-Green-Refactor): 1. **Red**: Write failing tests first 2. **Green**: Implement minimal code to pass tests 3. **Refactor**: Improve while keeping tests green **Test Coverage**: 100% for domain layer (all types have comprehensive test suites) ## Domain Types ### RelayId **File**: `backend/src/domain/relay/types/relayid.rs` **Purpose**: Type-safe identifier for relay channels (1-8) **Design**: ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(transparent)] pub struct RelayId(u8); impl RelayId { pub const fn new(id: u8) -> Result { if id > 0 && id < 9 { Ok(Self(id)) } else { Err(ControllerError::InvalidRelayId(id)) } } pub const fn as_u8(&self) -> u8 { self.0 } } ``` **Key Features**: - **Validation**: Smart constructor ensures ID is in valid range (1-8) - **Type Safety**: Cannot accidentally use a raw `u8` where `RelayId` is expected - **Zero-cost**: `#[repr(transparent)]` guarantees no runtime overhead - **Display**: Implements `Display` trait for logging and user-facing output **Test Coverage**: 5 tests - Valid lower bound (1) - Valid upper bound (8) - Invalid zero - Invalid out-of-range (9) - Accessor method (`as_u8()`) **Usage Example**: ```rust // Valid relay ID let relay = RelayId::new(1)?; // Ok(RelayId(1)) // Invalid relay IDs let invalid_zero = RelayId::new(0); // Err(InvalidRelayId(0)) let invalid_high = RelayId::new(9); // Err(InvalidRelayId(9)) // Type safety prevents mixing with raw integers fn control_relay(id: RelayId) { /* ... */ } control_relay(5); // Compile error! Must use RelayId::new(5)? ``` ### RelayState **File**: `backend/src/domain/relay/types/relaystate.rs` **Purpose**: Represents the on/off state of a relay **Design**: ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum RelayState { On, Off, } impl RelayState { pub const fn toggle(&self) -> Self { match self { Self::On => Self::Off, Self::Off => Self::On, } } } ``` **Key Features**: - **Explicit states**: Enum makes impossible to have invalid states - **Toggle logic**: Domain-level toggle operation - **Serialization**: Serde support for API DTOs - **Display**: User-friendly string representation **Test Coverage**: 4 tests - Serialization to "on"/"off" strings - Toggle from On to Off - Toggle from Off to On - Display formatting **Usage Example**: ```rust let state = RelayState::Off; let toggled = state.toggle(); // RelayState::On // Serializes to JSON as "on"/"off" let json = serde_json::to_string(&RelayState::On)?; // "\"on\"" ``` ### RelayLabel **File**: `backend/src/domain/relay/types/relaylabel.rs` **Purpose**: Validated human-readable label for relays (1-50 characters) **Design**: ```rust #[derive(Debug, Clone, PartialEq, Eq)] #[repr(transparent)] pub struct RelayLabel(String); #[derive(Debug, thiserror::Error)] pub enum RelayLabelError { #[error("Label cannot be empty")] Empty, #[error("Label exceeds maximum length of 50 characters")] TooLong, } impl RelayLabel { pub fn new(value: String) -> Result { if value.is_empty() { return Err(RelayLabelError::Empty); } if value.len() > 50 { return Err(RelayLabelError::TooLong); } Ok(Self(value)) } pub fn as_str(&self) -> &str { &self.0 } } ``` **Key Features**: - **Length validation**: Enforces 1-50 character limit - **Empty prevention**: Cannot create empty labels - **Type safety**: Cannot mix with regular strings - **Zero-cost**: `#[repr(transparent)]` wrapper **Test Coverage**: 4 tests - Valid label creation - Maximum length (50 chars) - Empty label rejection - Excessive length rejection (51+ chars) **Usage Example**: ```rust // Valid labels let pump = RelayLabel::new("Water Pump".to_string())?; // Ok let long = RelayLabel::new("A".repeat(50))?; // Ok (exactly 50) // Invalid labels let empty = RelayLabel::new("".to_string()); // Err(Empty) let too_long = RelayLabel::new("A".repeat(51)); // Err(TooLong) ``` ### Relay (Aggregate) **File**: `backend/src/domain/relay/entity.rs` **Purpose**: Primary domain entity representing a physical relay device **Design**: ```rust pub struct Relay { id: RelayId, state: RelayState, label: Option, } impl Relay { pub const fn new( id: RelayId, state: RelayState, label: Option ) -> Self { Self { id, state, label } } pub const fn toggle(&mut self) { match self.state { RelayState::On => self.turn_off(), RelayState::Off => self.turn_on(), } } pub const fn turn_on(&mut self) { self.state = RelayState::On; } pub const fn turn_off(&mut self) { self.state = RelayState::Off; } // Getters... pub const fn id(&self) -> RelayId { self.id } pub const fn state(&self) -> RelayState { self.state } pub fn label(&self) -> Option { self.label.clone() } } ``` **Key Features**: - **Encapsulation**: Private fields, public getters - **Behavior-rich**: Methods for state control (`toggle`, `turn_on`, `turn_off`) - **Immutable by default**: Mutation only through controlled methods - **Optional label**: Labels are optional metadata **Test Coverage**: 4 tests - Construction with all parameters - Toggle flips state - Turn on sets state to On - Turn off sets state to Off **Usage Example**: ```rust let id = RelayId::new(1)?; let mut relay = Relay::new(id, RelayState::Off, None); // Domain operations relay.turn_on(); assert_eq!(relay.state(), RelayState::On); relay.toggle(); assert_eq!(relay.state(), RelayState::Off); ``` ### ModbusAddress **File**: `backend/src/domain/modbus.rs` **Purpose**: Type-safe Modbus coil address with conversion from user-facing RelayId **Design**: ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(transparent)] pub struct ModbusAddress(u16); impl ModbusAddress { pub const fn as_u16(self) -> u16 { self.0 } } impl From for ModbusAddress { fn from(relay_id: RelayId) -> Self { // RelayId 1-8 → Modbus address 0-7 Self(u16::from(relay_id.as_u8() - 1)) } } ``` **Key Features**: - **Offset mapping**: User IDs (1-8) to Modbus addresses (0-7) - **Type safety**: Prevents mixing addresses with other integers - **Conversion trait**: Clean conversion from `RelayId` - **Zero-cost**: `#[repr(transparent)]` wrapper **Test Coverage**: 3 tests - RelayId(1) → ModbusAddress(0) - RelayId(8) → ModbusAddress(7) - All IDs convert correctly (comprehensive test) **Usage Example**: ```rust let relay_id = RelayId::new(1)?; let modbus_addr = ModbusAddress::from(relay_id); assert_eq!(modbus_addr.as_u16(), 0); // 0-based addressing // Type-safe usage in Modbus operations async fn read_coil(addr: ModbusAddress) -> Result { /* ... */ } read_coil(ModbusAddress::from(relay_id)).await?; ``` ### HealthStatus **File**: `backend/src/domain/health.rs` **Purpose**: Track system health with state transitions **Design**: ```rust #[derive(Debug, Clone, PartialEq, Eq)] pub enum HealthStatus { Healthy, Degraded { consecutive_errors: u32 }, Unhealthy { reason: String }, } impl HealthStatus { pub const fn healthy() -> Self { Self::Healthy } pub const fn degraded(consecutive_errors: u32) -> Self { Self::Degraded { consecutive_errors } } pub fn unhealthy(reason: impl Into) -> Self { Self::Unhealthy { reason: reason.into() } } pub fn record_error(self) -> Self { match self { Self::Healthy => Self::Degraded { consecutive_errors: 1 }, Self::Degraded { consecutive_errors } => { Self::Degraded { consecutive_errors: consecutive_errors + 1 } } Self::Unhealthy { reason } => Self::Unhealthy { reason }, } } pub fn record_success(self) -> Self { Self::Healthy } pub fn mark_unhealthy(self, reason: impl Into) -> Self { Self::Unhealthy { reason: reason.into() } } // Predicates pub const fn is_healthy(&self) -> bool { /* ... */ } pub const fn is_degraded(&self) -> bool { /* ... */ } pub const fn is_unhealthy(&self) -> bool { /* ... */ } } impl Display for HealthStatus { /* ... */ } ``` **Key Features**: - **State machine**: Well-defined state transitions - **Error tracking**: Consecutive error count in degraded state - **Recovery paths**: Can transition back to healthy from any state - **Reason tracking**: Human-readable failure reasons - **Display**: User-friendly string representation **State Transitions**: ``` Healthy ──(record_error)──> Degraded ──(record_error)──> Degraded (count++) ^ | | └──────(record_success)───────┘ | └────────────────(record_success)────────────────────────────┘ Healthy/Degraded ──(mark_unhealthy)──> Unhealthy Unhealthy ──(record_success)──> Healthy ``` **Test Coverage**: 14 tests - Creation of all states - Healthy → Degraded transition - Degraded error count increment - Unhealthy stays unhealthy on error - Healthy → Unhealthy - Degraded → Unhealthy - Degraded → Healthy recovery - Unhealthy → Healthy recovery - Display formatting for all states - Multiple state transitions **Usage Example**: ```rust let mut status = HealthStatus::healthy(); // Record errors status = status.record_error(); // Degraded { consecutive_errors: 1 } status = status.record_error(); // Degraded { consecutive_errors: 2 } status = status.record_error(); // Degraded { consecutive_errors: 3 } // Mark unhealthy after too many errors if let HealthStatus::Degraded { consecutive_errors } = &status { if *consecutive_errors >= 3 { status = status.mark_unhealthy("Too many consecutive errors"); } } // Recover status = status.record_success(); // Healthy ``` ## Module Structure ``` backend/src/domain/ ├── mod.rs # Domain layer exports ├── health.rs # HealthStatus enum ├── modbus.rs # ModbusAddress type └── relay/ ├── mod.rs # Relay module exports ├── controler.rs # RelayController trait (trait definition) ├── entity.rs # Relay aggregate └── types/ ├── mod.rs # Type exports ├── relayid.rs # RelayId newtype ├── relaystate.rs # RelayState enum └── relaylabel.rs # RelayLabel newtype ``` ## Dependency Graph ``` ┌─────────────────────────────────────────────────────────────┐ │ Domain Layer │ │ (Zero Dependencies) │ ├─────────────────────────────────────────────────────────────┤ │ │ │ RelayId ─────┐ │ │ ├──> Relay (aggregate) │ │ RelayState ──┤ │ │ │ │ │ RelayLabel ──┘ │ │ │ │ RelayId ────> ModbusAddress │ │ │ │ HealthStatus (independent) │ │ │ └─────────────────────────────────────────────────────────────┘ ``` **Key Observations**: - All types have zero external dependencies (only depend on `std`) - `RelayId` is used by both `Relay` and `ModbusAddress` - Types are self-contained and independently testable - No infrastructure or application layer dependencies ## Type Safety Benefits ### Before (Primitive Obsession) ```rust // ❌ Unsafe: Can accidentally swap parameters fn control_relay(id: u8, state: bool) { /* ... */ } control_relay(1, true); // OK control_relay(0, true); // Runtime error! Invalid ID control_relay(9, true); // Runtime error! Out of range // ❌ Can mix unrelated integers let relay_id: u8 = 5; let modbus_address: u16 = relay_id as u16; // Wrong offset! ``` ### After (Type-Driven Design) ```rust // ✅ Safe: Compiler prevents invalid usage fn control_relay(id: RelayId, state: RelayState) { /* ... */ } let id = RelayId::new(1)?; // Compile-time validation control_relay(id, RelayState::On); // OK let invalid = RelayId::new(0); // Compile-time error caught control_relay(invalid?, RelayState::On); // Won't compile // ✅ Conversion is explicit and correct let modbus_addr = ModbusAddress::from(id); // Guaranteed correct offset ``` ### Compile-Time Guarantees 1. **RelayId**: Cannot create invalid IDs (0 or >8) 2. **RelayState**: Cannot have intermediate or invalid states 3. **RelayLabel**: Cannot have empty or too-long labels 4. **ModbusAddress**: Cannot mix with `RelayId` or raw integers 5. **HealthStatus**: State transitions are explicit and type-safe ## Test Results All domain types have 100% test coverage with comprehensive test suites: ``` Running 28 domain layer tests: ✓ RelayId: 5 tests (valid bounds, invalid bounds, accessor) ✓ RelayState: 4 tests (serialization, toggle, display) ✓ RelayLabel: 4 tests (validation, length limits) ✓ Relay: 4 tests (construction, state control) ✓ ModbusAddress: 3 tests (conversion, offset mapping) ✓ HealthStatus: 14 tests (state transitions, display) All tests passing ✓ Coverage: 100% for domain layer ``` ## Lessons Learned ### TyDD Wins 1. **Smart Constructors**: Validation at construction makes entire codebase safer - Once a `RelayId` is created, it's guaranteed valid - No defensive checks needed throughout application layer 2. **Newtype Pattern**: Prevents accidental type confusion - Cannot mix `RelayId` with `ModbusAddress` or raw integers - Compiler catches errors at build time, not runtime 3. **Zero-Cost Abstractions**: `#[repr(transparent)]` ensures no runtime overhead - Type safety is purely compile-time - Final binary is as efficient as using raw types ### TDD Process 1. **Red Phase**: Writing tests first clarified API design - Forced thinking about edge cases upfront - Test names became documentation 2. **Green Phase**: Minimal implementation kept code simple - No premature optimization - Each test added one specific capability 3. **Refactor Phase**: Tests enabled confident refactoring - Could improve code without fear of breaking behavior - Test suite caught regressions immediately ### Best Practices Established 1. **Const where possible**: Most domain operations are `const fn` - Enables compile-time evaluation - Signals purity and side-effect-free operations 2. **Display trait**: All types implement `Display` for logging - User-friendly string representation - Consistent formatting across the system 3. **Comprehensive tests**: Test happy path, edge cases, and error conditions - Build confidence in domain logic - Serve as executable documentation ## Next Steps **Phase 3: Infrastructure Layer** (Tasks T028-T040) Now that domain types are complete, the infrastructure layer can: 1. Implement `RelayController` trait with real Modbus client 2. Create `MockRelayController` for testing 3. Implement `RelayLabelRepository` with SQLite 4. Use domain types throughout infrastructure code **Key advantage**: Infrastructure layer can depend on stable, well-tested domain types with strong guarantees. ## References - [Feature Specification](../specs/001-modbus-relay-control/spec.md) - [Implementation Plan](../specs/001-modbus-relay-control/plan.md) - [Tasks T017-T027](../specs/001-modbus-relay-control/tasks.md#phase-2-domain-layer---type-driven-development-1-day) - [Project Constitution](../specs/constitution.md) - [Type-Driven Design](../specs/001-modbus-relay-control/types-design.md)