Evolutionary architecture: fitness functions, incremental change, last responsible moment, reversible decisions, modular monolith to services, ADRs, and architecture tests in CI.
Evolutionary architecture (Ford, Parsons and Kua) is architecture designed to support guided, incremental change, rather than a fixed upfront design. The key tool is fitness functions: automated checks that protect the characteristics you care about, such as dependency rules (architecture tests), latency budgets, or startup time, run in CI or production. Around that, I keep boundaries modular (often a modular monolith first), make decisions reversible where possible and defer irreversible ones to the last responsible moment, record decisions in ADRs, and rely on continuous delivery, feature flags and observability so each change is small and safe.
"Evolutionary architecture, from Neal Ford, Rebecca Parsons and Patrick Kua, is architecture that supports guided, incremental change across several dimensions, instead of a fixed design produced upfront. My goal isn't to pick the right architecture on day one. It's to build one that can become right as we learn. The central tool is fitness functions: automated, objective checks that protect the characteristics we care about. In .NET those are architecture tests with NetArchTest or ArchUnitNET for dependency and module rules, performance budgets with BenchmarkDotNet or k6, SLO alerts on OpenTelemetry metrics, vulnerability scans, OpenAPI breaking-change diffs, and startup or image-size budgets, all running in CI or continuously in production. Around that I keep boundaries modular with owned data (usually a modular monolith first, so modules can be extracted into services later when there's a real scaling, cadence or ownership reason). I prefer reversible decisions and defer irreversible ones to the last responsible moment. I record decisions in ADRs so they can be revisited when the context changes. And I rely on continuous delivery, feature flags and observability so every change is small and safe. Evolving architecture is also a people exercise: Conway's law means team structure and stakeholder alignment have to move with it."
| Principle | In practice |
|---|---|
| Incremental change | Small, frequent deploys; strangler fig / branch by abstraction for big shifts |
| Guided by fitness functions | Automated checks for the characteristics that matter |
| Appropriate coupling | Modules with explicit public APIs and owned data |
| Last responsible moment | Decide when you know enough, not earlier, but not so late that options vanish |
| Reversibility | Prefer choices that are cheap to undo; isolate expensive ones behind boundaries |
| Experimentation | Feature flags, canary releases, A/B tests to learn safely |
| Characteristic | Fitness function | Where it runs |
|---|---|---|
| Layering / module boundaries | NetArchTest or ArchUnitNET tests | CI |
| Performance | BenchmarkDotNet thresholds; k6 load tests against SLOs | CI / nightly |
| Latency, availability, error rate | SLO alerts from OpenTelemetry metrics (Azure Monitor, Prometheus/Grafana) | Production (continuous) |
| Security | dotnet list package --vulnerable, container scans; build fails on critical |
CI |
| API compatibility | OpenAPI diff fails on breaking changes; public API analysers for packages | CI |
| Startup time / image size | Budgets checked in the pipeline (relevant for scale-to-zero, AOT) | CI |
| Resilience | Chaos experiments (e.g. Azure Chaos Studio) on dependencies | Scheduled |
| Cost | Budget alerts per environment/service | Continuous |
| Stage | What it looks like |
|---|---|
| 1. Modular monolith | One deployable; modules with internal implementation, public contracts, own EF Core DbContext and schema |
| 2. Enforced boundaries | Architecture tests in CI; in-process events between modules |
| 3. Independent needs appear | A module needs separate scaling, release cadence or team ownership |
| 4. Extract | Move the module behind its existing contract into a service; data already separate |
| 5. Verify | Fitness functions (latency, error rate) confirm no regressions |
| 6. Record | ADR for each step and its criteria |
| Decision | Reversibility | Approach |
|---|---|---|
| Internal module structure | High | Decide quickly, refactor freely |
| Message broker / cloud queue | Medium | Behind a port; keep messages broker-agnostic |
| Public API contracts, event schemas | Low | Design carefully; version; additive changes |
| Data ownership across modules | Low | Owned per module from the start |
| Managed cloud services (e.g. vendor-specific databases) | Lowβmedium | Weigh lock-in, cost and exit cost explicitly; abstract where worthwhile |
We built a [B2B ordering] platform as a modular monolith with [five] modules, each with its own EF Core DbContext and schema, communicating through public contracts and in-process events. NetArchTest rules in CI blocked any reference to another module's internals, and production SLOs on checkout latency acted as continuous fitness functions. About [18 months] later, catalogue search needed independent scaling for a heavy search workload. Because the boundary and data were already separate, extracting the catalogue module into its own service took [about three weeks]. The latency fitness function confirmed checkout didn't regress, and an ADR recorded the extraction criteria so the next decision would follow the same rules.
Checks strategic architecture thinking at staff level.
Green flags
Red flags
Building Evolutionary Architectures (Neal Ford, Rebecca Parsons, Patrick Kua; 2nd edition with Pramod Sadalage) defines it as architecture that "supports guided, incremental change across multiple dimensions". It borrows fitness functions from evolutionary computing: an objective measure of how close a solution is to its goals.
| Kind | Example |
|---|---|
| Atomic | One characteristic: "Domain doesn't reference EF Core" |
| Holistic | Several together: "p95 < 300 ms under 500 RPS with auth and logging enabled" |
| Triggered | Run on events: CI build, nightly job |
| Continuous | Always on: production SLO monitoring |
| Static | Fixed threshold (image < 200 MB) |
| Dynamic | Threshold depends on context (latency budget scales with payload size) |
Start with a few that protect what matters most; too many noisy checks get ignored.
src/
Modules/
Ordering/
Ordering.Contracts (public: commands, events, DTOs)
Ordering (internal implementation, OrderingDbContext β schema 'ordering')
Catalogue/
Catalogue.Contracts
Catalogue (CatalogueDbContext β schema 'catalogue')
Host/ (composition root; references modules)
tests/
ArchitectureTests/ (NetArchTest rules)
*.Contracts.internal; InternalsVisibleTo only for the module's tests.| Ready when | Check |
|---|---|
| No inbound references to internals | Architecture tests pass |
| Data owned and separable | Own schema; no cross-module joins or FKs |
| Communication via contracts/events | No shared in-memory state |
| Clear reason | Scaling, release cadence, team ownership, compliance isolation |
| Operational readiness | Observability, deployment pipeline, on-call ownership |
Deciding too early locks in choices with little information; deciding too late lets options disappear (e.g. data model already shared). The trick is to make the expensive decisions reversible where possible (ports in front of vendors, owned data, versioned contracts) so fewer decisions need to be made early at all.
# ADR-0012: Extract Catalogue into a separate service
Status: Accepted (2026-05-10)
Context: Search load grew 8x; catalogue scaling forced scaling the whole monolith.
Decision: Extract Catalogue behind Catalogue.Contracts; keep events via outbox.
Consequences: +1 deployable, +network hop for product lookups; independent scaling; team X owns it.
Revisit if: search load drops or cross-module latency exceeds the SLO.
*.Contracts assemblies.// Fitness functions as xUnit tests with NetArchTest.Rules.
using NetArchTest.Rules;
using Xunit;
public class ArchitectureFitnessTests
{
private static readonly System.Reflection.Assembly OrderingAssembly = typeof(Ordering.PlaceOrder).Assembly;
[Fact]
public void Ordering_domain_does_not_depend_on_infrastructure()
{
var result = Types.InAssembly(OrderingAssembly)
.That().ResideInNamespace("Ordering.Domain")
.ShouldNot().HaveDependencyOnAny("Microsoft.EntityFrameworkCore", "Ordering.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames ?? []));
}
[Fact]
public void Ordering_only_uses_other_modules_through_their_contracts()
{
var result = Types.InAssembly(OrderingAssembly)
.ShouldNot().HaveDependencyOnAny("Catalogue.Internal", "Billing.Internal")
.GetResult();
Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames ?? []));
}
[Fact]
public void Module_implementation_types_are_not_public()
{
var result = Types.InAssembly(OrderingAssembly)
.That().ResideInNamespace("Ordering.Internal")
.ShouldNot().BePublic()
.GetResult();
Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames ?? []));
}
}
namespace Ordering
{
public sealed class PlaceOrder { }
}
Requires xunit and NetArchTest.Rules. In a real solution the test project references the module assemblies; the PlaceOrder stub only gives typeof an anchor type.
What happens: a detailed target architecture before real usage. Why it is wrong: requirements and scale change; the design fits a future that doesn't arrive. Do instead: modular boundaries, fitness functions and incremental change.
What happens: many services on day one. Why it is wrong: distribution makes boundaries expensive to move while they're still uncertain. Do instead: modular monolith; extract when there's a concrete reason.
What happens: layering rules documented but not enforced. Why it is wrong: they erode under deadline pressure. Do instead: encode them as fitness functions in CI.
What happens: modules join each other's tables.
Why it is wrong: blocks later extraction and couples every schema change. (A single shared database can be fine for a simple monolith that will never split, but it's the main obstacle to evolving one.)
Do instead: module-owned schemas and DbContexts, communicating via contracts/events.
What happens: committing to a vendor-specific service or data model before you understand needs. Why it is wrong: locks in choices with little information. Do instead: defer to the last responsible moment; prefer reversible options.
What happens: dozens of flaky checks. Why it is wrong: teams ignore or disable them. Do instead: a few reliable checks for the characteristics that matter most.
What happens: only CI checks. Why it is wrong: latency, error and cost regressions appear only under real load. Do instead: SLO alerts and dashboards as continuous fitness functions.
What happens: architecture changed without changing team ownership. Why it is wrong: the system drifts back to match communication structures. Do instead: evolve team boundaries alongside module/service boundaries.
What happens: "why is it like this?" with no answer. Why it is wrong: teams either fear changing it or change it blindly. Do instead: ADRs with context, consequences and "revisit if" criteria.
What happens: engineers extract services or re-platform quietly. Why it is wrong: surprises in cost, delivery timelines and risk erode trust. Do instead: explain trade-offs in business terms and decide together.
At staff level the goal is not the right architecture but an architecture that can become right as we learn. I invest in boundaries, automated fitness functions and delivery speed, because those make every future change cheaper.
| Investment | Payoff |
|---|---|
| Module boundaries with owned data | Cheap extraction later |
| Architecture tests in CI | Rules survive deadlines |
| SLO-based production fitness functions | Detect regressions from change |
| ADRs | Decisions can be revisited with context |
| Continuous delivery and flags | Small, reversible changes |
Coupling is the degree to which one module depends on another: how much it must know about it, and how likely it is to change when the other changes. Some coupling is unavoidable; the goal is coupling that is explicit, narrow and stable. In code the harmful kinds are reaching into another type's internals, shared mutable global state and passing control flags. In distributed .NET systems the expensive kinds are temporal coupling (synchronous call chains), shared databases and lock-step deployments. I reduce them with narrow interfaces, published contracts, asynchronous messaging where it fits, and each service owning its data.
I treat a large refactoring as a series of small, behaviour-preserving steps merged to main continuously, not a big-bang branch. First I pin current behaviour with characterisation tests. Then I pick a technique: strangler fig to route traffic piece by piece to a new implementation, branch by abstraction to put an interface in front of the old code and swap implementations behind it, or parallel change (expand, migrate, contract) for APIs and database schemas. Feature flags and shadow comparisons let old and new paths run side by side. I prioritise hotspots (code that changes often and hurts), keep refactoring commits separate from feature commits, and define an end state so it does not drag on forever.
DIP says high-level policy should not depend on low-level details; both should depend on abstractions, and the abstraction belongs to the high-level side. For example, the domain or application layer defines IPaymentGateway, and the infrastructure project implements it with Stripe; the arrow of source dependency points inwards. Dependency injection is a technique for supplying those implementations (constructor injection), and the .NET DI container is a tool that automates it. You can use a container without inverting anything (if interfaces live next to their implementations in infrastructure), and you can invert dependencies without a container.
How to tell it (STAR)
We built the platform as a modular monolith with each module owning its data, and architecture tests in CI kept the boundaries honest. When catalogue search needed to scale on its own [18] months later, extracting it took [three] weeks because the boundary was already real. Our latency checks showed checkout didn't regress.
Last updated 31 Oct 2026.