torrust-tracker-deployer 0.1.0

Torrust Tracker Deployer - Deployment Infrastructure with Ansible and OpenTofu
Documentation
# DDD Practices Guide

This guide documents Domain-Driven Design practices adopted in the Torrust Tracker Deployer project. These patterns ensure domain objects are always valid and business rules are consistently enforced.

## Overview

DDD emphasizes that the domain layer should encapsulate business logic and maintain invariants. This guide covers specific implementation patterns we use to achieve these goals in Rust.

For layer placement guidelines (which code belongs where), see [DDD Layer Placement Guide](./ddd-layer-placement.md).

## Core Principles

### 1. Domain Objects Are Always Valid

After construction, a domain object must satisfy all its business invariants. Invalid objects should be impossible to create.

**Bad - Public fields allow invalid state:**

```rust
pub struct HttpApiConfig {
    pub bind_address: SocketAddr,  // Anyone can set port to 0
    pub use_tls_proxy: bool,       // Can enable TLS without domain
    pub domain: Option<DomainName>,
}
```

**Good - Validated constructor enforces invariants:**

```rust
pub struct HttpApiConfig {
    bind_address: SocketAddr,  // Private
    use_tls_proxy: bool,
    domain: Option<DomainName>,
}

impl HttpApiConfig {
    pub fn new(
        bind_address: SocketAddr,
        domain: Option<DomainName>,
        use_tls_proxy: bool,
    ) -> Result<Self, HttpApiConfigError> {
        if bind_address.port() == 0 {
            return Err(HttpApiConfigError::DynamicPortNotSupported(bind_address));
        }
        if use_tls_proxy && domain.is_none() {
            return Err(HttpApiConfigError::TlsProxyRequiresDomain);
        }
        Ok(Self { bind_address, domain, use_tls_proxy })
    }
}
```

### 2. Single Point of Validation

All validation happens in the constructor. Don't duplicate validation in:

- Separate `validate()` methods
- Setters
- Getters
- Serialization/deserialization code

### 3. Expressive Error Types

Domain errors should explain what went wrong and how to fix it:

```rust
#[derive(Debug, thiserror::Error)]
pub enum HttpApiConfigError {
    #[error("Dynamic port assignment (port 0) is not supported: {0}")]
    DynamicPortNotSupported(SocketAddr),

    #[error("TLS proxy requires a domain to be specified")]
    TlsProxyRequiresDomain,
}

impl HttpApiConfigError {
    #[must_use]
    pub fn help(&self) -> &'static str {
        match self {
            Self::DynamicPortNotSupported(_) => {
                "Dynamic port assignment (port 0) is not supported.\n\
                 \n\
                 Why: Port 0 tells the OS to assign a random available port.\n\
                 This is not suitable for deployment where ports must be known.\n\
                 \n\
                 Fix: Specify an explicit port number (e.g., 1212, 8080)."
            }
            Self::TlsProxyRequiresDomain => {
                "TLS proxy mode requires a domain name.\n\
                 \n\
                 Why: TLS certificates are issued for specific domain names.\n\
                 \n\
                 Fix: Either set a domain name or disable TLS proxy."
            }
        }
    }
}
```

## Validated Deserialization Pattern

When domain types use serde for serialization/deserialization, we must ensure deserialization also enforces invariants.

> **ADR**: See [Validated Deserialization for Domain Types]../decisions/validated-deserialization-for-domain-types.md for the full decision record.

### The Problem

Derived `#[derive(Deserialize)]` bypasses the validated constructor:

```rust
#[derive(Deserialize)]  // ❌ Creates object without validation
pub struct HttpApiConfig { ... }

// This JSON creates an invalid object:
let config: HttpApiConfig = serde_json::from_str(
    r#"{"bind_address": "0.0.0.0:0", "use_tls_proxy": true}"#
)?;
```

### The Solution

Use a custom `Deserialize` implementation with a Raw struct:

```rust
use serde::{Deserialize, Deserializer, Serialize};

/// The domain type with private fields
#[derive(Debug, Clone, Serialize)]  // Serialize can be derived
pub struct HttpApiConfig {
    bind_address: SocketAddr,
    admin_token: ApiToken,
    domain: Option<DomainName>,
    use_tls_proxy: bool,
}

/// Internal struct for deserialization (mirrors the main type)
#[derive(Deserialize)]
struct HttpApiConfigRaw {
    bind_address: SocketAddr,
    admin_token: ApiToken,
    #[serde(default)]
    domain: Option<DomainName>,
    use_tls_proxy: bool,
}

impl<'de> Deserialize<'de> for HttpApiConfig {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let raw = HttpApiConfigRaw::deserialize(deserializer)?;

        Self::new(
            raw.bind_address,
            raw.admin_token,
            raw.domain,
            raw.use_tls_proxy,
        )
        .map_err(serde::de::Error::custom)
    }
}
```

### When to Use This Pattern

Apply custom deserialization when a domain type has **all** of these:

- ✅ Private fields
- ✅ Validated constructor (`new()` returning `Result`)
- ✅ Business invariants to protect
- ✅ Needs serde deserialization (persisted or parsed from JSON)

### Checklist for Implementation

1. [ ] Create `TypeNameRaw` struct with identical fields
2. [ ] Add `#[derive(Deserialize)]` only to Raw struct
3. [ ] Keep `#[derive(Serialize)]` on main type (serialization doesn't need validation)
4. [ ] Implement `Deserialize` trait calling `new()`
5. [ ] Map domain errors with `serde::de::Error::custom`
6. [ ] Add `#[serde(default)]` to Raw struct for optional fields

## Getters for Private Fields

Provide getter methods for all private fields:

```rust
impl HttpApiConfig {
    #[must_use]
    pub fn bind_address(&self) -> SocketAddr {
        self.bind_address
    }

    #[must_use]
    pub fn domain(&self) -> Option<&DomainName> {
        self.domain.as_ref()
    }

    #[must_use]
    pub fn use_tls_proxy(&self) -> bool {
        self.use_tls_proxy
    }
}
```

Guidelines:

- Use `#[must_use]` on all getters
- Return references for owned types (`&str`, `&DomainName`)
- Return copies for `Copy` types (`bool`, `SocketAddr`, `u16`)
- Name getters after the field (no `get_` prefix)

## Default Implementations

When implementing `Default` for domain types with validated constructors:

```rust
impl Default for HttpApiConfig {
    fn default() -> Self {
        Self::new(
            "0.0.0.0:1212".parse().expect("default address is valid"),
            "MyAccessToken".to_string().into(),
            None,
            false,
        )
        .expect("default configuration is valid")
    }
}
```

The `expect()` is acceptable here because:

1. Default values are compile-time constants
2. Tests verify the default is valid
3. If it fails, it's a programmer error, not a user error

## Test Helper Functions

For tests that need to create domain objects with various configurations:

```rust
#[cfg(test)]
mod tests {
    use super::*;

    fn test_http_api_config(bind_address: &str, admin_token: &str) -> HttpApiConfig {
        HttpApiConfig::new(
            bind_address.parse().expect("test address is valid"),
            admin_token.to_string().into(),
            None,
            false,
        )
        .expect("test configuration is valid")
    }

    fn test_http_api_config_with_tls(
        bind_address: &str,
        domain: Option<DomainName>,
        use_tls_proxy: bool,
    ) -> HttpApiConfig {
        HttpApiConfig::new(
            bind_address.parse().expect("test address is valid"),
            "token".to_string().into(),
            domain,
            use_tls_proxy,
        )
        .expect("test configuration is valid")
    }
}
```

## Application Layer DTOs

Application layer DTOs bridge external data (JSON) to domain types. We use Rust's standard `TryFrom` trait for these conversions.

> **ADR**: See [TryFrom for DTO to Domain Conversion]../decisions/tryfrom-for-dto-to-domain-conversion.md for the full decision record.

### Why TryFrom?

Using standard traits makes the code self-documenting:

- **Discoverable**: Developers can search for `TryFrom` implementations
- **Consistent**: All conversions follow the same pattern
- **Generic-friendly**: Works with `T: TryInto<Target>` bounds
- **IDE support**: Autocomplete shows `.try_into()` on any type

### Implementation Pattern

```rust
use std::convert::TryFrom;

// Application DTO - accepts primitives from JSON
#[derive(Deserialize)]
pub struct HttpApiSection {
    pub bind_address: String,      // String from JSON
    pub admin_token: String,
    pub domain: Option<String>,
    pub use_tls_proxy: Option<bool>,
}

// Implement TryFrom on the domain type
impl TryFrom<HttpApiSection> for HttpApiConfig {
    type Error = CreateConfigError;

    fn try_from(section: HttpApiSection) -> Result<Self, Self::Error> {
        // 1. Parse primitives to domain types
        let bind_address: SocketAddr = section.bind_address.parse()
            .map_err(|e| CreateConfigError::InvalidBindAddress {
                value: section.bind_address.clone(),
                reason: e.to_string(),
            })?;

        let domain = section.domain
            .map(|d| DomainName::new(&d))
            .transpose()
            .map_err(|e| CreateConfigError::InvalidDomain { ... })?;

        // 2. Delegate to domain constructor
        HttpApiConfig::new(
            bind_address,
            section.admin_token.into(),
            domain,
            section.use_tls_proxy.unwrap_or(false),
        ).map_err(CreateConfigError::from)  // Domain errors convert via From trait
    }
}
```

### Usage

```rust
// Both forms are equivalent - use whichever reads better in context
let config = HttpApiConfig::try_from(section)?;
let config: HttpApiConfig = section.try_into()?;
```

### Key Points

- **DTOs use primitive types** (`String`, not `SocketAddr`)
- **Parsing** (string → typed value) happens in `TryFrom` implementation
- **Validation** (business rules) happens in domain constructor
- **Error propagation** via `?` and `From` trait implementations
- **Ownership**: `TryFrom` consumes the DTO; use `TryFrom<&T>` if borrowing is needed

### When to Use Which

| Scenario                  | Use           |
| ------------------------- | ------------- |
| DTO → Domain (fallible)   | `TryFrom`     |
| Domain → DTO (infallible) | `From`        |
| Need to borrow            | `TryFrom<&T>` |
| Multiple strategies       | Named methods |

## Related Documentation

- [DDD Layer Placement Guide]./ddd-layer-placement.md - Which code belongs in which layer
- [Error Handling Guide]./error-handling.md - Error design patterns
- [Validated Deserialization ADR]../decisions/validated-deserialization-for-domain-types.md - Custom Deserialize for domain invariants
- [TryFrom for DTO Conversion ADR]../decisions/tryfrom-for-dto-to-domain-conversion.md - Standard traits for DTO→Domain conversion
- [Refactoring Plan]../refactors/plans/strengthen-domain-invariant-enforcement.md - Active refactoring work