419 lines
12 KiB
Markdown
419 lines
12 KiB
Markdown
|
|
# 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<Self, RelayIdError>`
|
||
|
|
- 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<Self, RelayLabelError>`
|
||
|
|
- 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<RelayId> 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<RelayState, ControllerError>;
|
||
|
|
async fn write_relay_state(&self, id: RelayId, state: RelayState) -> Result<(), ControllerError>;
|
||
|
|
async fn read_all_states(&self) -> Result<Vec<RelayState>, ControllerError>;
|
||
|
|
async fn write_all_states(&self, states: Vec<RelayState>) -> Result<(), ControllerError>;
|
||
|
|
async fn check_connection(&self) -> Result<(), ControllerError>;
|
||
|
|
async fn get_firmware_version(&self) -> Result<Option<String>, 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<Option<RelayLabel>, RepositoryError>;
|
||
|
|
async fn save_label(&self, id: RelayId, label: RelayLabel) -> Result<(), RepositoryError>;
|
||
|
|
async fn get_all_labels(&self) -> Result<Vec<(RelayId, RelayLabel)>, 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<RelayId>
|
||
|
|
└── 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<Self, RelayIdError> {
|
||
|
|
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.
|