Split the monolithic app.rs (762 lines) into four DDD layers: - domain/: models, navigation enums (Tab, ModalKind), conversions, aggregates - application/: App state, LibraryState, NotificationManager, event handlers - infrastructure/: config, gRPC client, system utilities - presentation/: all render functions, widgets, views, modals Original modules (app, data, ui, config, grpc) preserved as thin re-export facades for backward compatibility. All 13 insta snapshot tests pass without modification.
20 KiB
DDD Migration Plan — ui-agregator
Hard Constraint
Existing insta snapshot tests in tests/ui_snapshots.rs MUST pass without any changes to the test file or snapshot content. All current import paths must continue to resolve to the same types and functions producing identical rendering output.
Frozen Test Import Paths
use ui_agregator::app::Tab;
use ui_agregator::data::{Album, AlbumStatus, Artist, MonitorState};
use ui_agregator::ui::library::{LibraryFocus, LibraryState, render_library};
use ui_agregator::ui::modals::render_help_modal;
use ui_agregator::ui::progress_bar::progress_bar;
use ui_agregator::ui::topbar::render_topbar;
Strategy: Facade Re-exports
Create new DDD directories (domain/, application/, infrastructure/, presentation/), move code there verbatim, then turn existing app.rs, data/, and ui/ into thin re-export facades. Tests import from unchanged paths while actual code lives in proper DDD layers.
Target Architecture
src/
├── lib.rs ← Module declarations + new layers
├── main.rs ← Entry point (unchanged)
│
├── domain/ ← Pure business logic, zero UI deps
│ ├── mod.rs
│ ├── models.rs ← from data/models.rs
│ ├── navigation.rs ← Tab enum (from app.rs) — shared by application + presentation
│ ├── conversions.rs ← from app.rs (convert_artist, convert_album, convert_track)
│ └── aggregates.rs ← from ui/library.rs (artist_status)
│
├── application/ ← State management, orchestration
│ ├── mod.rs
│ ├── app_state.rs ← App struct (from app.rs) — Tab moved to domain
│ ├── library_state.rs ← LibraryState + LibraryFocus (from ui/library.rs)
│ ├── notification_state.rs ← NotificationManager, NotifKind, Notification (from ui/notifications.rs)
│ └── handlers.rs ← All handle_* methods (from app.rs)
│
├── infrastructure/ ← External concerns
│ ├── mod.rs
│ ├── config.rs ← from src/config.rs
│ ├── grpc/ ← from src/grpc/
│ │ └── mod.rs
│ └── system.rs ← get_free_space() from statusbar.rs
│
├── presentation/ ← UI rendering only
│ ├── mod.rs
│ ├── library.rs ← render_library + render helpers (from ui/library.rs)
│ ├── notifications.rs ← render + render_notification_item (from ui/notifications.rs)
│ ├── topbar.rs ← from ui/topbar.rs
│ ├── statusbar.rs ← render only, syscall moved out
│ ├── pane.rs ← from ui/pane.rs
│ ├── progress_bar.rs ← from ui/progress_bar.rs
│ ├── modals/ ← from ui/modals/
│ └── views/ ← from ui/views/
│
├── theme.rs ← Unchanged (leaf node)
│
├── app.rs ← FACADE: re-exports from application
├── data/ ← FACADE: re-exports from domain
│ └── mod.rs
├── config.rs ← FACADE: re-exports from infrastructure
├── grpc/ ← FACADE: re-exports from infrastructure
│ └── mod.rs
└── ui/ ← FACADE: re-exports from presentation + application
├── mod.rs
├── library.rs ← re-exports LibraryFocus, LibraryState, render_library
├── modals/
│ └── mod.rs ← re-exports from presentation/modals
├── progress_bar.rs ← re-exports progress_bar
├── topbar.rs ← re-exports render_topbar
├── notifications.rs ← re-exports from application + presentation
├── statusbar.rs ← re-exports from presentation
└── pane.rs ← re-exports from presentation
Bounded Contexts (Lightweight)
For a single TUI binary, use sub-domains within the domain layer:
| Sub-domain | Entities | Value Objects |
|---|---|---|
| Library | Artist, Album, Track | AlbumStatus, MonitorState |
| Acquisition | WantedEntry, QueueEntry | — |
| Activity | HistoryEntry, CalendarEntry, Notification | NotifKind |
All live in domain/models.rs for now — separate into sub-modules only if they grow significantly.
Phase 1: Infrastructure Extraction (Safest)
Goal: Move external concerns. Zero test path impact.
Moves
| Source | Destination | Items |
|---|---|---|
src/config.rs |
src/infrastructure/config.rs |
Entire file (Config, ServerConfig) |
src/grpc/mod.rs |
src/infrastructure/grpc/mod.rs |
Entire file (GrpcClient, GrpcRequest, GrpcResponse, spawn_grpc_worker) |
src/ui/statusbar.rs line 12-25 |
src/infrastructure/system.rs |
get_free_space() only |
Facade Files
// src/config.rs → facade
pub use crate::infrastructure::config::*;
// src/grpc/mod.rs → facade
pub use crate::infrastructure::grpc::*;
Risk: Minimal
No test paths affected. config and grpc are only used by main.rs.
Verification
cargo build && cargo test
Phase 2: Domain Layer Creation
Goal: Extract pure domain models, shared navigation types, and business logic.
Moves
| Source | Destination | Items |
|---|---|---|
src/data/models.rs |
src/domain/models.rs |
All types: AlbumStatus, MonitorState, Artist, Album, Track, WantedEntry, QueueEntry, HistoryEntry, CalendarEntry |
src/app.rs Tab enum + impls (lines 26-68) |
src/domain/navigation.rs |
Tab enum (see Risk Resolution 1) |
src/ui/modals/mod.rs ModalKind enum (lines 7-11) |
src/domain/navigation.rs |
ModalKind enum (see Risk Resolution 6) |
src/app.rs (convert_artist, convert_album, convert_track, parse_year, format_duration) |
src/domain/conversions.rs |
5 functions (~90 lines) |
src/ui/library.rs (artist_status fn) |
src/domain/aggregates.rs |
artist_status() (~12 lines) |
Facade Files
// src/data/mod.rs → facade
pub use crate::domain::models::*;
// src/app.rs must re-export Tab for test compat
pub use crate::domain::navigation::Tab;
// src/domain/mod.rs
pub mod models;
pub mod navigation;
pub mod conversions;
pub mod aggregates;
Risk: Low
data::* path preserved via re-export. app::Tab path preserved via re-export. Domain models are leaf nodes with zero internal deps.
Verification
cargo test — all ui_agregator::data::* and ui_agregator::app::Tab imports still resolve.
Phase 3: Application Layer — State Types
Goal: Extract state management types and logic.
Moves
| Source | Destination | Items |
|---|---|---|
src/app.rs lines 70-131 |
src/application/app_state.rs |
App struct + Default impl + App::new() (Tab already moved to domain in Phase 2) |
src/ui/library.rs lines 18-198 + 593-650 |
src/application/library_state.rs |
LibraryFocus, LibraryState (all fields + all methods: new, selected_artist, selected_album, move_up/down, focus_left/right, cycle_focus, cache_tracks, needs_fetch, clear_cache, etc.) |
src/ui/notifications.rs lines 15-111 + 189-196 + 252-260 |
src/application/notification_state.rs |
NotifKind, Notification, NotificationManager (all fields + push, tick, history, active_count, history_count), format_elapsed |
Pre-requisite: Add active() getter to NotificationManager
Before moving, add this public getter (see Risk Resolution 2):
pub fn active(&self) -> &[Notification] {
&self.active
}
This allows Phase 4 to convert render() to a free function without accessing private fields.
Critical Re-exports (preserve test paths)
// src/app.rs → facade
pub use crate::domain::navigation::Tab;
pub use crate::application::app_state::App;
// src/ui/library.rs → facade
pub use crate::application::library_state::{LibraryFocus, LibraryState};
pub use crate::presentation::library::render_library;
// src/ui/notifications.rs → facade
pub use crate::application::notification_state::*;
pub use crate::presentation::notifications::render_notification_item;
Risk: Medium
Most refactoring happens here. LibraryState has private fields (tracks_cache, pending_album_id) — same crate so visibility is fine.
Mitigation
Move types first, verify tests, then move methods, verify again.
Verification
cargo test — snapshot tests must pass unchanged.
Phase 4: Presentation Layer — Render Functions
Goal: Move all rendering code.
Moves
| Source | Destination | Items |
|---|---|---|
src/ui/library.rs lines 200-591 |
src/presentation/library.rs |
render_library, render_artists_pane, render_detail_pane, render_artist_header, render_albums_list, render_tracks_list, status_icon, monitor_state_icon, track_icon, fmt_size |
src/ui/notifications.rs lines 113-250 |
src/presentation/notifications.rs |
NotificationManager::render (as free fn taking &NotificationManager), render_notification_item |
src/ui/topbar.rs |
src/presentation/topbar.rs |
Entire file (render_topbar, TopbarAreas) |
src/ui/progress_bar.rs |
src/presentation/progress_bar.rs |
Entire file (progress_bar fn) |
src/ui/pane.rs |
src/presentation/pane.rs |
Entire file (Pane, section_divider) |
src/ui/statusbar.rs |
src/presentation/statusbar.rs |
render_statusbar (with get_free_space imported from infrastructure) |
src/ui/modals/ |
src/presentation/modals/ |
Entire directory (ModalKind, render_help_modal, render_quit_modal) |
src/ui/views/ |
src/presentation/views/ |
Entire directory (render_calendar, render_history, render_queue, render_settings, render_wanted) |
Facade Files
// src/ui/topbar.rs → facade
pub use crate::presentation::topbar::*;
// src/ui/progress_bar.rs → facade
pub use crate::presentation::progress_bar::*;
// src/ui/modals/mod.rs → facade
pub use crate::presentation::modals::*;
// src/ui/pane.rs → facade
pub use crate::presentation::pane::*;
// src/ui/statusbar.rs → facade
pub use crate::presentation::statusbar::*;
Risk: Low-Medium
Mostly file moves. Rendering code moves verbatim — no logic changes means no snapshot changes.
Verification
cargo test — all 13 tests pass, snapshots unchanged.
Phase 5: Application Layer — Event Handlers
Goal: Extract event handling from app.rs.
Moves
| Source | Destination | Items |
|---|---|---|
src/app.rs handle_escape, handle_click, handle_modal_click, handle_topbar_click, handle_notification_click, handle_main_click, handle_library_click, select_list_item, handle_scroll, scroll_library_list, handle_tick, set_error, handle_grpc_response, pending_album_fetch |
src/application/handlers.rs |
All event handler methods |
src/app.rs scroll_list_state |
src/application/handlers.rs |
Free function |
Implementation
Use split impl blocks (Rust allows impl blocks in multiple files within the same crate):
// src/application/handlers.rs
use crate::application::app_state::App;
impl App {
pub fn handle_escape(&mut self) { ... }
pub fn handle_click(&mut self, x: u16, y: u16, button: MouseButton) { ... }
// ...all other handlers
}
Risk: Medium
Must manage imports carefully. All handler methods access App fields directly.
Verification
cargo test && cargo clippy, manual TUI smoke test.
Phase 6: Final app.rs Cleanup + draw() Extraction
Goal: Move rendering orchestration out of App, reduce app.rs to pure facade.
Moves
| Source | Destination | Items |
|---|---|---|
src/app.rs App::draw, render_main_content, render_notifications_dropdown, get_position |
src/presentation/app_renderer.rs |
All draw/render methods |
Final app.rs
pub use crate::domain::navigation::Tab;
pub use crate::application::app_state::App;
Final lib.rs
pub mod domain;
pub mod application;
pub mod infrastructure;
pub mod presentation;
pub mod theme;
// Facade modules (backward compat)
pub mod app;
pub mod config;
pub mod data;
pub mod grpc;
pub mod proto;
pub mod ui;
Risk: Highest
Final wiring — all re-exports must be correct.
Verification
cargo test, cargo clippy, run the actual TUI end-to-end.
Risk Resolutions
Risk 1: Tab Circular Dependency — RESOLVED
Problem: presentation/topbar.rs imports Tab from app.rs. If Tab lives in application/, then presentation depends on application, and application's App::draw() depends on presentation — creating a cycle.
Investigation: Tab is used in exactly 3 locations:
src/app.rs— definition + usage in App struct, event handlers, render routingsrc/ui/topbar.rs— imported asuse crate::app::Tab, used to build tab list and compare active tabtests/ui_snapshots.rs— imported asuse ui_agregator::app::Tab, used in test data
Tab has no business logic dependencies. It's a pure enum with display helpers (label(), index()). No method on Tab calls into application or presentation code.
Solution: Move Tab to domain/navigation.rs. Both application and presentation import from domain — dependency flows downward only:
domain/navigation.rs (defines Tab)
↑ ↑
application/app_state.rs presentation/topbar.rs
(App.tab field) (renders tabs)
Re-export chain for test compatibility:
// src/domain/navigation.rs — canonical location
pub enum Tab { Library, Wanted, Queue, History, Calendar, Settings }
// src/app.rs — facade re-export
pub use crate::domain::navigation::Tab;
// src/presentation/topbar.rs — imports from domain
use crate::domain::navigation::Tab;
Test path ui_agregator::app::Tab continues to work via the facade.
Risk 2: NotificationManager::render() Split — RESOLVED
Problem: NotificationManager::render() is an impl method. When the type moves to application/ and render moves to presentation/, the presentation layer can't add impl blocks to application types without creating a dependency cycle.
Investigation: render() accesses exactly ONE private field: self.active (the Vec of active notifications). All Notification fields are already public. render_notification_item() is already a free function — good precedent.
From app.rs, NotificationManager is used via these public methods only:
.history_count()— already pub.history()— already pub, returns&[Notification].render(frame, area)— the method to split.tick()— already pub.push(...)— already pub
Solution: Add one pub getter, convert render to free function:
// Step 1: Add getter to NotificationManager (in Phase 3, before the split)
pub fn active(&self) -> &[Notification] {
&self.active
}
// Step 2: Convert render() to free function (in Phase 4)
// src/presentation/notifications.rs
pub fn render_notifications(frame: &mut Frame, area: Rect, active: &[Notification]) {
// Identical logic, `self.active` becomes `active` parameter
}
// Step 3: Update call site in app.rs draw() (Phase 6)
// Before: self.notifications.render(frame, area);
// After: render_notifications(frame, area, self.notifications.active());
No snapshot impact — render logic is identical, just parameterized differently.
Risk 3: Private Fields Across Layers — RESOLVED
Problem: LibraryState has private fields (tracks_cache, pending_album_id). When type moves to application/ and render code moves to presentation/, can presentation still work?
Investigation: Verified that presentation/library.rs render functions access LibraryState through these paths only:
state.artists— pub fieldstate.tracks— pub fieldstate.focus— pub fieldstate.artist_state/state.album_state/state.track_state— pub fields (ListState for stateful widgets)state.selected_artist()— pub methodstate.selected_album()— pub method
The private fields (tracks_cache, pending_album_id) are only accessed by LibraryState's own methods (cache_tracks, needs_fetch, load_tracks_from_cache, clear_cache) — all of which stay with the type in application/library_state.rs.
Conclusion: No new getters needed. The existing public API is sufficient for the presentation layer.
Risk 4: impl Block Splitting — RESOLVED
Problem: After migration, App has impl blocks in 3 files: application/app_state.rs, application/handlers.rs, presentation/app_renderer.rs. Will Rust allow this?
Investigation: Rust allows multiple impl blocks for a type anywhere within the same crate. The only requirement is that the type is accessible via use. Since all three files are in the same crate, this works:
// application/app_state.rs — struct definition + new()
pub struct App { ... }
impl App { pub fn new() -> Self { ... } }
// application/handlers.rs — event handlers
use crate::application::app_state::App;
impl App { pub fn handle_click(&mut self, ...) { ... } }
// presentation/app_renderer.rs — draw methods
use crate::application::app_state::App;
impl App { pub fn draw(&mut self, frame: &mut Frame) { ... } }
Caveat: presentation/app_renderer.rs adds impl blocks to an application type. This means presentation depends on application — which is architecturally correct (presentation calls down to application, not up). But it does mean draw() can access private fields of App, which is acceptable since it needs self.tab, self.modal, self.queue, etc.
Conclusion: No issue. This is standard Rust practice for organizing large impl blocks.
Risk 5: use crate:: Path Updates — RESOLVED
Problem: Every moved file has use crate::data::*, use crate::ui::* etc. These must be updated.
Solution: Two options:
Option A (Recommended): Import from facade modules. Moved files keep importing use crate::data::*, use crate::ui::pane::* etc. Since facades re-export everything, these paths still work. No import changes needed in moved files.
Option B: Import from canonical locations. Update all imports to use crate::domain::*, use crate::presentation::pane::* etc. Cleaner but more churn.
Recommendation: Use Option A during migration (zero import changes in moved files), then optionally do a cleanup pass later with Option B. This eliminates an entire class of errors during the migration.
Risk 6: ModalKind Placement — RESOLVED
Problem: ModalKind enum is defined in ui/modals/mod.rs and used in app.rs (matching on self.modal). After migration, ModalKind moves to presentation/modals/ but App (application layer) matches on it — application depends on presentation.
Investigation: ModalKind is used in:
src/ui/modals/mod.rs— definitionsrc/app.rsline 180-184 —match modal { ModalKind::Help => ..., ModalKind::Quit => ... }
Solution: Move ModalKind to domain/navigation.rs alongside Tab — both are simple navigation/state enums shared across layers:
// src/domain/navigation.rs
pub enum Tab { Library, Wanted, Queue, History, Calendar, Settings }
pub enum ModalKind { Help, Quit }
Re-export from ui/modals/mod.rs facade for backward compatibility:
pub use crate::domain::navigation::ModalKind;
pub use crate::presentation::modals::{render_help_modal, render_quit_modal};
Application layer imports ModalKind from domain. Presentation layer imports it from domain. No cross-layer dependency.