# Domain Layer Architecture **Feature**: 001-modbus-relay-control **Phase**: Phase 2 - Domain Layer (Type-Driven Development) **Status**: ✅ Complete (2026-01-04) **Tasks**: T017-T027 ## Overview The domain layer implements pure business logic with zero external dependencies, following Domain-Driven Design (DDD) and Type-Driven Development (TyDD) principles. All types use smart constructors for validation and `#[repr(transparent)]` for zero-cost abstractions. ## Architecture Principles ### 1. Type-Driven Development (TyDD) - **Make illegal states unrepresentable**: Types prevent invalid data at compile time - **Parse, don't validate**: Validate once at boundaries, trust types internally - **Zero-cost abstractions**: `#[repr(transparent)]` ensures no runtime overhead ### 2. Test-Driven Development (TDD) - Red: Write failing tests first - Green: Implement minimal code to pass tests - Refactor: Clean up while keeping tests green - **Result**: 100% test coverage for domain layer ### 3. Hexagonal Architecture - Domain layer has ZERO external dependencies - Pure business logic only - Infrastructure concerns handled in other layers ## Type System Design ### Relay Types Module (`domain/relay/types/`) #### RelayId (`relayid.rs`) ```rust #[repr(transparent)] pub struct RelayId(u8); ``` **Purpose**: User-facing relay identifier (1-8) **Validation**: - Range: 1..=8 (8-channel relay controller) - Smart constructor: `RelayId::new(u8) -> Result` - Compile-time guarantees: Once created, always valid **Key Methods**: - `as_u8()` - Access inner value safely - Derives: `Debug`, `Clone`, `Copy`, `PartialEq`, `Eq`, `Hash`, `Display` **Example**: ```rust let relay = RelayId::new(1)?; // Valid let invalid = RelayId::new(9); // Error: OutOfRange ``` #### RelayState (`relaystate.rs`) ```rust #[derive(Serialize, Deserialize)] pub enum RelayState { On, Off, } ``` **Purpose**: Binary state representation for relay control **Features**: - Serializes to `"on"` / `"off"` for JSON API - Type-safe state transitions - No invalid states possible **Key Methods**: - `toggle()` - Flip state (On ↔ Off) - Derives: `Debug`, `Clone`, `Copy`, `PartialEq`, `Eq`, `Serialize`, `Deserialize`, `Display` **Example**: ```rust let state = RelayState::Off; let toggled = state.toggle(); // On ``` #### RelayLabel (`relaylabel.rs`) ```rust #[repr(transparent)] pub struct RelayLabel(String); ``` **Purpose**: Human-readable relay labels with validation **Validation**: - Length: 1..=50 characters - Smart constructor: `RelayLabel::new(String) -> Result` - Errors: `Empty` | `TooLong` **Key Methods**: - `as_str()` - Borrow inner string - `default()` - Returns "Unlabeled" - Derives: `Debug`, `Clone`, `PartialEq`, `Eq`, `Display` **Example**: ```rust let label = RelayLabel::new("Water Pump".to_string())?; let empty = RelayLabel::new("".to_string()); // Error: Empty ``` ### Relay Entity (`domain/relay/entity.rs`) #### Relay Aggregate ```rust pub struct Relay { id: RelayId, state: RelayState, label: RelayLabel, } ``` **Purpose**: Primary aggregate root for relay operations **Invariants**: - Always has valid RelayId (1-8) - Always has valid RelayState (On/Off) - Always has valid RelayLabel (guaranteed by types) **Construction**: - `new(id)` - Create with default state (Off) and label ("Unlabeled") - `with_state(id, state)` - Create with specific state - `with_label(id, state, label)` - Create fully specified **State Control Methods**: - `toggle()` - Flip state (On ↔ Off) - `turn_on()` - Set state to On - `turn_off()` - Set state to Off **Accessor Methods**: - `id() -> RelayId` - Get relay ID (copy) - `state() -> RelayState` - Get current state (copy) - `label() -> &RelayLabel` - Get label (borrow) **Example**: ```rust let mut relay = Relay::new(RelayId::new(1)?); assert_eq!(relay.state(), RelayState::Off); relay.toggle(); assert_eq!(relay.state(), RelayState::On); relay.turn_off(); assert_eq!(relay.state(), RelayState::Off); ``` ### Modbus Module (`domain/modbus.rs`) #### ModbusAddress ```rust #[repr(transparent)] pub struct ModbusAddress(u16); ``` **Purpose**: Modbus protocol address (0-based) **Conversion**: ```rust impl From for ModbusAddress { // User facing: 1-8 → Modbus protocol: 0-7 fn from(relay_id: RelayId) -> Self { Self(u16::from(relay_id.as_u8() - 1)) } } ``` **Key Methods**: - `as_u16()` - Get Modbus address value **Example**: ```rust let relay_id = RelayId::new(1)?; let addr = ModbusAddress::from(relay_id); assert_eq!(addr.as_u16(), 0); // Relay 1 → Address 0 ``` **Rationale**: Separates user-facing numbering (1-based) from protocol addressing (0-based) at the domain boundary. ### Health Module (`domain/health.rs`) #### HealthStatus ```rust pub enum HealthStatus { Healthy, Degraded { consecutive_errors: u32 }, Unhealthy { reason: String }, } ``` **Purpose**: Track system health with state transitions **State Machine**: ``` Healthy ──(errors)──> Degraded ──(more errors)──> Unhealthy ↑ ↓ ↓ └──────(recovery)───────┘ ↓ └────────────────(recovery)────────────────────────┘ ``` **Key Methods**: - `healthy()` - Create healthy status - `degraded(count)` - Create degraded status with error count - `unhealthy(reason)` - Create unhealthy status with reason - `record_error()` - Transition toward unhealthy - `record_success()` - Reset to healthy - `mark_unhealthy(reason)` - Force unhealthy state - `is_healthy()`, `is_degraded()`, `is_unhealthy()` - State checks **Example**: ```rust let mut status = HealthStatus::healthy(); status = status.record_error(); // Degraded { consecutive_errors: 1 } status = status.record_error(); // Degraded { consecutive_errors: 2 } status = status.mark_unhealthy("Too many errors"); // Unhealthy status = status.record_success(); // Healthy ``` ## Domain Traits ### RelayController (`domain/relay/controler.rs`) ```rust #[async_trait] pub trait RelayController: Send + Sync { async fn read_relay_state(&self, id: RelayId) -> Result; async fn write_relay_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError>; async fn read_all_states(&self) -> Result, ControllerError>; async fn write_all_states(&self, states: Vec) -> Result<(), ControllerError>; async fn check_connection(&self) -> Result<(), ControllerError>; async fn get_firmware_version(&self) -> Result, ControllerError>; } ``` **Purpose**: Abstract Modbus hardware communication **Error Types**: - `ConnectionError(String)` - Network/connection issues - `Timeout(u64)` - Operation timeout - `ModbusException(String)` - Protocol errors - `InvalidRelayId(u8)` - Should never happen (prevented by types) **Implementations** (future phases): - `MockRelayController` - In-memory testing - `ModbusRelayController` - Real hardware via tokio-modbus ### RelayLabelRepository (`domain/relay/repository.rs`) ```rust #[async_trait] pub trait RelayLabelRepository: Send + Sync { async fn get_label(&self, id: RelayId) -> Result, RepositoryError>; async fn save_label(&self, id: RelayId, label: RelayLabel) -> Result<(), RepositoryError>; async fn get_all_labels(&self) -> Result, RepositoryError>; } ``` **Purpose**: Abstract label persistence **Error Types**: - `DatabaseError(String)` - Storage failures - `NotFound(RelayId)` - Label not found **Implementations** (future phases): - `MockLabelRepository` - In-memory HashMap - `SqliteRelayLabelRepository` - SQLite persistence ## File Structure ``` backend/src/domain/ ├── mod.rs # Module exports (relay, modbus, health) ├── relay/ │ ├── mod.rs # Relay module exports │ ├── types/ │ │ ├── mod.rs # Type module exports │ │ ├── relayid.rs # RelayId newtype (1-8 validation) │ │ ├── relaystate.rs # RelayState enum (On/Off) │ │ └── relaylabel.rs # RelayLabel newtype (1-50 chars) │ ├── entity.rs # Relay aggregate │ ├── controler.rs # RelayController trait + errors │ └── repository.rs # RelayLabelRepository trait + errors ├── modbus.rs # ModbusAddress type + From └── health.rs # HealthStatus enum + transitions ``` ## Test Coverage **Total Tests**: 50+ comprehensive tests across all domain types **Coverage**: 100% (domain layer requirement) **Test Organization**: - Tests embedded in module files with `#[cfg(test)]` - Each type has comprehensive unit tests - Tests verify both happy paths and error cases - State transitions tested exhaustively (HealthStatus) **Example Test Count**: - RelayId: 5 tests (validation, conversion) - RelayState: 3 tests (serialization, toggle) - RelayLabel: 5 tests (validation, default) - Relay: 8 tests (construction, state control) - ModbusAddress: 3 tests (conversion) - HealthStatus: 15 tests (all state transitions) ## Design Decisions ### Why Newtypes Over Type Aliases? ❌ **Type Alias** (no safety): ```rust type RelayId = u8; type UserId = u8; fn send_notification(user: UserId, relay: RelayId); send_notification(relay_id, user_id); // Compiles! Wrong! ``` ✅ **Newtype** (compile-time safety): ```rust struct RelayId(u8); struct UserId(u8); fn send_notification(user: UserId, relay: RelayId); send_notification(relay_id, user_id); // Compiler error! ``` ### Why `#[repr(transparent)]`? Guarantees zero runtime overhead: - Same memory layout as inner type - No boxing, no indirection - Compiler can optimize like primitive - Cost: Only at type boundaries (validation) ### Why Smart Constructors? **Parse, Don't Validate**: ```rust // ❌ Validate everywhere fn control_relay(id: u8) { if id < 1 || id > 8 { panic!("Invalid!"); } // ... business logic } // ✅ Validate once, trust types fn control_relay(id: RelayId) { // id is guaranteed valid by type // ... business logic } ``` ### Why `Result` Over `panic!`? Smart constructors return `Result` for composability: ```rust // ❌ Panic - hard to test, poor UX impl RelayId { pub fn new(value: u8) -> Self { assert!(value >= 1 && value <= 8); // Crashes! Self(value) } } // ✅ Result - testable, composable impl RelayId { pub fn new(value: u8) -> Result { if value < 1 || value > 8 { return Err(RelayIdError::OutOfRange(value)); } Ok(Self(value)) } } ``` ## Integration with Other Layers ### Application Layer (Phase 5) - Use cases will orchestrate domain entities and traits - Example: `ToggleRelayUseCase` uses `RelayController` trait ### Infrastructure Layer (Phase 3-4) - Implements domain traits (`RelayController`, `RelayLabelRepository`) - `ModbusRelayController` converts `RelayId` → `ModbusAddress` - `SqliteRelayLabelRepository` persists `RelayLabel` ### Presentation Layer (Phase 6) - DTOs map to/from domain types - Validation happens once at API boundary - Internal logic trusts domain types ## Future Considerations ### Planned Extensions 1. **Domain Events** - Capture state changes for audit log 2. **Relay Policies** - Business rules for relay operations 3. **Device Aggregate** - Group multiple relays into devices ### Not Needed for MVP - Relay scheduling (out of scope) - Multi-device support (Phase 2+ feature) - Complex relay patterns (future enhancement) ## References - [Feature Specification](./spec.md) - User stories and requirements - [Tasks](./tasks.md) - Implementation tasks T017-T027 - [Type System Design](./types-design.md) - Detailed TyDD patterns - [Project Constitution](../constitution.md) - DDD and hexagonal architecture principles ## Lessons Learned See [lessons-learned.md](./lessons-learned.md) for detailed insights from Phase 2 implementation.