Swapping a CLI for a TUI Without Touching Business Logic: A Hexagonal Architecture Story in Rust #
I built a task manager CLI in Rust using hexagonal architecture. Then I ripped out the entire user interface and replaced it with an interactive TUI — without changing a single line of domain or application code. Here’s how it went, what I learned about Rust’s type system along the way, and why the architecture paid for itself.
The Starting Point #
todo-cli is a simple task manager written in Rust. You can add tasks, list them, mark them done, delete them. Nothing groundbreaking — but the interesting part was always the architecture.
The project uses hexagonal (ports & adapters) architecture with what’s sometimes called “screaming architecture” — the folder structure screams the domain, not the framework. The layers are strict:
- Domain: pure business logic. Task entities, status transitions, validation rules. Zero external dependencies.
- Application: use case orchestration.
AddTaskServicetakes a repo, creates a task, saves it. That’s it. - Ports: trait definitions.
TaskRepositoryis the contract — save, list, find, delete. - Adapters: the outside world. A
JsonFileTaskRepositoryfor persistence, and originally aclap-based CLI for the user interface.
The CLI worked fine. You’d run todo-cli add "Buy groceries", todo-cli list, todo-cli done <uuid>. Standard stuff. But I wanted something more interactive — a terminal UI where you could navigate tasks, toggle their status, add and edit without memorizing UUIDs.
The question was: would the hexagonal architecture actually deliver on its promise? Could I swap the entire presentation layer without the change rippling into the core?
The Decision #
The answer came almost immediately when I looked at the dependency graph. The CLI adapter — adapters/cli/ with its clap parsing, its printer module, its CliError type — depended on the application layer. The application layer depended on ports and domain. Domain depended on nothing.
The arrows all pointed inward. Replacing the CLI meant replacing one leaf of the dependency tree. Everything upstream was untouched.
I chose ratatui as the TUI framework (it’s the de facto standard for Rust TUIs) with crossterm as the terminal backend. The plan was simple: delete pub mod cli, write pub mod tui, rewire main.rs. Seven steps total.
For the internal architecture of the TUI itself, I went with a pragmatic variant of TEA (The Elm Architecture) — a Model/View/Update loop. An App struct holds all the state, a draw() function renders it, and event handlers mutate the state. Clean separation even within the adapter.
Making Invalid States Unrepresentable #
The first real design decision came early: how to model the TUI’s interaction modes.
A TUI app needs to behave differently depending on context. In Normal mode, pressing q quits. In text input mode, pressing q types the letter “q”. Same key, completely different meaning. The app needs modes.
The naive approach is boolean flags:
struct App {
is_adding: bool,
is_confirming_delete: bool,
}This creates four possible combinations — but only three are valid. (true, true) means the user is simultaneously adding a task AND confirming a deletion, which makes no sense. You’d need runtime checks everywhere to guard against the impossible state.
Instead, I used an enum:
enum InputMode {
Normal,
Adding,
ConfirmDelete,
}Exactly three values. The fourth, impossible state literally cannot exist in memory. The compiler enforces it. When I later added Editing as a fourth mode, every match on InputMode in the codebase flagged a missing arm. The compiler told me exactly where I needed to handle the new mode. No grep, no “find all references,” no hoping I didn’t miss one.
This is the Rust principle of making invalid states unrepresentable. It’s the same idea behind Option<T> instead of null pointers, and Result<T, E> instead of error codes. Push your invariants into the type system, and entire categories of bugs become compilation errors.
The Ownership Puzzle #
Here’s a Rust-specific challenge I hit when wiring up the TUI.
The use case services take ownership of the repository:
impl<R: TaskRepository> AddTaskService<R> {
pub fn new(repo: R) -> Self { Self { repo } }
}But the App struct needs to keep its repo alive for the entire session — the user might add a task, then list, then delete, then add another. If I moved the repo into a service, the App would lose it after the first operation.
The solution was .clone(). But when someone says “just clone it” in Rust, my instinct is to flinch. Cloning can be expensive. Is it here?
I looked at what JsonFileTaskRepository actually is:
pub struct JsonFileTaskRepository {
file_path: PathBuf, // that's it
}A PathBuf. About 50 bytes. Cloning it allocates a new heap string and copies the file path. That’s it. No file handles, no connection pools, no caches. The clone cost is noise compared to the I/O the service does next (read file, parse JSON, modify, serialize, write file).
I considered alternatives — &mut borrows (lifetime complications), Rc<RefCell<>> (overkill for a string wrapper), changing the port trait signatures (breaks all layers). Clone was the textbook right answer. But the exercise of asking “what does this clone actually do?” is worth doing every time. The answer changes depending on what’s inside the struct.
Building the View Layer #
Ratatui’s rendering model is immediate-mode: every frame, you describe the entire screen from scratch. There’s no retained widget tree, no diffing. You call terminal.draw(|frame| ...) and paint widgets onto rectangular areas.
I split the screen into three vertical zones:
┌────────────── TODO Tasks ──────────────┐
│ ID STATUS TITLE DATES │ ← Table (flexible height)
│ a1b2.. [x] DONE Buy milk ... │
│ c3d4.. [ ] TODO Write tests ... │
├────────────────────────────────────────┤
│ [a]dd [e]dit [d]el [x]toggle [f] [q] │ ← Command bar (1 row)
│ Filter: All | 2 tasks │ ← Status bar (1 row)
└────────────────────────────────────────┘The table uses render_stateful_widget with a TableState for row highlighting. UUIDs are truncated to 8 characters (4 billion combinations is plenty for a personal task list). Timestamps show Mar 21 14:30 instead of full ISO-8601. Status is color-coded: yellow for TODO, green for DONE.
One subtle detail: the table’s Block (the border) must be passed to the Table via .block(), not rendered separately. If you render them independently, they fight for the same area and the table content overlaps the border. This cost me a few minutes of “why does my layout look wrong.”
The Terminal Restore Problem #
Here’s a gotcha that’s specific to TUI apps and easy to miss.
When ratatui starts, it does three things: enables raw mode (keystrokes come one-at-a-time), enters an alternate screen (so the TUI doesn’t trash your scrollback), and hides the cursor. When the app exits, all three must be undone.
If the main loop panics or returns an error via ?, the cleanup code never runs. Your terminal is left in raw mode with an invisible cursor. The only fix is blindly typing reset or closing the window.
Rust doesn’t have finally. But there’s a clean pattern:
fn run(repo: R) -> Result<(), Box<dyn std::error::Error>> {
let mut app = App::new(repo)?;
let mut terminal = ratatui::init();
let result = loop_app(&mut terminal, &mut app);
ratatui::restore(); // ALWAYS runs
result // THEN propagate the error
}Capture the result, clean up, return the result. It’s the Rust equivalent of Go’s defer or Java’s finally. Simple, but you have to know to do it.
The Migration Moment of Truth #
After seven steps of incremental work — update deps, scaffold modules, build state, implement rendering, wire events, rewire main, verify — I ran the checks:
cargo build: OK
cargo test: 12 passed, 0 failed
cargo clippy: 0 warningsAnd here’s the part that made me smile:
Files changed: Cargo.toml, main.rs, adapters/mod.rs, plus 5 new files in adapters/tui/.
Files NOT changed: everything in domain/, application/, ports/, and adapters/persistence/. Zero. All 12 existing tests passed without modification.
The hexagonal architecture did exactly what it promised. The entire presentation layer was a replaceable adapter, and replacing it touched nothing else. The domain didn’t know or care whether it was being driven by clap arguments or ratatui key events.
Polish: The Last Mile #
A working TUI and a good TUI are different things. The first version was functionally correct but visually flat — monochrome text, 36-character UUIDs eating screen width, no feedback when actions succeeded, no guidance when the task list was empty.
I spent a focused batch on five improvements:
Color coding made the interface scannable. TODO in yellow, DONE in green. Shortcut keys colored by category — cyan for creation, red for destructive, magenta for filter. The status bar shows transient feedback: “Task added: Buy groceries” appears after creating a task and disappears when you navigate.
Empty state handling replaced the blank table with a centered message: “No tasks yet. Press [a] to add one.” It’s a small thing, but first-time users shouldn’t stare at an empty screen wondering what to do.
The input popup was a bigger change. Originally, text input happened in the command bar — a single row at the bottom of the screen. It worked, but it was cramped and easy to miss. I replaced it with a centered modal popup that overlays the task list:
│ ┌──────────────────────┐ │
│ │ New Task │ │
│ │ │ │
│ │ Buy groceries█ │ │
│ │ │ │
│ │ [Enter] ok [Esc] no │ │
│ └──────────────────────┘ │The popup is built by calculating a centered Rect using nested layout splits, rendering a Clear widget to wipe the background, then drawing the bordered input area on top. Ratatui’s immediate-mode rendering makes this trivial — you just draw the popup after the normal UI, and it overwrites whatever’s underneath.
Event polling fixed a subtle performance issue. The original loop used blocking event::read(), which meant every keystroke had to wait for a full draw cycle before the next key could be processed. Fast typing felt sluggish. Switching to event::poll(16ms) made the loop non-blocking — it redraws at ~60fps and processes keys the instant they arrive. The difference was immediately noticeable.
Adding Edit: The Vertical Slice Test #
The real test of any architecture is how easy it is to add features. I had a perfect candidate: the domain already had an edit_title() method that was implemented but never wired to any UI.
Adding the edit feature required touching three layers:
-
Application: A new
EditTaskServicefollowing the existing pattern —find_by_id → edit_title → save. The file was a near-copy ofMarkTaskDoneServicewith different method calls. -
TUI adapter: A new
InputMode::Editingvariant (the compiler flagged every match that needed updating), astart_editing()method that pre-fills the input buffer with the current title, and ahandle_editing_mode()event handler. -
Domain: zero changes. The method was already there, already validated, already handling edge cases (empty titles, timestamp updates). This is the hexagonal payoff — the domain was ready before the feature was even planned.
The whole thing took maybe 15 minutes. Most of that was the application layer boilerplate. The TUI changes were mechanical — I already had the patterns from Adding mode.
Key Takeaways #
Hexagonal architecture earns its keep at swap time. The overhead of defining ports, keeping layers separate, and resisting the urge to put domain logic in adapters — it all pays off the moment you need to replace a layer. The CLI-to-TUI migration was the proof.
Rust’s enums are a state machine DSL. InputMode started with 3 variants and grew to 4. Each addition was safe because match is exhaustive. The compiler is your state machine verifier — it won’t let you forget a case.
“What does this clone actually do?” is always worth asking. The answer ranges from “copies 50 bytes” to “duplicates a database connection pool.” Same syntax, wildly different implications. Know what’s inside your structs.
Non-blocking event loops matter for TUIs. event::read() vs event::poll() is the difference between typing that feels laggy and typing that feels instant. Always poll with a timeout.
The last 20% of UX is 80% of the experience. Color coding, empty states, transient feedback, modal popups — none of these change functionality, but they transform a tool you tolerate into one you enjoy using.
Where Things Stand #
The TUI is fully functional: add, edit, delete, toggle status, filter by status, keyboard navigation. The architecture is clean — domain knows nothing about terminals, the TUI adapter is self-contained, persistence is swappable.
There’s a list of future modes I’d like to explore: a help overlay (?), inline search/filtering, a detail view for full task information, sort options. Each would be a new InputMode variant and a bit of rendering code. The foundation is solid.
The code is at github.com/todo-cli. The LOGBOOK.md in the repo has the full development diary with every decision, dead end, and lesson learned. If you’re building a TUI in Rust or thinking about hexagonal architecture, I hope this gives you a useful reference point.
The most satisfying moment? Running cargo test after the migration and seeing all 12 tests pass unchanged. That’s the sound of architectural boundaries holding up under pressure.