Files
sta/specs/001-modbus-relay-control/domain-layer-architecture.md
Lucien Cartier-Tilet 72f1721ba4 docs: document Phase 2 domain layer completion
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
2026-01-22 00:57:11 +01:00

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 string
  • default() - 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 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:

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 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:

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

#[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):

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: ToggleRelayUseCase uses RelayController trait

Infrastructure Layer (Phase 3-4)

  • Implements domain traits (RelayController, RelayLabelRepository)
  • ModbusRelayController converts RelayIdModbusAddress
  • 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

Lessons Learned

See lessons-learned.md for detailed insights from Phase 2 implementation.