8.7 KiB
Deepening Application and Adapter Interfaces
Date: 2026-06-17
Status: Approved
Scope: Four architectural deepening refactors across the application and adapter layers
Background
Four architectural friction points identified via architecture review:
- I/O leaks across the seam into the application layer (
GenerateDiagram,CheckFreshness,DiffDiagram) - Orchestration logic (
build_graph) lives in the presentation layer instead of application - Renderer adapters duplicate import filtering and identifier sanitization
LanguageExtractortrait's singleanalyze()method gives no structure to implementors
All four refactors deepen existing modules — shrinking their interfaces and concentrating behavior — without adding new capabilities.
Candidate 1: Pull I/O out of the application layer
Files: crates/application/src/use_cases/generate_diagram.rs, check_freshness.rs, diff_diagram.rs
GenerateDiagram
Remove output_dir: Option<PathBuf> and format_ext: String fields. Remove write_split, write_file_to_dir free functions, and check_violations_only() method.
execute() returns Result<GenerateDiagramResult, DomainError>:
pub struct GenerateDiagramResult {
pub violations: Vec<RuleViolation>, // domain type, not pre-formatted strings
pub output: RenderOutput, // caller writes; split mode produces N files
}
Split-by-module rendering stays inside execute(): it builds a RenderOutput with an overview file plus one file per module. Presentation receives the RenderOutput and hands it to OutputWriter.
check_violations_only() is removed. Callers needing early-exit on violations (strict mode, watch mode) call execute() and inspect result.violations.
CheckFreshness
// before
pub existing_path: &'a std::path::Path,
// after
pub existing_content: &'a str,
execute() becomes a pure string comparison. Presentation reads the file with std::fs::read_to_string() before constructing the struct.
DiffDiagram
Same pattern: existing_path: &Path → existing_content: &str. Presentation reads the file.
Presentation impact
- Reads files before calling
CheckFreshness/DiffDiagram - After
GenerateDiagram::execute(): inspectsresult.violationsfor strict-mode bail, prints violations, callsoutput_writer.write(&result.output) write_splitandwrite_file_to_dirfree functions move into presentation (or are inlined)
Candidate 2: Sink graph-building orchestration into the application layer
Files: crates/application/src/use_cases/build_code_graph.rs (new), crates/presentation/src/lib.rs
New use case: BuildCodeGraph
pub struct BuildCodeGraph<F, S> {
pub discovery: F,
pub source_analyzer: S,
pub project_analyzer: Option<Box<dyn ProjectAnalyzer>>,
}
pub struct BuildCodeGraphResult {
pub graph: NormalizedGraph,
pub warnings: Vec<AnalysisWarning>,
}
impl<F: FileDiscovery, S: SourceAnalyzer> BuildCodeGraph<F, S> {
pub fn execute(
self,
root: &Path,
config: &AnalysisConfig,
level: DiagramLevel,
) -> Result<BuildCodeGraphResult, DomainError>
}
Logic inside execute():
DiagramLevel::Project→ callproject_analyzer.expect("project analyzer required for Project level").analyze(root), return viaNormalizedGraph::from_project(), empty warningsDiagramLevel::Module | Type→ callAnalyzeCodebase::execute(), collect warnings; atModulelevel, ifproject_analyzerisSome, merge its edges into the graph
Presentation impact
build_graph() in presentation/src/lib.rs drops from ~70 lines to ~25:
- Apply CLI overrides to
AnalysisConfig(scope, excludes, include_tests, changed_files) — stays in presentation, CLI-derived - Detect project analyzer:
if cargo_toml.exists() { Some(CargoWorkspaceAnalyzer) } else if pyproject.exists() { Some(PythonProjectAnalyzer) } else { None } - Construct
BuildCodeGraphand callexecute(root, config, level) - Handle warnings (
eprintln, strict bail) — stays in presentation
Candidate 3: Shared renderer primitives
Files: crates/adapters/rendering-primitives/src/lib.rs (new crate), all four renderer adapter crates
New crate: rendering-primitives
Depends only on archlens_domain. Exports two functions:
/// Returns all relationships excluding Import kind.
pub fn non_import_rels(rels: &[Relationship]) -> impl Iterator<Item = &Relationship> {
rels.iter().filter(|r| r.kind() != RelationshipKind::Import)
}
/// Sanitize an identifier by replacing common separator characters with underscore.
/// Handles `::`, `-`, `.`, and space.
pub fn sanitize_identifier(name: &str) -> String {
name.replace("::", "_").replace(['-', '.', ' '], "_")
}
Each of the four renderer adapter crates (mermaid, d2, ascii, html-viewer) adds rendering-primitives as a dependency.
Mermaid's sanitize_id strips only [-.] (not ::) — intentionally different because Mermaid handles qualified names via display_name(). It keeps its local variant; the shared sanitize_identifier is used by D2 and HTML. The universal win is non_import_rels, which replaces the identical filter expression in all four renderers.
Candidate 4: Typed LanguageExtractor pipeline
Files: crates/adapters/tree-sitter/src/language_extractor.rs, rust/mod.rs, python/mod.rs, tree_sitter_analyzer.rs
Revised LanguageExtractor trait
Replace the current single-method trait with a 3-method trait plus a run_extraction free function:
pub trait LanguageExtractor {
fn tree_sitter_language(&self) -> tree_sitter::Language;
fn extract_types(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
fn extract_relationships(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
fn extract_imports(&self, root: &Node, source: &str, ctx: &mut ExtractionContext);
}
pub fn run_extraction(
extractor: &dyn LanguageExtractor,
source: &str,
file_path: &FilePath,
) -> Result<AnalysisResult, DomainError>
run_extraction owns: create parser, set language, parse, call all 3 methods in order on the root node, return ctx.into_result().
Extractor changes
RustExtractor and PythonExtractor implement the new trait. Existing free functions are reorganised into the 3 methods — no logic changes:
extract_types→ wrapscollect_typesextract_relationships→ wrapscollect_relationshipsextract_imports→ wrapscollect_mod_declarations+collect_use_imports
TreeSitterAnalyzer::analyze_file() replaces extractor.analyze(source, path) with run_extraction(extractor, source, path).
Adding CSharp = implement 4 methods. No guesswork about what's required.
Execution order
- Candidate 1 — cleanest standalone win; establishes the pattern (pure use cases return data)
- Candidate 2 — independent of Candidate 1 but benefits from the pattern being in place;
BuildCodeGraphandGenerateDiagramare separate use cases with no shared code - Candidates 3 and 4 — independent of 1+2 and of each other; can run in parallel
FileOutputWriter (Directory variant) already iterates over all RenderedFiles in a RenderOutput, so split-by-module output producing N files in one RenderOutput requires no changes to the writer.
TDD behaviors to verify (by candidate)
Candidate 1
GenerateDiagram::execute()returnsRenderOutputwith rendered content (no filesystem side effects)GenerateDiagram::execute()returnsVec<RuleViolation>when rules are violatedGenerateDiagram::execute()withsplit_by_module=truereturnsRenderOutputwith multiple files (overview + per-module)CheckFreshness::execute()returnstruewhen rendered content equalsexisting_contentCheckFreshness::execute()returnsfalsewhen they differDiffDiagram::execute()returns correct added/removed lines
Candidate 2
BuildCodeGraph::execute()atProjectlevel delegates toProjectAnalyzer, returns its graphBuildCodeGraph::execute()atModulelevel merges project edges whenproject_analyzerisSomeBuildCodeGraph::execute()atTypelevel does not merge edges even whenproject_analyzerisSomeBuildCodeGraph::execute()withNoneproject analyzer atModulelevel skips merge cleanlyBuildCodeGraph::execute()propagatesAnalysisWarnings fromAnalyzeCodebase
Candidate 3
non_import_relsexcludesImportrelationships, passes throughInheritanceandCompositionsanitize_identifierreplaces::,-,., space with_
Candidate 4
run_extractioncallsextract_types,extract_relationships,extract_importsin orderRustExtractorextracts struct/enum/trait types viaextract_typesPythonExtractorextracts classes viaextract_types