Documentation
adrs/004-roslyn-analyzer-enforcement.md
ADR-004: Roslyn Analyzer for Architectural Rule Enforcement
Status
Accepted
Context
The Polylith architecture depends on a critical rule: components must only reference other components' interfaces (.Spec projects), never their implementations. This rule ensures:
- Loose coupling: Components remain decoupled at compile time
- Modularity: Components can be swapped without recompilation
- Runtime flexibility: Dynamic component loading works correctly
- Clear boundaries: Architectural intent is enforced
However, enforcing this rule manually is error-prone and relies on:
- Developer discipline: Remembering not to add forbidden references
- Code review process: Catching violations during peer review
- Runtime failures: Discovering violations when components fail to load
The challenge was how to enforce this architectural rule automatically and catch violations at compile time, not runtime.
Several enforcement approaches were considered:
- Manual code reviews: Rely on human review process
- Build scripts: Custom PowerShell/bash scripts to validate references
- MSBuild tasks: Custom MSBuild tasks to check project references
- Unit tests: Reflection-based tests to verify assembly references
- Roslyn analyzers: Compile-time code analysis with real-time feedback
Decision
We implemented a custom Roslyn analyzer (SpecReferenceRequirementAnalyzer) that enforces the architectural rule at compile time.
Analyzer Rule: ACSIS0001
- Severity: Error (build-breaking)
- Scope: All Acsis assemblies
- Rule: Only
.Specassemblies may be referenced across components
Implementation Details:
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class SpecReferenceRequirementAnalyzer : DiagnosticAnalyzer
Detection Logic:
- Analyze all code operations (method calls, object creation, property access)
- Check if the target member is from an Acsis assembly
- Verify the target assembly ends with
.Spec - Report violation if non-.Spec assembly is referenced
Integration:
- Included in all component and base projects via
Directory.Build.props - Runs during every build and in IDE real-time
- Provides immediate feedback to developers
Consequences
Positive
Architectural Integrity:
- Compile-time enforcement: Violations caught before runtime
- Automatic validation: No manual checking required
- Consistent application: Rule applied uniformly across all projects
- Early feedback: Developers get immediate notification of violations
Developer Experience:
- IDE integration: Real-time squiggles and error highlighting
- Clear error messages: Descriptive messages explain the violation
- Immediate fix guidance: Error points to exactly what needs to change
- Build integration: Prevents building code with architectural violations
Process Benefits:
- Reduced review burden: Automated checking reduces manual review needs
- Documentation: The analyzer serves as executable documentation of the rule
- Onboarding: New developers learn the rule through immediate feedback
- Consistency: Eliminates subjective interpretation of the architectural rule
Quality Assurance:
- Runtime reliability: Prevents component loading failures
- Maintainability: Enforces loose coupling that improves maintainability
- Refactoring safety: Violations surface immediately during refactoring
- Deployment confidence: Architectural violations can't reach production
Negative
Development Overhead:
- Analyzer complexity: Custom analyzer adds codebase complexity
- Maintenance burden: Analyzer itself needs to be maintained and updated
- Performance impact: Analysis adds slight overhead to build times
- Learning curve: Developers need to understand analyzer rules and messages
Rigidity Concerns:
- Edge cases: May flag legitimate cases that should be exceptions
- Innovation blocking: Might prevent valid architectural experiments
- Migration challenges: Could complicate migration from legacy code
- False positives: Analyzer might incorrectly flag valid code patterns
Technical Limitations:
- Reflection gaps: May not catch all forms of dynamic references
- Runtime binding: Can't analyze reflection-based component access
- Build tool dependency: Adds dependency on Roslyn analyzer infrastructure
- Version compatibility: Must be maintained for .NET version compatibility
Debugging Complexity:
- Analyzer debugging: Troubleshooting analyzer issues is specialized skill
- Build failures: Cryptic analyzer failures can be difficult to diagnose
- IDE integration: Inconsistent behavior across different IDEs
- Suppression complexity: Properly suppressing legitimate violations
Mitigation Strategies
Handling Edge Cases:
// Suppress for legitimate violations (sparingly used)
#pragma warning disable ACSIS0001
using Acsis.Dynaplex.Engines.CoreData; // Justified reason documented
#pragma warning restore ACSIS0001
Clear Error Messages:
private const string MessageFormat =
"Assembly '{0}' references assembly '{1}'. Only specification assemblies (.Spec) may be referenced across bricks.";
Comprehensive Testing:
// Test analyzer with various scenarios
[Test]
public void Should_Allow_Spec_References() { /* ... */ }
[Test]
public void Should_Reject_Implementation_References() { /* ... */ }
Documentation and Training:
- Clear documentation of when suppressions are appropriate
- Training materials explaining the architectural rule
- Examples of correct and incorrect reference patterns
Implementation Details
Core Analysis Logic:
private static void StartAnalysis(CompilationStartAnalysisContext ctx) {
var referencingAsmName = ctx.Compilation.Assembly.Name;
ctx.RegisterOperationAction(
opCtx => {
var referencedAssembly = GetAssembly(opCtx.Operation);
if (referencedAssembly?.Name?.StartsWith("Acsis.") == true &&
!referencedAssembly.Name.EndsWith(".Spec")) {
opCtx.ReportDiagnostic(
Diagnostic.Create(_forbiddenRefRule,
opCtx.Operation.Syntax.GetLocation(),
referencingAsmName,
referencedAssembly.Name));
}
},
OperationKind.Invocation,
OperationKind.ObjectCreation,
OperationKind.FieldReference,
OperationKind.PropertyReference
);
}
Project Integration:
<!-- In component projects -->
<ItemGroup>
<ProjectReference Include="$(Analyzers)"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false"/>
</ItemGroup>
Error Example:
Error ACSIS0001: Assembly 'Acsis.Foundation' references assembly 'Acsis.Dynaplex.Engines.CoreData'.
Only specification assemblies (.Spec) may be referenced across bricks.
Effectiveness Metrics
Since implementation, the analyzer has:
- Prevented violations: Caught numerous accidental implementation references
- Guided development: Helped developers understand the architectural pattern
- Improved reliability: Eliminated runtime component loading failures due to reference violations
- Reduced review time: Automated checking reduced manual code review burden
Related Decisions
- ADR-001: Architecture requiring reference restrictions
- ADR-002: .Spec naming that analyzer detects
- ADR-003: Component loading that requires clean references
Alternatives Considered
MSBuild Tasks
Pros: Integrated into build process, could check project references
Cons: Only catches project-level references, not code-level usage
Unit Tests
Pros: Simple to implement, flexible validation logic
Cons: Run too late (after code is written), not real-time feedback
Build Scripts
Pros: Simple scripting, easy to customize
Cons: Platform-specific, not integrated with IDE, poor developer experience
FxCop/Code Analysis Rules
Pros: Mature framework, established patterns
Cons: Being deprecated in favor of Roslyn analyzers, limited flexibility
Future Enhancements
- Additional Rules: Analyzers for other architectural patterns
- Configuration: Allow configuration of allowed/forbidden patterns
- Metrics: Collect metrics on architectural violations over time
- IDE Extensions: Enhanced IDE integration with quick fixes
- Documentation Generation: Generate architectural documentation from analyzer rules
The Roslyn analyzer has proven to be highly effective at maintaining architectural integrity while providing excellent developer experience through real-time feedback.