Sitemap

Why We Need to Talk More about Clean Architecture

9 min readOct 23, 2026

--

Press enter or click to view image in full size
Photo by PixelPro Vibes on Unsplash

Or why business logic cannot be done in a pure function alone.

This post is inspired by Rico Fritzsche’s recent article available on his blog or here — on Medium. The question Rico poses in his article is a very pertinent one and deserves a straightforward and a thoughtful response.

To facilitate the discussion, here is his example of a pure function encapsulating business logic of processing a request for a booking or a reservation.

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" };
}

(From original example by Rico, available here)

Why do we need to talk more about Clean Architecture

Of course, it was never Rico’s intention to claim that the example above is an illustration of a working enterprise grade software. That was neither his point nor the point of the discussion which follows here. What we want to focus on, is the question of whether or not the pure functional form of business logic processing — as presented by the example above, however simplified — is fundamentally capable of expressing the whole of business logic per se.

When we talk business — and not mathematics — we mean some particular domain. Rico is the first one to agree. That’s why he places his function at the heart of what he calls a “domain capability”. But each business domain brings with itself a whole host of specific logical, computational, regulatory, and operational considerations. That’s just to name a few. Are we talking about reserving one specific seat on a plane — taking in a consideration the common airline practice of overbooking? Or, are we talking about booking a hotel room for several adults with small children — which changes occupancy restrictions? Or, maybe, we are talking about reserving an available slot in a busy manager's schedule — with a priority based dynamic substitution. These are realistic business scenarios — dealing with real peoples’ problems.

In the function above, we receive all we need to perform our business logic as arguments to our function. Some of our arguments are beautifully typed, immutable, value objects. Reservation as a tuple of an arrival and an departure dates — that’s one of them. It is self-explanatory and screams business in a Ubiquitous Language. But let’s look at BookingContext and specifically the array — Resevation[] — which it encapsulates. What does this array represent in terms of the business at hand? Is this a set — meaning, that it cannot contain two equal Reservation objects? Is it an ordered list? If it is — the list traversal with .some() may be constructed as taking advantage of this fact terminating efficiently when there is no chance of ever finding an overlapping reservation beyond a certain departure date.

In fact, by reading the function we discover what this array actually is — this is a set of non-overlapping Reservations. So what we have here is an invariant over a data structure. Our function, evaluateBooking() will uphold this invariant by never adding an overlapping reservation to the set. But, and this is an extremely important moment, the function simply takes for granted that Reservation[] passed to it already is in itself a valid structure containing a set of non-overlapping tuples.

Which component with what responsibilities will provide evaluateBooking() with a valid array of Reservations? How and where this domain invariant will be enforced before and after the call the the function? What other business rules depend on this invariant? In what other “domain capabilities”?

These are archi-important questions for a software designer. And none of them are answered looking at the function itself. That’s why, in our opinion, to pretend that this function can embody the very essence of booking as a real business problem — is also an “illusion”. Same type of illusion as pretending that “functional” dependencies disappear at runtime simply because we have eliminated source code dependencies in Clean Architecture — the correct point which Rico himself makes very clear.

There are even more interesting questions. An experienced designer, will instantly see how BookingContext is a semantically poor ad hoc construction which is not properly expressed in a Ubiquitous Language. Maybe hospitality business has distinct specific concept for “weaving” (from Latin: con- textere) together number of guests, the duration of the stay, and a set of existing reservations. A structure (or an aggregate) modeled and named after this concept would convey a lot of semantics simply by corresponding to a rich natural phenomenon in our problem domain.

That brings us to Clean Architecture proper. We see now that the questions like where BookingContext comes from, or how exactly Reservation[] is obtained — are business logic related questions. Pretending that these are some kind of generic technical or annex problems — with which the core of our business should not concern itself — is a wishful thinking. We are talking real business problems here. But if we look at how Reservation value object maintains a valid tuple of dates (i.e.: arrival no later than departure), we’ll see another kind of business logic and rules. The invariants and logic of Reservation is more general, reusable in different business scenarios in exactly the same fashion.

This is why Robert C. Martin speaks of Enterprise Level rules (our Reservation) and Application Level rules (our evaluateBooking()). And so Use Cases layer and Domain Entities layer come to existence precisely as a result of profound, thoughtful theoretical or conceptual characterization of responsibilities of different type of logical components in design. This is not about a colorful rings on a diagram somewhere. This is about theoretical breakthrough on par of “deep model” by Eric Evans.

Why do we need to talk more about Hexagonal Architecture

Hexagon is a nice symmetrical figure when it comes to geometry. Alistair Cockburn used it as an illustration of another conceptual breakthrough in software design. His hexagon is asymmetrical. There are left-hand side and right-hand side. And the “sides” — or ports — are different in number and in form on the left and on the right.

Hexagonal ports are not (just) programming language interfaces. This is one of the most pernicious misunderstandings and confusions in software architecture. Cockburn makes a simple but a very powerful point — ports represent abstractions of business functionalities provided by (input ports) and required for (output ports) our application logic. One cannot stress enough the word “business” in the definition above.

Get George’s stories in your inbox

Join Medium for free to get updates from this writer.

When we talk about output ports— which drive secondary adapters — specifically, we are not just talking about hiding technical implementation behind an interface. Here is a somewhat exaggerated example of an output port for our problem space:

interface WalkInHotelRoomBookingOperationsOutputPort {

Reservation[] obtainAllHotelRoomReservationsWithTimeZoneIncludingScheduledMaintenance(HotelId hotelId,
RoomNumber number,
TimeZone timeZone,
Maintenance maintenance);

Reservation reserveHotelRoomWithAmenities(HotelId hotelId,
RoomNumber number,
LocalDateTime arrival,
LocalDateTime departure,
int guests,
Amenities amenities);

Reservation promoteRoomClassForVip(ReservationId originalReservation,
RoomClass minimalTargetClass,
VipType vipType);

}

We see instantly that the output port is not a bag of findBy()s and save()s but an inteface scoped specifically to the business use case, say — when a clerk processes a booking for a walk-in guest at the counter. Is this exactly how this is going to be implemented? Of course not, but the point is, we have to make descisions oriented by a particular business scenario or several related business scenarios when we think about:

  • granularity of an output port (how many methods or “sides” in our hexagon)
  • what domain literals and value objets would it take as inputs
  • what domain objects will it provide to the caller (use case)
  • will control return normally to the caller or an exception may be thrown by the adapter and how will it be propagated to the caller

Countless hours may be spent with a domain expert discussing business raison d’être and especially the naming of these ports. All of this will be specific to the operations of a concrete business. Some ports and their signatures will make sense only in a context of a single use case. Some maybe grouped to provide functionality for several use cases.

Important point is that an adapter implementing a port will take care of actually providing infrastracture support for the operation. It may do a conditional append to an event stream, query a projection, construct a very intricate SELECT joining many tables. But all of this is abstracted from the use case — behind a port with a screaming, semantically precise business operation signature.

And of course, asymetrically, on the left-hand side of the hexagon, we have input ports. Each input port is an abstraction over the business use case itself: a sequence of interactions of primary and secondary actors (or a system). With clrearly assigned responsibility and end-goals.

The granularity and the composition of input ports is a fascinating topic in itself. Here, our Clean DDD approach tries to stay as close as possible to Cockburn — Jacobson semi-formal model of use cases as requirements specifications. The interested reader may find more information about this approach on our blog here on Medium.

Why we need to talk more about procedures

And this brings us to the final point of this discussion. Hexagonal Architecture and Clean Architecture are theoretical frameworks which bring much needed conceptual rigour into the field of software design. They also integrate wonderfully with the paradigm of Domain Driven Design. But, design does not come down to simply following a paradigm or an architecture — it’s about using these techniques to help us solve a particular business problem. And to do so in a comprehensible way, compatible with the way domain experts and users understand it.

Rico’s pure function is almost there. But, at the same time, it can never quite get there — simply by the very fact of being a function. At least not by itself.

Fundamentally, an enterprise problem is most congrous with a process or a procedure. Not a function. Not a service. Not an object. When a domain expert describes how the business operates, she, naturally, speaks in use cases first. She indicates what actors (roles) are involved and what each particular scenario entails in terms of outcomes.

Rico’s evaluateBooking() returns a result. There many different values possible for this result, as witnessed by the strings supplied as status and reason to BookingResult instance. But, here is a crucial point — it is someone else, not evaluateBooking() itself who who will act on the result of the evaluation. Somebody, somewhere will have to decide, act, and provide the end-user with an outcome of her command processing depending on whether our function has evaluated to accepted or to rejected with too-many-guests, for instance. So, the business logic for the entire “slice” or “business capability” is spread over a pure computation and some imperative procedure reacting to the result of this computation.

It is our contention and our firm belief, that this situation — the necessity of an imperative procedural component of any business logic processing — is absolutely irreducible in enterprise software design. We can get away without a function. This may lead us to abandon some computational efficiency or reuse — and it would be a real pity. But we absolutely cannot get away without some business logic processing in an imperative shell around the function. Function is a side-effect free stateless computation. That is to say it is absolutely harmless and toothless. Business is all about side-effects and state changes. Aggregates take care of the latter, use cases take care of orchestrating the former. Functions expedite and abstract pure computation, they are essential algorithmic constructs. But business problem solving requires all the three working in unison.

So, what does it mean concretely for our example? Instead of returning results from a function, we simply perform any necessary operations in every one of Rico’s if-then-else branches by calling appropriate output ports and by presenting the resulting outcome to the end-user together with a set of affordances for the next interaction in the business scenario. Then we surrender the control — we return void (nothing) — and wait for an actor to continue interacting with our use case. A function, then, becomes an interaction in a Clean DDD use case. What we gain in terms of design, is an unprecedented compatibility of the implementation of business process with the way this process is actually understood by the stakeholders. Code aligns with mental model, including that of a developer herself — and that’s not a small thing. And we achieve this, all the while keeping all of the logic processing inside the use case exclusively.

In conclusion, we would like to reiterate that we have always considered Functional Core/Imperative Shell paradigm as very fruitful way of thinking about the design of software. Also, many Rico’s ideas find genuine echos in our own research and we are grateful for his opinions and advice. In these interesting — for software architects — times, all approaches deserve a fair share of discussion.

References

--

--