Evolution without Big Bang: Hexagonal, Strangler and governance
Series: Part 5 of 5 — CQRS and architecture for AI agents.
Reading time: ~7 min.
In the previous article we covered Event Sourcing and materialization in Elixir. This last article covers Hexagonal Architecture, Strangler Fig, Evolutionary Architectures, Fitness Functions, Ports & Adapters in practice, and closes the series with conclusion and takeaway.
Hexagonal Architecture (Ports and Adapters) and surgical migrations
Knowing that CQRS confines and compartmentalizes complexity addresses part of the problem; the ongoing challenge is dealing with dependent infrastructure without rigidifying the software. In this context the Hexagonal Architecture pattern fits orthogonally, also known as Ports and Adapters, conceived by Alistair Cockburn.
The essence of Hexagonal Architecture strictly forbids infrastructure concerns, libraries and framework abstractions from leaking into domain logic (the core or hexagon). At the heart of the application lie only pure business entities, CQRS Aggregates and orchestration use cases. Domain code is unaware of Data Lakes, OpenAI LLMs, MongoDB instances or RabbitMQ enterprise buses.
The core defines its communication boundary through well-designed interfaces, often implemented as purely abstract classes, Traits or Behaviours depending on the language:
Primary Ports (Driving Ports): Represent the methods by which external actors (e.g. a REST API, queue handlers or AI agent triggers) drive the application. They use Primary Adapters (e.g. a Phoenix Controller or an AWS Lambda) to turn external requests into commands the core accepts.
Secondary Ports (Driven Ports): Denote contracts defined by the domain for what it needs to complete a task. For example a
WalletRepositoryPortsays the system needs a way to save financial transactions. The core calls the abstract port; an outer layer handles dependency injection of a Secondary Adapter (e.g.DynamoDBWalletAdapterorStripePaymentGatewayAdapter) that implements the actual connection.
The death of "Big Bang Rewrite" with the Strangler Fig pattern
By combining CQRS’s asynchronous routines with the abstract modularity of Ports and Adapters, organizations can modernize heavily worn monoliths without the severe business risk of mass replacement (Big Bang Rewrite). This gradual progression aligns with the Strangler Fig pattern, conceived by Martin Fowler.
With Strangler Fig, new infrastructure gradually wraps and replaces the edges of a decaying monolith, "strangling" the legacy system’s relevance route by route, component by component. If the company decides an internal service must interact heavily with vectors, engineering does not need to redesign the heavy transactional databases in the back; a new adapter and selective redirection suffice.
Under these combined architectures, designers add a new interface on the Hexagon’s primary port and implement a secondary adapter wired to a Pinecone vector indexer. At the CQRS Query code level in Elixir, the ListGroupItinerary request is selectively redirected to consume vector store data in real time. All surrounding business behavior (Commands) keeps flowing unchanged over the existing PostgreSQL drivers.
Step by step in a concrete case: (1) Before: ListGroupItinerary reads from PostgreSQL. (2) Create a read adapter for Pinecone and a new Query. (3) Redirect only ListGroupItinerary to the new adapter; commands still use Postgres. Surgical migration, no Big Bang.
Large migrations break down into testable, contained sub-deployments that are inherently safer and protect against business-stopping outages and unexpected catastrophic failures.
The goal of Evolutionary Architectures
Structuring domain separation, mitigating data contention through CQRS and protecting logic from infrastructure churn via Ports and Adapters strengthens the defensive strategy, but a forward-looking holistic direction is still missing. Enterprise-scale systems should not be judged by how resilient they remain unchanged, but by agility in their functional plasticity. That primary goal leads to Evolutionary Architectures.
In the book of the same name by Neal Ford, Rebecca Parsons and Patrick Kua, the core idea is that an Evolutionary Architecture supports guided, incremental change as a first-class concern across multiple dimensions — technology, security, automated deployment and conceptual domain evolution.
The paradigm argues against the structural arrogance of big design up front (Big Design Up Front), recognizing that anticipating the adoption of disruptive innovations — such as generative AI orchestrators in LLM flows — is fundamentally impossible at day zero of an architecture. Instead, structures must prioritize evolvability over predictability.
The technical viability of an evolutionary architecture depends on advanced CI/CD pipeline automation and deep systemic modularization through clear boundaries (which CQRS cohesion provides). Without those boundaries, any change becomes a global refactor and undermines evolution.
Continuous governance through Architectural Fitness Functions
Allowing a platform to evolve continuously carries the risk of silent regression or structural integrity collapse over time. The architectural principles baked into the application — such as non-negotiable isolation between models or infrastructure coupling restricted to adapters in Hexagonal — need inviolable guardians.
The basic tactic in evolutionary architectures is Architectural Fitness Functions: testable, automated, objective checks that express how well the system preserves critical properties (security, decoupling, latency, observability) despite ongoing code mutations.
These metrics span operational security testing and structural architectural assessment. For topology and encoded architecture, engineers replace slow manuals and biased human review (Governance by Inspection) with executable governance via rules in the project repository.
Tools such as ArchUnit (JVM/Java) and NetArchTest (C#/.NET) exist in the market. In the Elixir ecosystem, packages like ArchTest offer similar power to enforce checks from the AST and bytecode level.
In practice, the architect can add Continuous Fitness Functions to ExUnit so that if a security abstraction is dropped or a developer accidentally wires direct Repo or UI access in an isolated CQRS Command module, CI catches the Hexagonal isolation violation and fails the build. Automation constrains drift and keeps maintainability intact over time.
Concrete example: a test (ExUnit or ArchTest) ensures that *.Commands modules never call Repo or UI libraries directly; the test fails if a command injects database access without going through a Port. Thus governance becomes executable code in CI.
Dimensions of Fitness Functions
| Dimension | Example in CQRS / Hexagonal domain |
|---|---|
| Continuity (Atomic/Holistic) | On every CI/CD run, check for invalid syntactic coupling between Domain layer and PostgreSQL Adapter. |
| Static and direction (Code/Bytecode) | An ArchTest ensuring *.Commands modules never call UI or external dependencies without abstractions or formal Port injection. |
| Quantitative metrics | A load test showing write-side commands are enqueued asynchronously and meet latency under heavy AI read traffic on the vector read model. |
| Temporal | Runtime monitoring to detect degrading vector similarity (RAG drift in production). |
Raising the bar: Ports and Adapters in practice (Elixir)
The Trips4you.Finance.Queries and Commands modules we saw earlier act as the application’s entry point — the Driving Adapters. They receive requests from the outside (HTTP, Kafka event or AI agent prompt) and trigger the core. The real work happens inside the execute/1 functions: that’s where Ports and Adapters come in.
Ports & Adapters examples in practice (Elixir)
The idea is simple: the domain (business rules) should not know whether the "port" is implemented by Postgres, Stripe, a queue, a vector store, or an intern copying data by hand (please don’t).
1. Define the Port (contract) with behaviour
Example: a port that persists "liquidations" and notifies the payment provider.
defmodule Trips4you.Finance.Ports.LiquidationRepo do
@callback persist_liquidation(%{group_id: String.t(), amount: integer()})
:: {:ok, %{liquidation_id: String.t()}} | {:error, term()}
@callback notify_payments(%{liquidation_id: String.t()}) :: :ok | {:error, term()}
end
2. Create Adapters (real implementations)
Example: an adapter using Postgres (or any infrastructure you prefer).
defmodule Trips4you.Finance.Adapters.LiquidationRepo.Postgres do
@behaviour Trips4you.Finance.Ports.LiquidationRepo
def persist_liquidation(_attrs) do
# Repo.insert(...) etc.
{:ok, %{liquidation_id: "liq_" <> "123"}}
end
def notify_payments(%{liquidation_id: _liquidation_id}) do
# Call to another part of the system (e.g. via messaging)
:ok
end
end
3. Invoke the port inside the Command (domain)
The command holds the intention and rules; it only "passes messages" to infrastructure via the port.
defmodule Trips4you.Finance.Commands.LiquidateGroupWithWallets do
defstruct [:scope, :group_id, :adapters]
def execute(%__MODULE__{group_id: group_id, adapters: adapters}) do
attrs = %{group_id: group_id, amount: 123_00}
with {:ok, %{liquidation_id: liquidation_id}} <-
adapters.liquidation_repo.persist_liquidation(attrs),
:ok <- adapters.liquidation_repo.notify_payments(%{liquidation_id: liquidation_id}) do
{:ok, liquidation_id}
end
end
end
Composition/injection happens on the "outside" (e.g. facade, controller, consumer).
defmodule Trips4you.Finance.Commands do
alias Trips4you.Finance.Commands.LiquidateGroupWithWallets
alias Trips4you.Finance.Adapters.LiquidationRepo.Postgres
def liquidate_group_with_wallets(scope, group_id) do
adapters = %{
liquidation_repo: Postgres
}
LiquidateGroupWithWallets.execute(%LiquidateGroupWithWallets{
scope: scope,
group_id: group_id,
adapters: adapters
})
end
end
The point: the stable domain becomes the "engine", and infrastructure becomes "swappable". If you change backends tomorrow, you change the adapter, not the rules.
4. Select adapter via config (boot time)
In practice you usually don’t want to hardcode which adapter is active. You configure it.
# config/runtime.exs (or config/config.exs)
config :trips4you, :liquidation_repo_adapter, Trips4you.Finance.Adapters.LiquidationRepo.Postgres
In the facade/command runner you resolve the adapter from config:
defmodule Trips4you.Finance.Adapters do
def liquidation_repo_adapter do
Application.fetch_env!(:trips4you, :liquidation_repo_adapter)
end
end
def liquidate_group_with_wallets(scope, group_id) do
adapters = %{
liquidation_repo: Trips4you.Finance.Adapters.liquidation_repo_adapter()
}
LiquidateGroupWithWallets.execute(%LiquidateGroupWithWallets{
scope: scope,
group_id: group_id,
adapters: adapters
})
end
So "changing infrastructure" becomes: change config, restart (or ensure runtime reloads), and you’re done.
5. Swap adapter at runtime (without restart)
For rollback/rollforward, A/B tests or emergency provider switch you need an adapter "router".
Simple example: a process that holds the current adapter and allows live swap.
defmodule Trips4you.Finance.AdapterRouter.LiquidationRepo do
use Agent
def start_link(_) do
Agent.start_link(fn ->
Application.fetch_env!(:trips4you, :liquidation_repo_adapter)
end, name: __MODULE__)
end
def get do
Agent.get(__MODULE__, & &1)
end
def put(adapter_module) do
Agent.update(__MODULE__, fn _old -> adapter_module end)
end
end
In the command you stay decoupled from how the adapter was chosen:
def liquidate_group_with_wallets(scope, group_id) do
adapters = %{
liquidation_repo: Trips4you.Finance.AdapterRouter.LiquidationRepo.get()
}
LiquidateGroupWithWallets.execute(%LiquidateGroupWithWallets{
scope: scope,
group_id: group_id,
adapters: adapters
})
end
From an admin endpoint, maintenance task or message consumer you then switch:
Trips4you.Finance.AdapterRouter.LiquidationRepo.put(
Trips4you.Finance.Adapters.LiquidationRepo.Postgres
)
Result: the domain stays the same; the outside world (infra and providers) changes when needed.
Note: The example uses Agent for simplicity. You can also implement adapter switching with Registry (per adapter/key) or :persistent_term (cheaper read on the hot path). If you do, sharing with the community is appreciated.
Further reading: For more performance from config and runtime behavior switching, see the combination of metaprogramming in Elixir with the Notification-Oriented Paradigm (NOP) and hot code swapping on the BEAM. Instead of re-evaluating an if (or adapter lookup) on every call, config can trigger reactive recompilation and the "hot" code runs only the active path — no unnecessary branch. Matheus de Camargo Marques explores this in detail (including benchmarks) in Are Feature Flags Bullsh*t? Why Your "IF" is Killing Performance (and the Planet) (DEV Community).
Same idea: fixed adapter via recompilation (PON-style)
Instead of asking "which adapter is active?" on every request, treat adapter change as a Fact and react with recompilation. The hot path has no if or lookup; the generated module already calls the right adapter.
1. "Compiler" that generates the module with the fixed adapter:
defmodule Trips4you.Finance.AdapterCompiler do
def recompile_liquidation_repo(adapter_module) do
# The 'if' / choice happens ONCE here, at recompilation.
function_body =
quote do
def persist_liquidation(attrs), do: unquote(adapter_module).persist_liquidation(attrs)
def notify_payments(payload), do: unquote(adapter_module).notify_payments(payload)
end
module_ast =
quote do
defmodule Trips4you.Finance.Adapters.CurrentLiquidationRepo do
unquote(function_body)
end
end
[{_module, binary}] = Code.compile_quoted(module_ast)
:code.load_binary(Trips4you.Finance.Adapters.CurrentLiquidationRepo, ~c"nofile", binary)
{:ok, Trips4you.Finance.Adapters.CurrentLiquidationRepo}
end
end
2. "Notification" (NOP style): when adapter changes, recompile.
defmodule Trips4you.Finance.AdapterWatcher do
use GenServer
def handle_cast({:set_adapter, adapter_module}, _state) do
Trips4you.Finance.AdapterCompiler.recompile_liquidation_repo(adapter_module)
{:noreply, adapter_module}
end
end
3. In the command you only call the "current" module — zero branch on the hot path.
# In LiquidateGroupWithWallets.execute/1, instead of adapters.liquidation_repo,
# use the recompiled module:
Trips4you.Finance.Adapters.CurrentLiquidationRepo.persist_liquidation(attrs)
Adapter choice becomes reactive state: whoever cares (the compiler) reacts to "adapter changed" and the rest of the system just runs the already-resolved code. For NOP, benchmarks and the physical cost of if, see the article and repo pon_feature_flag.
Domain isolation
If inside LiquidateGroupWithWallets.execute/1 you have direct (hardcoded) calls to PostgreSQL (Repo.insert, etc.) or to the Stripe API, the system is still coupled to infrastructure.
With Ports and Adapters we define "Ports" (in Elixir, Behaviours for contracts). The core of your business rule says: "I need to save this liquidation event and notify the payment system". It does not care how that is done.
You then implement "Adapters" (implementations of those Behaviours) that inject that capability into the command.
Granular migrations: the end of "Big Bang Rewrite"
CQRS plus Ports and Adapters enables surgical, granular migrations.
Suppose your legacy system is struggling and you decide that itinerary reads (list_group_itinerary) must move from the old relational DB to a vector store optimized for your AI agents. How do you do that without rewriting the whole system?
- Step 1: Create a new adapter for the itinerary read port, pointing to the vector store.
-
Step 2: Change dependency injection only for the Query
ListGroupItinerary. -
The rest stays intact: The
create_groupcommand still writes to Postgres. Theliquidate_groupcommand still talks to the same API.
You have migrated one use case to a new technology, tested in production and validated performance without blocking the team or stopping feature delivery. That is the end of "Big Bang" rewrites (the kind that take two years and are legacy on day one). You evolve the system piece by piece, command by command, query by query. The architecture serves business evolution, not the other way around.
Practice ecosystems and the spread of structural innovation
The technical leap that enables robust support for massive autonomous computation rarely survives if it stays only in written form. Building resilient CQRS-based infrastructure, adopting Hexagonal approaches and architectural automation depends heavily on organic flow and pragmatism from technical communities of practice and specialized development forums.
Highly innovative hubs with strong market ecosystems act as catalysts. In the Brazilian context, cities like Curitiba and regions in Paraná stand out. The spread of backend structure and domain innovation is reinforced at culture-shaping conferences where practitioners discuss post-mortems, operational limits of Strangler Fig refactors and functional implementation bottlenecks. A wide range of events supports this — from large conferences on professional practice and careers (e.g. Codecon Summit, DevPR Conf) to networks focused on digital urban architecture (e.g. Smart City Expo Curitiba) and institutional forums (e.g. UFPR). In these networking arenas, architectural complexity gains depth through practical analysis by communities focused on backend tech, Data Science and languages well suited to event-sourcing and reactive systems, including meetups and hackathons (e.g. Google Developer Group (GDG)). The synergy of these regional collaborative instances enables organizations to modernize their pipelines systematically, moving worn monoliths to distributed, highly resilient architectures able to handle the intense contextual demands of algorithmic orchestration flows.
Conclusion: preparing the ground for evolution
Transformations in the technological landscape, driven by scale saturation and the need to support autonomous AI agents, make CRUD-based backend models fatally transient. Establishing CQRS together with Hexagonal Architecture and Event Sourcing forms the essential substrate for large-scale generative automation — with Query models segregated for RAG and Command models validating Tool Execution. Evolutionary Architectures and architectural fitness functions give the software proactive immunity; architectures become tools that serve the current ecosystem and anticipate the coming computational intelligence revolution.
CQRS is the tourniquet that stems the bleeding; with Ports and Adapters you get the scalpel for surgical migrations without "Big Bang Rewrite". Systems should be built to change easily — and that is the theme of the next article here.
Before that, I’d like to hear from you in the trenches:
- Have you been through a full system rewrite (the famous Big Bang) that was legacy from day one?
- How are you handling aggressive concurrency and database bottlenecks that multiple AI agents bring to the backend?
Share your stories in the comments. See you in the next post on Evolutionary Architectures.
#architecture #elixir #backend #ai
Computational thinking (agent mode)
When a system must evolve quickly, reasoning becomes decomposition and boundaries. In plain terms:
- Decompose by intention: every real-world action is a Command (e.g. "liquidate", "create", "add member"). Everything that is observation is a Query (e.g. "dashboard", "itinerary", "summary").
- Separate system state: what changes lives in write (Commands). What is displayed lives in read (Queries). Mixing them is like using the same pan for soup and dessert.
- Define contracts, not accidents: "Query is for asking" and "Command is for doing". The right name reduces the chance of bugs becoming "features".
- Treat concurrency as normal: multiple agents will always try to touch something. CQRS helps turn chaos into a queue of intentions.
If you do this well, your agents stop being "a bunch of directionless prompts" and become a system that knows what to ask and what to execute.
Summary and review: what we take away
In short:
- Problem: CRUD-centric backends bleed under concurrency, integrations and AI agents; a single read/write model causes locks, rigid schema and coupling.
- Conceptual solution: CQRS — segregation between Command stack (change intentions, handlers, validation) and Query stack (materialized views, optimized read, multiple stores). For agents: Ask (RAG, vectors, Query) vs Act (Tool Use, Command, HITL).
- Persistence and materialization: Event Sourcing stores immutable events; projectors feed Read Models (including vectors with Bumblebee/pgvector) without blocking the command core.
- Infrastructure evolution: Hexagonal Architecture (Ports and Adapters) + Strangler Fig let you swap adapters (Postgres, Pinecone, etc.) per use case, without Big Bang.
- Governance: Evolutionary Architectures and Fitness Functions (architecture tests, ExUnit/ArchTest) keep CQRS and Hexagonal boundaries under control in CI.
The thread: separating read and write intention stems the bleeding; ports and adapters let you evolve in parts; events and projectors give auditability and scale for AI.
Takeaway
Before you close the tab:
- Map one use case in your current system: where do you have "one method that reads and maybe writes"? Name the Query (what someone asks) and the Command (what someone wants to happen). That exercise alone reveals coupling.
- Draw the Ask vs Act boundary for a flow with an AI agent: what is context retrieval (RAG, Query) and what is tool execution (Command)? Ensure the Command is validated and, if critical, goes through an approval channel (HITL).
- Pick one Query or Command and imagine swapping the adapter (e.g. read from Postgres to cache or vector). With CQRS + Ports that’s a local change; without it, it’s cascading refactor.
-
Add a "must not" test in CI: e.g. that Command modules never call
Repoor UI directly. A simple Fitness Function already protects the boundary.
When you apply at least one of these steps in your context, the concepts become tools. When you’re ready, the next article here will go deeper into Evolutionary Architectures — until then, comments and stories are welcome.
If the content helped, you can buy me a coffee.
Previous: Event Sourcing and materialization in Elixir
Series: CQRS series index
Top comments (0)