From Clean Architecture to Evolutionary Architecture: Building Systems that Learn Over Time
Clean Architecture works. It keeps your code organized and separates concerns properly. But here’s the thing - static boundaries struggle when your system needs to change fast.
What if your architecture could learn and adapt automatically? That’s where Evolutionary Architecture comes in.
The Problem with Static Boundaries
Clean Architecture gives you clear layers: Domain, Application, and Infrastructure. Each layer has rules about what can depend on what. This works great when your requirements stay the same.
But real systems change. New features arrive. Performance needs shift. Your domain model grows. Static boundaries start to feel like handcuffs.
You end up with two choices:
- Break the rules and create technical debt
- Refactor everything every time something changes
Neither option feels right.
What is Evolutionary Architecture?
Evolutionary Architecture is software that learns. It uses fitness functions - automated tests that measure how well your architecture meets its goals.
Think of fitness functions like health checks for your architecture. They run continuously and tell you when something’s wrong.
Here’s how it works:
Fitness Functions measure architectural health automatically. They check things like:
- Are dependencies flowing the right direction?
- Is response time under your SLA?
- Are tests covering the right areas?
Architectural Drift happens when your code slowly moves away from your intended design. Fitness functions catch this early.
Feedback Loops connect your architecture to your CI/CD pipeline. When fitness functions fail, the system responds.
Bridging Clean Architecture with Evolution
You don’t need to throw away Clean Architecture. You can make it evolutionary.
Start with your existing layers:
Domain Layer
Your business logic stays here. But now you add fitness functions that check:
- Domain entities don’t depend on external frameworks
- Business rules are properly encapsulated
- Domain services follow single responsibility
Application Layer
This handles use cases and coordinates between layers. Fitness functions verify:
- Application services don’t leak infrastructure concerns
- Command/Query separation is maintained
- Transaction boundaries are correct
Infrastructure Layer
External concerns live here. Fitness functions ensure:
- Infrastructure doesn’t know about business logic
- Database queries stay in repositories
- External API calls are properly abstracted
Implementation Walkthrough
Let’s build this step by step. I’ll show you how to add fitness functions to a C# project using NetArchTest.
Step 1: Add Fitness Function Dependencies
dotnet add package NetArchTest.Rules
dotnet add package Microsoft.Extensions.Logging
Step 2: Create Your First Fitness Function
using NetArchTest.Rules;
using Xunit;
public class DependencyFitnessFunction
{
[Fact]
public void Domain_Should_Not_Depend_On_Infrastructure()
{
var result = Types.InAssembly(typeof(Domain.User).Assembly)
.Should()
.NotHaveDependencyOn("Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful,
$"Domain layer has {result.FailingTypes.Count} violations");
}
[Fact]
public void Application_Should_Not_Depend_On_Infrastructure()
{
var result = Types.InAssembly(typeof(Application.UserService).Assembly)
.Should()
.NotHaveDependencyOn("Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful,
$"Application layer has {result.FailingTypes.Count} violations");
}
[Fact]
public void Controllers_Should_Only_Depend_On_Application()
{
var result = Types.InAssembly(typeof(Web.Controllers.UserController).Assembly)
.That()
.HaveNameEndingWith("Controller")
.Should()
.OnlyHaveDependencyOn("Application")
.GetResult();
Assert.True(result.IsSuccessful,
$"Controllers have {result.FailingTypes.Count} dependency violations");
}
}
Step 3: Add Performance Fitness Functions
public class PerformanceFitnessFunction
{
[Fact]
public async Task User_Service_Response_Time_Under_100ms()
{
var userService = new UserService();
var stopwatch = Stopwatch.StartNew();
await userService.GetUserAsync(1);
stopwatch.Stop();
Assert.True(stopwatch.ElapsedMilliseconds < 100,
$"User service took {stopwatch.ElapsedMilliseconds}ms, expected < 100ms");
}
[Fact]
public void Database_Queries_Use_Proper_Indexes()
{
// This would integrate with your database to check query plans
var slowQueries = DatabaseAnalyzer.GetSlowQueries();
Assert.Empty(slowQueries);
}
}
Step 4: Create CI/CD Pipeline
name: Architecture Fitness Check
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
fitness-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Run Architecture Tests
run: dotnet test --filter "Category=Architecture" --logger trx --results-directory TestResults
- name: Publish Test Results
uses: dorny/test-reporter@v1
if: always()
with:
name: Architecture Fitness Results
path: TestResults/*.trx
reporter: dotnet-trx
- name: Fail on Architecture Violations
if: failure()
run: |
echo "Architecture fitness check failed!"
echo "Review the test results and fix violations before merging."
exit 1
Step 5: Add Monitoring Integration
public class MonitoringFitnessFunction
{
private readonly ILogger<MonitoringFitnessFunction> _logger;
private readonly IMetricsCollector _metrics;
public MonitoringFitnessFunction(ILogger<MonitoringFitnessFunction> logger,
IMetricsCollector metrics)
{
_logger = logger;
_metrics = metrics;
}
[Fact]
public async Task System_Health_Within_Acceptable_Range()
{
var healthMetrics = await _metrics.GetHealthMetricsAsync();
// Check response time
Assert.True(healthMetrics.AverageResponseTime < 200,
$"Average response time {healthMetrics.AverageResponseTime}ms exceeds 200ms threshold");
// Check error rate
Assert.True(healthMetrics.ErrorRate < 0.01,
$"Error rate {healthMetrics.ErrorRate:P2} exceeds 1% threshold");
// Check memory usage
Assert.True(healthMetrics.MemoryUsage < 0.8,
$"Memory usage {healthMetrics.MemoryUsage:P2} exceeds 80% threshold");
}
}
Case Study: E-commerce Evolution
Let’s see this in action with an e-commerce system.
Phase 1: Monolith
You start with a simple monolith. Clean Architecture keeps things organized, but everything runs in one process.
Fitness functions check:
- Domain logic stays pure
- Database queries are efficient
- Response times stay under 500ms
Phase 2: Modular Monolith
As you grow, you split into modules. Each module has its own domain, but they share infrastructure.
New fitness functions:
- Modules don’t depend on each other’s internals
- Shared infrastructure is properly abstracted
- Module boundaries are respected
Phase 3: Microservices
Eventually, you extract services. Each service has its own database and deployment.
Updated fitness functions:
- Services communicate only through APIs
- Database schemas are independent
- Service discovery works correctly
The key insight: Your fitness functions evolve with your architecture. They guide the transition, not block it.
Best Practices
Version Your Fitness Profiles
Don’t change fitness functions randomly. Version them like you version your API.
public class FitnessProfileV1
{
public static void ValidateDependencies() { /* V1 rules */ }
}
public class FitnessProfileV2
{
public static void ValidateDependencies() { /* V2 rules */ }
}
Use Metrics to Trigger Refactoring
Set up alerts when fitness functions start failing frequently. This tells you when to refactor.
public class FitnessTrendAnalyzer
{
public async Task<RefactoringRecommendation> AnalyzeTrendsAsync()
{
var recentFailures = await GetRecentFitnessFailuresAsync();
if (recentFailures.Count > 10)
{
return new RefactoringRecommendation
{
Priority = Priority.High,
SuggestedAction = "Consider architectural refactoring",
AffectedComponents = recentFailures.Select(f => f.Component).Distinct()
};
}
return RefactoringRecommendation.None;
}
}
Evolution Over Revolution
Don’t try to fix everything at once. Let your architecture evolve gradually.
Start with one fitness function. Get it working. Add another. Build momentum.
The Future: AI-Powered Architecture
This is where things get interesting. AI and LLMs can help with architectural decisions.
Imagine fitness functions that:
- Suggest architectural improvements based on code patterns
- Automatically refactor code to meet fitness criteria
- Predict architectural problems before they happen
public class AIArchitectureAdvisor
{
public async Task<ArchitecturalSuggestion> AnalyzeCodebaseAsync()
{
var codeAnalysis = await AnalyzeCodePatternsAsync();
var fitnessResults = await RunFitnessFunctionsAsync();
return await _aiService.GenerateSuggestionAsync(codeAnalysis, fitnessResults);
}
}
We’re not there yet, but the foundation is being laid.
Conclusion
Evolutionary Architecture bridges the gap between good design and changing requirements. It takes Clean Architecture and makes it adaptive.
The key is starting simple. Add one fitness function. See how it works. Build from there.
Your architecture should serve your business, not the other way around. When your business needs change, your architecture should adapt. Fitness functions make that adaptation guided and safe.
This isn’t about perfect architecture. It’s about architecture that learns and grows with your system.
Start with your next feature. Add a fitness function that matters. See what happens.
The future of software architecture is adaptive. The question is: will you evolve with it?
Ready to make your architecture evolutionary? Start with one fitness function and build from there. Your future self will thank you.
Join the Discussion
Have thoughts on this article? Share your insights and engage with the community.