Add comprehensive documentation for completed domain layer implementation: - Update CLAUDE.md with Phase 2 status - Update README.md with Phase 2 achievements and documentation links - Add domain-layer-architecture.md with type system design - Add lessons-learned.md with implementation insights Phase 2 complete: 100% test coverage, zero external dependencies
12 KiB
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)
#[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:
let relay = RelayId::new(1)?; // Valid
let invalid = RelayId::new(9); // Error: OutOfRange
RelayState (relaystate.rs)
#[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:
let state = RelayState::Off;
let toggled = state.toggle(); // On
RelayLabel (relaylabel.rs)
#[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 stringdefault()- Returns "Unlabeled"- Derives:
Debug,Clone,PartialEq,Eq,Display
Example:
let label = RelayLabel::new("Water Pump".to_string())?;
let empty = RelayLabel::new("".to_string()); // Error: Empty
Relay Entity (domain/relay/entity.rs)
Relay Aggregate
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 statewith_label(id, state, label)- Create fully specified
State Control Methods:
toggle()- Flip state (On ↔ Off)turn_on()- Set state to Onturn_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:
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
#[repr(transparent)]
pub struct ModbusAddress(u16);
Purpose: Modbus protocol address (0-based)
Conversion:
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:
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
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 statusdegraded(count)- Create degraded status with error countunhealthy(reason)- Create unhealthy status with reasonrecord_error()- Transition toward unhealthyrecord_success()- Reset to healthymark_unhealthy(reason)- Force unhealthy stateis_healthy(),is_degraded(),is_unhealthy()- State checks
Example:
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)
#[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 issuesTimeout(u64)- Operation timeoutModbusException(String)- Protocol errorsInvalidRelayId(u8)- Should never happen (prevented by types)
Implementations (future phases):
MockRelayController- In-memory testingModbusRelayController- Real hardware via tokio-modbus
RelayLabelRepository (domain/relay/repository.rs)
#[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 failuresNotFound(RelayId)- Label not found
Implementations (future phases):
MockLabelRepository- In-memory HashMapSqliteRelayLabelRepository- 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):
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):
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:
// ❌ 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:
// ❌ 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:
ToggleRelayUseCaseusesRelayControllertrait
Infrastructure Layer (Phase 3-4)
- Implements domain traits (
RelayController,RelayLabelRepository) ModbusRelayControllerconvertsRelayId→ModbusAddressSqliteRelayLabelRepositorypersistsRelayLabel
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
- Domain Events - Capture state changes for audit log
- Relay Policies - Business rules for relay operations
- 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 - User stories and requirements
- Tasks - Implementation tasks T017-T027
- Type System Design - Detailed TyDD patterns
- Project Constitution - DDD and hexagonal architecture principles
Lessons Learned
See lessons-learned.md for detailed insights from Phase 2 implementation.