This document proposes a new design for the src/presentation/ layer based on:
- Research on CLI organization patterns
- Analysis of current structure
- Industry best practices (MVC/MVT patterns)
Goal: Transform the presentation layer into a clear, maintainable, layered architecture.
Date: November 6, 2025
Explicit Four-Layer Architecture inspired by MVC/MVT patterns:
Input → Dispatch → Controllers → Views
Each layer has one clear responsibility and explicit boundaries.
graph TD
User[User Input] --> InputLayer[INPUT LAYER<br/>CLI Parsing]
InputLayer --> DispatchLayer[DISPATCH LAYER<br/>Routing & Context]
DispatchLayer --> ControllerLayer[CONTROLLER LAYER<br/>Orchestration]
ControllerLayer --> AppLayer[Application Layer<br/>Use Cases]
ControllerLayer --> ViewLayer[VIEW LAYER<br/>Rendering]
ViewLayer --> Output[User Output<br/>stdout/stderr]
style InputLayer fill:#e1f5e1
style DispatchLayer fill:#e3f2fd
style ControllerLayer fill:#fff3e0
style ViewLayer fill:#fce4ec
src/presentation/
├── input/ # INPUT LAYER
│ ├── cli/ # CLI parsing
│ │ ├── args.rs # Global arguments
│ │ ├── commands.rs # Command definitions
│ │ └── mod.rs # Cli struct
│ └── mod.rs # Input layer exports
│
├── dispatch/ # DISPATCH LAYER
│ ├── router.rs # Command router
│ ├── context.rs # Execution context (wraps Container)
│ └── mod.rs # Dispatch layer exports
│
├── controllers/ # CONTROLLER LAYER
│ ├── create/ # Create controller
│ │ ├── handler.rs # Handler implementation
│ │ ├── subcommands/ # Subcommand handlers
│ │ ├── config_loader.rs
│ │ ├── errors.rs
│ │ ├── tests/
│ │ └── mod.rs
│ ├── destroy/ # Destroy controller
│ │ ├── handler.rs
│ │ ├── errors.rs
│ │ ├── tests/
│ │ └── mod.rs
│ ├── constants.rs # Shared constants
│ └── mod.rs # Controller exports
│
├── views/ # VIEW LAYER
│ ├── formatters/ # Output formatters
│ │ ├── json.rs
│ │ └── mod.rs
│ ├── messages/ # Message types
│ │ ├── error.rs
│ │ ├── info_block.rs
│ │ ├── progress.rs # Progress messages
│ │ ├── result.rs
│ │ ├── steps.rs
│ │ ├── success.rs
│ │ ├── warning.rs
│ │ └── mod.rs
│ ├── progress/ # Progress indicators
│ │ ├── bar.rs # (from old progress.rs)
│ │ ├── spinner.rs
│ │ └── mod.rs
│ ├── terminal/ # Terminal I/O
│ │ ├── channel.rs # Stdout/stderr
│ │ ├── writers.rs # Output writers
│ │ ├── sinks.rs # Output sinks
│ │ └── mod.rs
│ ├── core.rs # UserOutput main struct
│ ├── theme.rs # Visual theme
│ ├── verbosity.rs # Verbosity levels
│ ├── traits.rs # Output traits
│ ├── test_support.rs
│ └── mod.rs # View layer exports
│
├── errors.rs # Top-level errors
└── mod.rs # Presentation layer exports
CURRENT → PROPOSED
cli/ → input/cli/
commands/mod.rs → dispatch/router.rs (dispatcher logic)
commands/context.rs → dispatch/context.rs (wraps bootstrap/container.rs)
commands/factory.rs → (removed - use Container from bootstrap/)
commands/create/ → controllers/create/
commands/destroy/ → controllers/destroy/
commands/constants.rs → controllers/constants.rs
user_output/ → views/ (rename + reorganize)
progress.rs → views/progress/ (integrate)
Responsibility: Parse and validate user input.
What It Does:
- CLI argument parsing (clap)
- Convert raw arguments to
Commandsenum - Input syntax validation
What It Does NOT Do:
- Execute commands
- Access application/domain logic
- Render output
Key Interface:
// src/presentation/input/mod.rs
pub mod cli;
pub use cli::{Cli, Commands, GlobalArgs, CreateAction};
// Main entry point
impl Cli {
pub fn parse() -> Self {
Self::parse_from(std::env::args())
}
}Example:
// User runs: torrust-tracker-deployer destroy my-env
let cli = Cli::parse();
// cli.command = Some(Commands::Destroy { environment: "my-env" })Responsibility: Route commands to appropriate controllers.
What It Does:
- Pattern match on
Commandsenum - Create execution context (wraps Container)
- Route to controller handlers
- Handle errors uniformly
What It Does NOT Do:
- Implement business logic
- Render output directly
- Know controller internals
Key Files:
use crate::presentation::input::Commands;
use crate::presentation::controllers;
use crate::presentation::dispatch::ExecutionContext;
use crate::bootstrap::container::Container;
pub fn route_command(
command: Commands,
working_dir: &Path,
container: Container,
) -> Result<(), CommandError> {
let ctx = ExecutionContext::new(working_dir.to_path_buf(), container);
match command {
Commands::Create { action } => {
controllers::create::handle(action, &ctx)?;
}
Commands::Destroy { environment } => {
controllers::destroy::handle(&environment, &ctx)?;
}
}
Ok(())
}use crate::bootstrap::container::Container;
/// Execution context for commands
///
/// Wraps the application Container and provides command-specific context.
pub struct ExecutionContext {
pub working_dir: PathBuf,
pub container: Container,
}
impl ExecutionContext {
pub fn new(working_dir: PathBuf, container: Container) -> Self {
Self { working_dir, container }
}
}Note: Instead of a separate "factory", we use the existing Container from src/bootstrap/container.rs with lazy-loading pattern. Services are instantiated on-demand using thread-safe Arc<Mutex<Option<Arc<T>>>> fields.
Container Enhancement (future work):
// Extend src/bootstrap/container.rs with lazy-loaded services
pub struct Container {
user_output: Arc<Mutex<UserOutput>>,
// Lazy-loaded services - None until first requested
// Arc<Mutex<Option<...>>> provides thread-safe lazy initialization
opentofu_client: Arc<Mutex<Option<Arc<OpenTofuClient>>>>,
repository: Arc<Mutex<Option<Arc<dyn EnvironmentRepository>>>>,
}
impl Container {
pub fn opentofu_client(&self) -> Result<Arc<OpenTofuClient>, ContainerError> {
let mut guard = self.opentofu_client.lock().unwrap();
if let Some(client) = guard.as_ref() {
// Already initialized - return cached clone
return Ok(Arc::clone(client));
}
// First access - build the service (thread-safe initialization)
let client = Arc::new(OpenTofuClient::new()?);
*guard = Some(Arc::clone(&client));
Ok(client)
}
pub fn repository(&self) -> Result<Arc<dyn EnvironmentRepository>, ContainerError> {
let mut guard = self.repository.lock().unwrap();
if let Some(repo) = guard.as_ref() {
return Ok(Arc::clone(repo));
}
// Build repository with dependencies (thread-safe initialization)
let repo: Arc<dyn EnvironmentRepository> = Arc::new(
JsonEnvironmentRepository::new(/* ... */)?
);
*guard = Some(Arc::clone(&repo));
Ok(repo)
}
}Why Arc<Mutex<Option<Arc<T>>>>?
This pattern provides thread-safe lazy initialization, which is important because:
- Parallel task execution: Commands may execute internal tasks concurrently (e.g., rendering OpenTofu and Ansible templates in parallel during provisioning)
- Thread-safe lazy-loading: Multiple threads can safely request services concurrently without data races
- Future-proof: Ready for parallelism at the task level, even though only one command runs at a time
- Correct semantics: Mutex protects the Option during check-and-initialize, then returns Arc for shared ownership
Example - Parallel template rendering:
// In provision command handler - tasks run in parallel
let (tofu_templates, ansible_templates) = tokio::join!(
async { ctx.container.opentofu_client()?.render_templates() }, // Thread 1
async { ctx.container.ansible_client()?.render_templates() }, // Thread 2
);
// Both threads can safely lazy-load their clients concurrentlyBenefits:
- Efficient: Only instantiate services actually used by the command
- Short-lived CLI: Perfect for one-command-per-execution model
- Centralized: All service initialization in one place
- Testable: Easy to mock services in container
- Lazy: No upfront cost for unused services
Responsibility: Orchestrate command execution.
What It Does:
- Coordinate application/domain layers
- Handle command-specific logic
- Use views for output
- Transform results for presentation
What It Does NOT Do:
- Parse CLI arguments
- Implement domain logic
- Directly write to stdout/stderr
- Format output (delegates to views)
Key Pattern:
Each controller is a module with:
controllers/{command}/
├── handler.rs # Main handler function
├── errors.rs # Command-specific errors
├── tests/ # Controller tests
└── mod.rs # Exports
Handler Signature:
pub fn handle(
// Command-specific arguments
ctx: &ExecutionContext,
) -> Result<(), {Command}Error>Example (controllers/destroy/handler.rs):
use crate::application::commands::destroy::DestroyCommand;
use crate::presentation::dispatch::ExecutionContext;
pub fn handle(
environment_name: &str,
ctx: &ExecutionContext,
) -> Result<(), DestroyError> {
// 1. Get user output from container
let user_output = ctx.container.user_output();
// 2. Show progress
user_output.lock().unwrap().progress("Destroying environment...");
// 3. Get lazy-loaded dependencies from container
let opentofu = ctx.container.opentofu_client()?;
let repository = ctx.container.repository()?;
// 4. Execute application logic
let destroy_cmd = DestroyCommand::new(opentofu, repository);
destroy_cmd.execute(environment_name)?;
// 5. Show success
user_output.lock().unwrap().success("Environment destroyed successfully");
Ok(())
}Benefits:
- Clear orchestration role
- Easy to test (mock application layer)
- Consistent pattern across commands
- Single responsibility
Responsibility: Render all user-facing output.
What It Does:
- Format messages (error, success, progress, etc.)
- Manage progress indicators (bars, spinners)
- Handle terminal I/O (stdout/stderr channels)
- Support multiple output formats (text, JSON)
- Apply themes and styling
What It Does NOT Do:
- Execute business logic
- Access domain/application directly
- Parse user input
Key Structure:
views/
├── messages/ # Message types (ErrorMessage, SuccessMessage, etc.)
├── progress/ # Progress indicators (bars, spinners)
├── terminal/ # Terminal I/O (channels, writers, sinks)
├── formatters/ # Output formatters (JSON, plain text)
├── core.rs # UserOutput coordinator
├── theme.rs # Visual theme
├── verbosity.rs # Verbosity levels
└── traits.rs # Output traits
Main Interface (views/core.rs):
pub struct UserOutput {
sinks: Vec<Box<dyn OutputSink>>,
verbosity: VerbosityLevel,
theme: Theme,
}
impl UserOutput {
pub fn progress(&mut self, message: &str) {
let msg = ProgressMessage::new(message);
self.write(&msg);
}
pub fn success(&mut self, message: &str) {
let msg = SuccessMessage::new(message);
self.write(&msg);
}
pub fn error(&mut self, message: &str) {
let msg = ErrorMessage::new(message);
self.write(&msg);
}
pub fn result(&mut self, data: &str) {
let msg = ResultMessage::new(data);
self.write(&msg);
}
}Benefits:
- All output in one place
- Consistent formatting
- Easy to add new message types
- Easy to change themes/formats
sequenceDiagram
participant User
participant Input as INPUT<br/>input/cli
participant Dispatch as DISPATCH<br/>dispatch/router
participant Factory as DISPATCH<br/>dispatch/factory
participant Controller as CONTROLLER<br/>controllers/destroy
participant App as Application Layer
participant View as VIEW<br/>views/
User->>Input: torrust-tracker-deployer destroy my-env
Input->>Input: Parse args
Input->>Dispatch: Commands::Destroy { env: "my-env" }
Dispatch->>Dispatch: Create ExecutionContext(Container)
Dispatch->>Controller: handle("my-env", ctx)
Controller->>Container: container.user_output()
Container-->>Controller: Arc<Mutex<UserOutput>>
Controller->>View: progress("Destroying...")
View->>User: [stderr] Destroying...
Controller->>Container: container.opentofu_client() (lazy-load)
Container-->>Controller: Arc<OpenTofuClient>
Controller->>Container: container.repository() (lazy-load)
Container-->>Controller: Arc<Repository>
Controller->>App: DestroyCommand::execute()
App-->>Controller: Ok(())
Controller->>View: success("Destroyed!")
View->>User: [stderr] ✓ Destroyed!
Controller-->>Dispatch: Ok(())
Dispatch-->>Input: Ok(())
graph LR
A[Input Layer] -->|Commands enum| B[Dispatch Layer]
B -->|Dependencies| C[Controller Layer]
C -->|Requests| D[Application Layer]
C -->|Messages| E[View Layer]
E -->|Output| F[User]
style A fill:#e1f5e1
style B fill:#e3f2fd
style C fill:#fff3e0
style E fill:#fce4ec
| Problem | Solution |
|---|---|
Mixed responsibilities in commands/ |
Clear layers with single responsibilities |
| No explicit router | Dedicated dispatch/router.rs |
Unclear naming (user_output/) |
Standard terminology (views/) |
Orphaned progress.rs |
Integrated into views/progress/ |
| Context placement unclear | dispatch/context.rs wraps Container |
| Unused Factory | Use existing Container from bootstrap/ |
| Scalability concerns | Router scales linearly with commands |
| Testing complexity | Each layer mockable independently |
| Discoverability | Clear structure, obvious where code goes |
Clear Responsibilities:
- Adding a command: obvious where to add code
- Changing routing: modify
dispatch/router.rs - New output format: add to
views/formatters/
Better Testing:
// Test routing without controllers
#[test]
fn test_router() {
let cmd = Commands::Destroy { environment: "test".into() };
// Mock controller
let result = route_command(cmd, mock_context);
assert!(result.is_ok());
}
// Test controller without dispatch
#[test]
fn test_destroy_controller() {
let mock_app = MockApplication::new();
let mock_view = MockView::new();
let result = destroy::handle("test", &path, &mock_view);
assert!(result.is_ok());
assert!(mock_view.success_called());
}Easy Extension:
// Future: Add middleware in dispatch layer
pub fn route_command(cmd: Commands, working_dir: &Path, container: Container) -> Result<()> {
// Pre-middleware
validate_command(&cmd)?;
log_command(&cmd)?;
let ctx = ExecutionContext::new(working_dir.to_path_buf(), container);
// Route
match cmd {
Commands::NewCommand { .. } => controllers::new_command::handle(&ctx)?,
// ...
}
// Post-middleware
audit_command(&cmd)?;
Ok(())
}Web Framework Alignment:
| Web MVC | Our CLI | Purpose |
|---|---|---|
| Request Parsing | Input Layer | Parse user input |
| Router | Dispatch Layer | Route to handlers |
| Controller | Controller Layer | Orchestrate logic |
| View/Template | View Layer | Render output |
SOLID Principles:
- ✅ Single Responsibility: Each layer has one job
- ✅ Open/Closed: Easy to extend (new commands, formatters)
- ✅ Liskov Substitution: Controllers interchangeable
- ✅ Interface Segregation: Small, focused interfaces
- ✅ Dependency Inversion: Depend on traits, not concrete types
Reuses Existing Infrastructure: The proposal integrates with src/bootstrap/container.rs instead of creating a new dependency injection mechanism.
Thread-Safe Lazy Loading Pattern: Services are instantiated on-demand using Arc<Mutex<Option<Arc<T>>>>:
// Container only builds services when first requested
let opentofu = ctx.container.opentofu_client()?; // Built on first call (thread-safe)
let opentofu2 = ctx.container.opentofu_client()?; // Returns cached instance
// Multiple threads can safely request services concurrently
let (tofu, ansible) = tokio::join!(
async { ctx.container.opentofu_client() }, // Thread-safe lazy load
async { ctx.container.ansible_client() }, // Thread-safe lazy load
);Benefits for CLI with Internal Parallelism:
- ✅ No upfront cost: Don't build services the command won't use
- ✅ Thread-safe: Multiple threads can safely lazy-load services concurrently
- ✅ Efficient: Single command execution, then exit
- ✅ Parallel tasks: Commands can execute internal operations in parallel (e.g., template rendering)
- ✅ Simple: One container for entire application lifecycle
- ✅ Testable: Easy to provide mock container with test doubles
Example - Command only pays for what it uses:
# Create command: Uses Container services: user_output, repository, config_loader
$ torrust-tracker-deployer create environment my-env.json
# Container doesn't instantiate: opentofu_client, ansible_client (unused)
# Destroy command: Uses Container services: user_output, repository, opentofu_client
$ torrust-tracker-deployer destroy my-env
# Container doesn't instantiate: config_loader, ansible_client (unused)Current: Commands added to single execute() function.
// Current: commands/mod.rs
pub fn execute(cmd: Commands, ...) -> Result<()> {
match cmd {
Commands::Create { .. } => { /* ... */ }
Commands::Destroy { .. } => { /* ... */ }
Commands::NewCmd1 { .. } => { /* ... */ } // Line 15
Commands::NewCmd2 { .. } => { /* ... */ } // Line 18
Commands::NewCmd3 { .. } => { /* ... */ } // Line 21
// File grows linearly, all in one place
}
}Proposed: Dedicated router, clear pattern.
// Proposed: dispatch/router.rs
pub fn route_command(cmd: Commands, ctx: Context) -> Result<()> {
match cmd {
Commands::Create { .. } => controllers::create::handle(..),
Commands::Destroy { .. } => controllers::destroy::handle(..),
Commands::NewCmd { .. } => controllers::new_cmd::handle(..), // Easy!
// Router stays simple, controllers isolated
}
}Pros:
- No migration effort
- Familiar to current team
Cons:
- Mixed concerns remain
- Scalability issues
- Hard to onboard new contributors
- Doesn't follow industry patterns
Verdict: ❌ Short-term gain, long-term pain
src/presentation/
├── cli.rs
├── commands/
└── output.rs
Pros:
- Simple
- Easy to navigate
Cons:
- Doesn't scale to 5+ commands
- Mixes routing and handling
- Torrust has roadmap for many commands
Verdict:
crates/
├── cli/
├── commands/
├── output/
└── core/
Pros:
- Maximum isolation
- Reusable components
Cons:
- High complexity
- Overkill for CLI app
- Adds build complexity
Verdict:
src/presentation/
├── input/
├── dispatch/
├── controllers/
└── views/
Pros:
- ✅ Clear separation of concerns
- ✅ Industry-standard patterns
- ✅ Scales well
- ✅ Easy to test
- ✅ Familiar terminology
- ✅ Appropriate complexity
Cons:
⚠️ Migration effort (~12-18 hours)⚠️ More directories than current
Verdict: ✅ Best balance for our needs
Trade-off: More directories vs. clearer organization
Current: 3 main directories (cli, commands, user_output)
Proposed: 4 main directories (input, dispatch, controllers, views)
Assessment: Worth it for clarity
Trade-off: Breaking changes vs. better organization
Current:
use crate::presentation::commands::destroy::handle_destroy_command;
use crate::presentation::views::UserOutput;Proposed:
use crate::presentation::controllers::destroy::handle;
use crate::presentation::views::UserOutput;Mitigation: Phased migration with re-exports (see Refactor Plan)
Trade-off: Initial learning vs. long-term maintainability
Impact: New contributors need to understand layer pattern
Mitigation:
- Clear documentation
- Architecture diagrams
- Consistent patterns
- Industry-standard terminology
Current Proposal: Keep handlers as functions
pub fn handle(...) -> Result<()>Alternative: Introduce controller trait
pub trait Controller {
type Input;
fn execute(&self, input: Self::Input) -> Result<()>;
}Decision: Start with functions, evolve to traits if needed
Rationale:
- Simpler migration
- Less boilerplate initially
- Can add trait later without restructuring
- Functions are idiomatic Rust
- All layers have clear, single responsibilities
- Explicit router in
dispatch/layer - Controllers isolated in
controllers/layer - All output in
views/layer - No orphaned modules
- All tests passing
- Import paths updated
- Linters pass (markdown, rust, etc.)
- Documentation updated
- Architecture diagrams current
- Code follows project conventions
- New contributors can find code easily
- Adding commands is straightforward
- Routing logic is explicit
- Each layer testable independently
This proposal transforms the presentation layer from:
- ❌ Mixed concerns and implicit routing
- ✅ To clear, layered architecture with explicit boundaries
- Explicit routing - Dedicated
dispatch/layer - Clear organization - Four distinct layers
- Standard terminology -
views/instead ofuser_output/ - Integrated modules - No more orphaned files
- Better scalability - Easy to extend
- Industry alignment - Follows MVC/MVT patterns
- ✅ Research completed
- ✅ Current structure analyzed
- ✅ New design proposed
- ⏭️ Next: Create refactor plan with safe migration steps
- ⏭️ Then: Create GitHub issue for implementation
Proposal by: GitHub Copilot
Date: November 6, 2025
Status: Ready for review and refactor planning