Clean Architecture Layer Boundary Remediation Patterns
Clean Architecture Layer Boundary Remediation Patterns
Problem
Production code audit revealed 8 layer boundary violations across the application and framework layers. These patterns recur whenever features are built quickly and shortcuts bypass the architectural boundaries.
Symptoms:
use std::fs;in application layer use cases#[derive(specta::Type)](framework concern) as hard dependency in application layer- Tauri commands calling
repo.get_by_slug()before/after use case execution - Commands directly mutating via repo instead of through a use case
Patterns and Fixes
Pattern 1: Filesystem I/O in Application Layer
Violation: Application use case directly calls std::fs::write(), std::fs::read(), std::fs::create_dir_all().
Fix: Extract a trait in application, implement in infrastructure/framework.
// application/src/page/services.rs — trait definition (application layer)pub trait ExportWriter: Send + Sync { fn create_dir_all(&self, path: &Path) -> std::io::Result<()>; fn write_file(&self, path: &Path, content: &str) -> std::io::Result<()>;}
// commands/src/fs_providers.rs — implementation (framework layer)pub struct FsExportWriter;impl ExportWriter for FsExportWriter { fn create_dir_all(&self, path: &Path) -> std::io::Result<()> { std::fs::create_dir_all(path) } fn write_file(&self, path: &Path, content: &str) -> std::io::Result<()> { std::fs::write(path, content) }}
// Use case gains a generic parameterpub struct ExportPagesUseCase<R: PageRepository, W: ExportWriter> { ... }Key insight: std::path::Path as a parameter type is fine — it’s the I/O operations that violate boundaries.
Tests (#[cfg(test)]) are allowed to use std::fs directly.
Applied to: export.rs (ExportWriter), get_variant.rs (VariantCacheProvider), sync_coordinator.rs
(AttachmentFileProvider).
Pattern 2: Framework-Specific Derives as Hard Dependency
Violation: specta::Type derive (Rust→TypeScript codegen) as unconditional dependency in application crate.
Fix: Feature-gate behind an optional Cargo feature.
[dependencies]specta = { workspace = true, optional = true }// Before#[derive(Debug, Clone, serde::Serialize, specta::Type)]pub struct SyncStatus { ... }
// After#[derive(Debug, Clone, serde::Serialize)]#[cfg_attr(feature = "specta", derive(specta::Type))]pub struct SyncStatus { ... }# src-tauri/Cargo.toml — enable feature where neededapplication = { path = "../../crates/application", features = ["specta"] }Key insight: cargo check -p application (without feature) verifies non-framework consumers aren’t broken.
Framework crates opt in explicitly.
Pattern 3: Fetch-Before-Execute Anti-Pattern
Violation: Command handler fetches entity data (e.g., page ID) before calling a use case, then uses the data after the use case runs.
// Before — framework does extra repo calllet page_id = state.page_repo().get_by_slug(&slug).map(|p| p.id).ok();let deleted = use_case.execute(&guard, &slug, cascade)?;if let Some(id) = page_id { coordinator.on_page_deleted(id); }Fix: Enrich use case return types to include all data the caller needs.
// After — use case returns everythingpub struct DeletePageOutcome { pub deleted_count: usize, pub page_id: Option<Uuid>, // looked up inside the use case}
let outcome = use_case.execute(&guard, &slug, cascade)?;if let Some(id) = outcome.page_id { coordinator.on_page_deleted(id); }Key insight: Use Outcome suffix for application-layer result types to avoid naming conflicts with framework-layer
response types (e.g., DeletePageResult in the commands crate).
Pattern 4: Direct Repository Access Bypassing Use Case
Violation: Command handler directly calls repo.get_by_slug() → mutates entity → repo.save().
// Before — command handler IS the use caselet mut page = page_repo.get_by_slug(&slug)?;page.update_block_content(1, new_content);page_repo.save(&page)?;Fix: Create a proper use case.
// After — thin use case wraps the patternpub struct UpdateBlockContentUseCase<R: PageRepository> { repository: R }impl<R: PageRepository> UpdateBlockContentUseCase<R> { pub fn execute(&self, guard: &PermissionGuard, slug: &str, slot_id: u32, content: String) -> PageResult<Page> { guard.require(Capability::PagesWrite)?; let mut page = self.repository.get_by_slug(slug)?; page.update_block_content(slot_id, content); self.repository.save(&page)?; Ok(page) }}Key insight: Even “trivial” use cases add value — they enforce permission checks, provide a single place to add tracing/events, and keep command handlers as pure adapters.
Pattern 5: Fat Controller Decomposition
Violation: Single command file has 30+ commands and 1000+ lines.
Fix: Split into directory module with focused submodules.
commands/page.rs (1109 lines) → commands/page/mod.rs (re-exports) commands/page/crud.rs (6 commands) commands/page/tree.rs (3 commands) commands/page/blocks.rs (7 commands) commands/page/layout.rs (5 commands) ...Key insight: mod page; in Rust works for both page.rs and page/mod.rs — no changes needed in the parent module
or main.rs as long as mod.rs re-exports everything with pub use submodule::*;.
Prevention
Audit Checklist
Run periodically:
grep -rn 'std::fs' crates/application/src/ --include='*.rs' | grep -v '#\[cfg(test)\]' | grep -v 'mod tests'
Warning Signs
use std::fs;oruse std::io;in application layer (non-test code)- Framework crate names in application layer
Cargo.tomlwithoutoptional = true - Command handler with more than ~5 lines of logic between use case calls
- Repository method called directly in a command handler (should go through use case)
CI Gate
Consider adding a clippy lint or custom check to flag std::fs usage in the application crate.
References
- Commits:
8cb7caa7,c4a2274d,e6d7d11e,f2dbacd7,b3c919da - Architecture:
CLAUDE.md→ Architecture → Layer Organization
Write a SQLite Migration Next
CRDT Binary Must Pass Through Untouched — Never Re-serialize from Materialized Text
Was this page helpful?
Thanks for your feedback!