Skip to content
Documentation GitHub
Architecture

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 parameter
pub 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.

application/Cargo.toml
[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 needed
application = { 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 call
let 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 everything
pub 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 case
let 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 pattern
pub 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; or use std::io; in application layer (non-test code)
  • Framework crate names in application layer Cargo.toml without optional = 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

Was this page helpful?