Why Are We Still Talking About Clean Architecture?
Functional Core / Imperative Shell: Moving Beyond Clean and Hexagonal Architecture
Let me get this out of the way right off the bat: We should stop talking about this. Unless, that is, the goal is to finally consign all these layered architectural approaches to the dustbin of history. Because that’s exactly where Clean Architecture, Hexagonal Architecture, Onion Architecture, and whatever else they’re called belong.
And before the first person raises their hand to explain to me that Hexagonal Architecture isn’t a layered architecture: Cockburn did, in fact, move away from the classic layered model. But the fundamental principle remains the same. An application is placed at the center and is supposed to be isolated from its infrastructure through ports, adapters, and dependency inversion. The dependencies are abstracted, their direction is changed, but the functional dependencies and side effects do not disappear as a result.
This is where the problem lies. For decades, we have been trying to make technical dependencies manageable through ever-new architectural models, instead of questioning the fundamental assumptions from which these dependencies arise in the first place.
The Intention
Business logic should remain independent of specific technical implementations. That is also my intention, and in that respect, the fundamental rationale behind these architectural approaches is understandable and correct. Above all, underlying this is the desire to be able to test business behavior in isolation, without having to run databases, external services or other infrastructure. This is a reasonable goal, and at first, there is little to disagree with.

Another frequently cited rationale is the interchangeability of technical implementations. This is particularly evident in the work of Robert C. Martin, who devotes an entire chapter in his book Clean Architecture to the statement "The Database Is a Detail". In doing so, he explicitly distinguishes between the database as a technology and the data model, to which he attributes central architectural significance. In practice, this has often led to the development of an in-memory domain model, while persistence is hidden behind interfaces, adapters, and mapping layers.
However, I find myself wondering what practical benefit this flexibility actually provides. In my decades of software development, how often have I seen someone come up with the idea of replacing the database technology in an existing application? Exactly zero times! And even if that were to happen, such a replacement is by no means trivial. Query capabilities, transaction behavior, and consistency guarantees are largely determined by the database used. An abstraction can hide these differences, but it cannot simply eliminate them.
Theoretical interchangeability thus comes at a price—one we pay in the form of additional complexity. ORM frameworks and mapping infrastructures are added to the mix, which in turn create long-term dependencies. Ironically, the very technologies that are supposed to help us achieve the desired independence often end up being extremely difficult to replace.
The Reality
For me, this raises a more fundamental question: What have we actually achieved with all these architectural approaches?
Dependency inversion makes it possible to reverse source code dependencies and abstract concrete implementations behind interfaces. This makes the business logic independent of specific technologies at the source-code level. However, the functional dependencies remain.
Let’s consider a simple example:
class BookingService {
constructor(private repository: BookingRepository) {}
async book(request: BookingRequest) {
const reservations =
await this.repository.findReservations(request);
// Business rules
// ...
}
}The BookingService is not aware of the specific database implementation. However, it still relies on an external operation being executed before it can apply its business rules. Whether this operation is called via an interface, an adapter, or directly through a database driver does not change this functional dependency.
The side effects and uncertainties of the interaction also remain. The database may be unreachable, the query may fail, and its result depends on the current state of the system. The interface does nothing to change this.
So we have changed the direction of the source code dependencies without actually separating the business logic from external interactions.
And this is where one of the real problems with these architectural approaches begins. We have introduced an enormous amount of abstractions and indirection to control technical dependencies, rather than first questioning why these dependencies must be part of our business logic at all.
The Illusion of Decoupling
Of course, there are various ways to handle errors from external systems. You can handle exceptions directly within the service, translate them in an adapter, or pass them on to a higher level. These are established approaches, but none of them eliminates the underlying dependency.
At runtime, the BookingService remains dependent on external interaction, including its availability, error behavior, and side effects. Even if all exceptions are handled outside the service, the infrastructure call remains part of its flow.
Dependency Inversion abstracts the concrete implementation but does not separate the business logic from the external interaction. That is precisely the crucial difference.
What I Actually Want to Achieve
When I talk about keeping business logic independent of technical dependencies, I mean something very specific: I want business logic that does not perform any external interactions, does not cause any side effects, and whose result depends solely on the values passed to it.
This has a key advantage: a function like this can be tested immediately. I pass in values and check the result. No database, no repository interfaces, no dependency injection, and—most importantly—no mocks.
Let’s take our booking example again. Instead of accessing a repository within the business logic, we pass it the information it needs for the business-logic evaluation.
type BookingRequest = {
guests: number;
arrival: Date;
departure: Date;
};
type Reservation = {
arrival: Date;
departure: Date;
};
type BookingContext = {
maxGuests: number;
minNights: number;
reservations: Reservation[];
};
type BookingResult =
| { status: "accepted" }
| { status: "rejected"; reason: string };
const DAY = 24 * 60 * 60 * 1000;
function evaluateBooking(
request: BookingRequest,
context: BookingContext
): BookingResult {
if (request.guests > context.maxGuests)
return { status: "rejected", reason: "too-many-guests" };
const nights =
(request.departure.getTime() - request.arrival.getTime()) / DAY;
if (nights < context.minNights)
return { status: "rejected", reason: "minimum-stay" };
const overlaps = context.reservations.some(
(r) => request.arrival < r.departure && r.arrival < request.departure
);
if (overlaps)
return { status: "rejected", reason: "unavailable" };
return { status: "accepted" };
}A test now requires nothing more than input values and the expected result.
expect(
evaluateBooking(
{
guests: 2,
arrival: new Date("2026-11-03"),
departure: new Date("2026-11-06")
},
{
maxGuests: 2,
minNights: 2,
reservations: [
{ arrival: new Date("2026-11-05"), departure: new Date("2026-11-08") }
]
}
)
).toEqual({ status: "rejected", reason: "unavailable" });That’s the kind of testability I want. No compromises. Business rules can be verified independently, deterministically, and without any pre-configured infrastructure.
And that brings us to another problem: mocks. They’re often needed because we’ve mixed business logic with external interactions and then have to simulate those dependencies for our tests. So we first introduce coupling, abstract it through interfaces, and then need test doubles to control the effects of that coupling during testing.
Of course, mocks have their place when we want to test how components work together. But why should we need them to verify a business rule?
Where the necessary information comes from and how the business result is subsequently persisted under the required consistency conditions are separate responsibilities. They must not be part of the business calculation.
The Solution
It was these considerations, in particular, that led me some time ago to consistently develop software according to the Functional Core / Imperative Shell principle. It’s a surprisingly simple approach that eliminates much of the complexity that had previously been artificially introduced.
The idea is very simple: The Functional Core contains the business logic. It processes values and returns results without calling external systems or causing side effects. The Imperative Shell handles the necessary interactions with the outside world. It retrieves the required information, passes it to the Functional Core, and ensures that the Functional Core’s result is persisted under the necessary consistency conditions.
This is by no means about splitting an entire application into two large layers or parts, which fundamentally distinguishes this approach from Hexagonal Architecture. An application consists of many such units, each of which is structured according to this principle. This gives us ownership and coherence at the level of business capabilities and frees us from dividing an application based on technical concerns.
For me, the natural boundaries of these units are the domain capabilities. Each capability has its own functional core and its own imperative shell. It encapsulates its business behavior, the necessary technical interactions, and the responsibility for its consistency. Only the totality of these business-defined units constitutes the application.
This ensures that the implementation of a capability remains coherent and traceable. Its business logic can be tested directly using input values and expected results, while its technical interactions remain explicit.
Why We No Longer Need a Hexagon
Of course, one could argue that each of these capabilities could also be viewed as a separate hexagon. But why should we do that?
Cockburn’s Hexagonal Architecture focuses on the application and isolates it from its external systems using ports and adapters. In our case, however, there is no single central application component that we would need to isolate in this way. The application is composed of many domain-specific capabilities, each of which encapsulates its own rules and technical interactions.

The boundaries of these capabilities are defined by the domain and its responsibilities. Within each boundary, the separation of the Functional Core and the Imperative Shell ensures that business calculations remain independent of external interactions. To achieve this, we need neither to reverse the direction of dependencies nor to introduce technical interfaces whose sole purpose is to hide infrastructure implementations.
This also fundamentally changes the structure of our software. Instead of distributing business behavior across horizontal layers of entities, use cases, repositories, and adapters, the implementation of a business capability remains cohesive within its own boundaries.
What we need are clearly defined business responsibilities and control over consistency within their contextual boundaries. Dependency inversion, ports, and adapters do not provide us with these properties.
The architecture emerges from the system’s business capabilities and how they interact. An additional hexagon makes this structure neither clearer nor simpler.
Conclusion
So why are we still talking about Clean Architecture? We’ve spent decades trying to control technical dependencies through an ever-growing number of abstractions and architectural models. In doing so, we’ve created complexity that, for the most part, we don’t even need. Dependency inversion changes the direction of dependencies, but it neither eliminates functional coupling nor side effects, nor does it create meaningful business boundaries.
We can structure software much more easily. With clearly defined domain capabilities whose business logic remains independent of external interactions and whose responsibility for behavior and consistency is unambiguous. Without scattering the business behavior across technical layers.
The goal of keeping business logic testable and independent of infrastructure was certainly reasonable. But we don’t need these architectural models to achieve it, as we’ve learned the hard way. It's time to put this endless discussion behind us.
Off to the attic. Close the lid.
Cheers!