November 7, 2025

Hexagonal architecture vs. eDSL - a demo

In this blog post, we compare Hexagonal architecture to eDSL on a toy train-reservation system.

Michal Hadrava
Full-stack developer

Introduction

In a previous blog post, we presented a Hiccup-based embedded domain-specific language (eDSL) as an alternative to Hexagonal architecture (Hexagon) when applying Domain-driven design (DDD) in Clojure. More precisely, we argued that the eDSL addresses known shortcomings of Hexagon. In the present blog post, we pit the two approaches against each other in implementing a toy train-reservation system inspired by the example from here. The Hexagonal implementation can be found here and the eDSL-based one here.

Requirements

Given a seat count and a departure date, the app books that many seats on a train departing that day, subject to the following constraints:

  • All the booked seats are in the same coach
  • The train occupancy stays below or at 70%
  • The coach occupancy preferably stays below or at 80%

In addition, the app logs the status of the reservation.

The app interfaces with two external services:

  • Trains: search trains by departure date and book seats on a selected train
  • Log: log information and errors

A Hexagonal implementation

In line with Hexagon, the code is split into Model, Ports, and Adapters. In addition, Components (here and here) collect those APIs that only have a single implementation (e.g., the Controller) and Modules take care of switching certain components off in tests.

We leverage the Duct framework for dependency injection. More precisely, each adapter is implemented as an Integrant component that resolves to a map of closures; for instance, the Log adapters (the one for the “actual” service and the mock) look like this:

(defmethod ig/init-key ::log
  [_ {:log/keys [error info]}]
  #:api{:error! error
        :info! info})

(defmethod ig/init-key ::log-mock
  [_ _]
  (atom {}))

(defmethod ig/resolve-key ::log-mock
  [_ impl]
  #:api{:merge-log-impl!
        (partial swap! impl merge)

        :error!
        (fn [& args]
          (apply (:api/error! @impl) args))

        :info!
        (fn [& args]
          (apply (:api/info! @impl) args))})

Each port, in turn, is implemented as a Duct module that switches between the components based on a profile; the Log port, for instance, looks like this:

(defmethod ig/expand-key ::log
  [_ ctx]
  (ig/profile
   :repl {:train-reservation.adapters.api/log ctx}
   :test {:train-reservation.adapters.api/log-mock {}}
   :main {:train-reservation.adapters.api/log ctx}))

The Controller is implemented as a sequence of three operations, each reporting its result to the database:

(defn handler
  [{:api/keys [reservation search-trains!] :as api} request]
  (search-trains! request)
  (reserve-if-available api request (reservation request))
  (check-reservation-status api (reservation request)))

Here, api/search-trains! fetches trains departing on the requested date and stores them in the database under a reservation entity; api/reservation pulls the reservation entity from the database and reserve-if-available attempts to book the requested number of seats on one of the trains and reports the status to the database; finally, check-reservation-status logs the reservation status stored in the database.

The reader might wonder why not structure the handler like this:

(defn handler
  [{:api/keys [search-trains!] :as api} request]
  (->> (search-trains! request)
       (reserve-if-available api request)
       (check-reservation-status api)))

The reason is that, when testing the latter handler, we need to mock api/search-trains! (and api/reserve-seats! called internally by reserve-if-available) with functions having sensible outputs; with the former handler, though, we can mock them both with (constantly nil) and simulate their effects by directly writing to the database. Indeed, consider the following test of the handler:

(deftest train-reservation-test
  (with-system [system (run)]
    (let [{:train-reservation.components/keys [ctx api]} system
          {:state/keys [transact! ...]} ctx
          {:api/keys [merge-trains-impl! reservation]} api
          {:reservation/keys [id] :as request}
          #:reservation{:id #uuid "e736e756-a405-4959-be52-80bc7217ada7"
                        :departure-date "2025-10-21"
                        :seat-count 10}
          read-reservation-status
          ...]
      ...
      (testing "There are seats available on a train departing on the date"
        (transact!
         [{:train/id "George Washington"
           :vehicle/occupied 130}
          {:coach/id "GW.1"
           :vehicle/occupied 60}])
        (merge-trains-impl! #:api{:reserve-seats! (constantly nil)})
        (reserve-if-available api request (reservation request))
        (is (= :submitted (read-reservation-status request)))))))

Here, we do not even need to mock api/search-trains!; instead, we write the “result” to the database right away. We do need to mock api/reserve-seats!, though, as it will be called internally by reserve-if-available; still, due to the peculiar structure of the handler, mocking it with (constantly nil) is enough. More detailed tests, however, require more sophisticated mocks:

(deftest check-reservation-status-test
  (with-system [system (run)]
    (let [{:train-reservation.components/keys [api]} system
          {:api/keys [merge-log-impl!]} api]
      (testing "Reservation status is being logged"
        (let [{:reservation/keys [seat-count departure-date selected-train selected-coach] :as reservation}
              #:reservation{:id #uuid "e736e756-a405-4959-be52-80bc7217ada7"
                            :seat-count 10
                            :departure-date "2025-10-22"
                            :selected-train {:train/id "Thomas Jefferson"}
                            :selected-coach {:coach/id "TJ.2"}}
              log-entry (atom nil)]
          (merge-log-impl!
           #:api{:error!
                 (fn [msg data]
                   (reset! log-entry [msg data]))
                 ...})
          (check-reservation-status api (-> reservation (assoc :reservation/status :unavailable)))
          (is (= ["No trains available!" {:departure-date departure-date, :seat-count seat-count}]
                 @log-entry))
          ...)))))

Getting rid of those is the main goal of the eDSL-based implementation discussed below.

An eDSL-based implementation

The eDSL-based implementation only differs from the Hexagonal one in the way the Controller leverages the API to handle a request. More precisely, the Hexagonal Controller calls a side-effectful handler that, in turn, calls the API passed to it:

(defmethod ig/init-key ::controller
  [_ {:keys [api departure-date seat-count]}]
  (handler api #:reservation{:id (random-uuid), :departure-date departure-date, :seat-count seat-count}))

In contrast, the eDSL Controller calls a side-effect-free handler to obtain a Hiccup-like expression and interprets that expression using the definitions from the API:

(defmethod ig/init-key ::controller
  [_ {:keys [api departure-date seat-count]}]
  (let [...
        [[{:cmd/keys [handler]}]]
        ...]
    (dsl/interpret api (handler #:reservation{:id (random-uuid), :departure-date departure-date, :seat-count seat-count}))))

This is made possible by having reserve-if-available and check-reservation-status return data representation of API calls instead of calling the API directly; e.g., compare the eDSL implementation of check-reservation-status:

(defn check-reservation-status
  [{:reservation/keys [seat-count departure-date selected-train selected-coach status]}]
  (let [train (-> selected-train (select-keys [:train/id]))
        coach (-> selected-coach (select-keys [:coach/id]))]
    (case status
      :unavailable [:api/error! "No trains available!" {:departure-date departure-date, :seat-count seat-count}]
      :confirmed [:api/info! "Reservation successful!" {:train train, :coach coach, :seat-count seat-count}]
      :cancelled [:api/error! "No longer available!" {:train train, :coach coach, :seat-count seat-count}])))

to the Hexagonal one:

(defn check-reservation-status
  [{:api/keys [error! info!]} {:reservation/keys [seat-count departure-date selected-train selected-coach status]}]
  (let [train (-> selected-train (select-keys [:train/id]))
        coach (-> selected-coach (select-keys [:coach/id]))]
    (case status
      :unavailable (error! "No trains available!" {:departure-date departure-date, :seat-count seat-count})
      :confirmed (info! "Reservation successful!" {:train train, :coach coach, :seat-count seat-count})
      :cancelled (error! "No longer available!" {:train train, :coach coach, :seat-count seat-count}))))

A slightly polished version of the interpreter from the previous blog post then takes care of evaluating the expression returned by the handler:

(defn handler
  [request]
  [vector
   [:api/search-trains! request]
   [reserve-if-available request [:api/reservation request]]
   [check-reservation-status [:api/reservation request]]])

With the eDSL-based implementation, mocks are completely eliminated from the tests; e.g., compare this test of the eDSL handler:

(deftest train-reservation-test
  (with-system [system (run)]
    (let [{:train-reservation.components/keys [ctx api]} system
          {:state/keys [transact! ...]} ctx
          {:reservation/keys [id] :as request}
          #:reservation{:id #uuid "e736e756-a405-4959-be52-80bc7217ada7"
                        :departure-date "2025-10-21"
                        :seat-count 10}
          read-reservation-status
          ...]
      ...
      (testing "There are seats available on a train departing on the date"
        (transact!
         [{:train/id "George Washington"
           :vehicle/occupied 130}
          {:coach/id "GW.1"
           :vehicle/occupied 60}])
        (interpret api [reserve-if-available request [:api/reservation request]])
        (is (= :submitted (read-reservation-status request)))))))

to the corresponding test of the Hexagonal handler:

(deftest train-reservation-test
  (with-system [system (run)]
    (let [{:train-reservation.components/keys [ctx api]} system
          {:state/keys [transact! ...]} ctx
          {:api/keys [merge-trains-impl! reservation]} api
          {:reservation/keys [id] :as request}
          #:reservation{:id #uuid "e736e756-a405-4959-be52-80bc7217ada7"
                        :departure-date "2025-10-21"
                        :seat-count 10}
          read-reservation-status
          ...]
      ...
      (testing "There are seats available on a train departing on the date"
        (transact!
         [{:train/id "George Washington"
           :vehicle/occupied 130}
          {:coach/id "GW.1"
           :vehicle/occupied 60}])
        (merge-trains-impl! #:api{:reserve-seats! (constantly nil)})
        (reserve-if-available api request (reservation request))
        (is (= :submitted (read-reservation-status request)))))))

The difference is even more pronounced for more detailed tests; e.g., compare this test of the eDSL implementation:

(deftest check-reservation-status-test
  (testing "Reservation status is being logged"
    (let [{:reservation/keys [seat-count departure-date selected-train selected-coach] :as reservation}
          #:reservation{:id #uuid "e736e756-a405-4959-be52-80bc7217ada7"
                        :seat-count 10
                        :departure-date "2025-10-22"
                        :selected-train {:train/id "Thomas Jefferson"}
                        :selected-coach {:coach/id "TJ.2"}}]
      (is (= [:api/error! "No trains available!" {:departure-date departure-date, :seat-count seat-count}]
             (check-reservation-status (-> reservation (assoc :reservation/status :unavailable)))))
      ...)))

to the equivalent test of the Hexagonal implementation:

(deftest check-reservation-status-test
  (with-system [system (run)]
    (let [{:train-reservation.components/keys [api]} system
          {:api/keys [merge-log-impl!]} api]
      (testing "Reservation status is being logged"
        (let [{:reservation/keys [seat-count departure-date selected-train selected-coach] :as reservation}
              #:reservation{:id #uuid "e736e756-a405-4959-be52-80bc7217ada7"
                            :seat-count 10
                            :departure-date "2025-10-22"
                            :selected-train {:train/id "Thomas Jefferson"}
                            :selected-coach {:coach/id "TJ.2"}}
              log-entry (atom nil)]
          (merge-log-impl!
           #:api{:error!
                 (fn [msg data]
                   (reset! log-entry [msg data]))
                 ...})
          (check-reservation-status api (-> reservation (assoc :reservation/status :unavailable)))
          (is (= ["No trains available!" {:departure-date departure-date, :seat-count seat-count}]
                 @log-entry))
          ...)))))

Instead of mocking, the data representation of the API calls is inspected directly - without even running the system!

Discussion

In general, tests should make sure the implementation is aligned with the requirements; to this end, the semantic gap between the tests and the requirements should be minimized. With this in mind, consider the question the Hexagonal test above answers: “What will api/error! be called with if check-reservation-status is called with the API and the reservation?” Compare that to the question answered by the equivalent eDSL test above: “What will happen if check-reservation-status is called with the reservation?”. The latter is arguably semantically closer to the requirements.

It is a common objection to eDSLs that they end up re-implementing the host language; however, as already shown in the previous blog post, complex logic can be pushed to the host language by wrapping it in a regular function and using that function as an expression head. For instance, the following simple expression:

[reserve-if-available request [:api/reservation request]]

harbours some non-trivial business logic:

(defn reserve-if-available
  [{:reservation/keys [seat-count] :as request} {:reservation/keys [trains-found]}]
  (if-some [available-trains (seq (available-trains seat-count trains-found))]
    (let [selected-train (-> (select-first-train available-trains) (select-keys [:train/id]))
          selected-coach (-> (select-first-coach available-trains) (select-keys [:coach/id]))
          reservation (-> request (merge #:reservation{:selected-train selected-train, :selected-coach selected-coach, :status :submitted}))]
      [vector
       [:api/transact! [reservation]]
       [:api/reserve-seats! reservation]])
    (let [reservation (-> request (merge #:reservation{:status :unavailable}))]
      [:api/transact! [reservation]])))

Moreover, we argue that as long as each handler is implemented as a sequence of operations, each operation consuming API query results and emitting API commands, eDSL expressions will never get fundamentally more complex than those above. Note that such an operation structure is nothing too esoteric or even restrictive; it is precisely the structure of an event handler in the Event Sourcing pattern.

The reader might have noticed that the Controller does not import the handler directly; instead, the handler is first wrapped in transaction data:

(def model
  [{:cmd/id :find-the-best-places
    :cmd/handler handler}])

Subsequently, the Controller transacts the data to an in-memory DataScript database and finally queries the database for the handler (see here):

(defmethod ig/init-key ::controller
  [_ {:keys [api departure-date seat-count]}]
  (let [conn
        (doto (d/create-conn (edn/read-string (slurp (io/resource "model-schema.edn"))))
          (d/transact! model/model))
        [[{:cmd/keys [handler]}]]
        (d/q
         '[:find (pull ?cmd [:cmd/handler])
           :where
           [?cmd :cmd/id :find-the-best-places]]
         @conn)]
    (dsl/interpret api (handler #:reservation{:id (random-uuid), :departure-date departure-date, :seat-count seat-count}))))

Even though the approach feels somewhat circuitous for the present tiny app, we believe it will prove useful in more complex projects; imagine all your handlers, schemas, and relationships between those expressed using a universal data model that can be queried to build an arbitrary Controller (command-line utility, desktop/mobile/web app, you name it).

Conclusion

Comparing a Hexagonal implementation of a toy train-reservation system to an eDSL-based one, we have demonstrated how the latter approach addresses known shortcomings of the former. Namely, we have shown that a combination of a Hiccup-like eDSL and an event-handler-like operation structure completely eliminates mocks from tests, thus closing the semantic gap between tests and requirements. Incidentally, this decouples the Controller from the Model; leveraging DataScript to decouple it even more, especially in more complex projects, is a possible avenue of further research.

Contact us

Let's build the software
your company needs

We design and deliver intelligent, tailored software that helps your company operate smarter, move faster, and grow with confidence.