Cross cutting concerns with Clojure macros

December 26, 2024

Introduction

We define cross cutting concerns as functionality that affects different parts of the application, cut across main business logic and appear in multiple places. A domain model should be pure and free from these concerns. Maintaining clean code is universally beneficial, but the negative impact of an unclear domain layer becomes particularly severe as projects scale in complexity and size. It is especially in these circumstances that significant economic incentives are typically involved, and therefore, this is also your opportunity to excel as a programmer and architect.

Pollution in the domain model leads to code duplication, cognitive overhead, and fuels testing complexity. All of this results in higher maintenance costs.

EnvironmentConfiguration, Context, ResourcesInterfaceAPIs, Controllers, ViewsModelData, Business Logic, StateTelemetry CautionFilter PII and sensitive databefore sending to third partiesEnvironmentInterfaceModel

Yes, but does this matter in the age of LLMs?

One can imagine how this can have consequences even with current frontier model capabilities. LLMs perform more effectively when working with a context window that focuses on core domain logic rather than being cluttered with cross-cutting concerns. This cleaner separation not only maximizes the efficient use of limited token space but also enables the LLM to better understand business intent and provide more relevant, domain-aligned responses.

Examples

Classical examples of cross cutting concerns are:

  • Authentication/authorization.
  • Input validation.
  • Domain events publishing.
  • Metrics tracking and observability.

Unique Advantages of Macros for Cross-Cutting Concerns

Most languages and frameworks offer infrastructure plumbing to deal with cross cutting concerns. Some examples are the decorator pattern, Aspect-Oriented programming (AOP), middleware, proxy pattern, Higher Order functions (HOC), etc.

Generally speaking, Macros can provide:

  • Expasion at compile-time (no overhead at runtime).
  • Code generation and transformation.
  • Custom syntax and implementations of DSLs.
  • Implicit context and scope access.
  • Modification of the execution environment.

We will see in an increasignly complex implementation some of this advantages.

A simple authorization example

Typical authorization approaches include role-based access control, permission/action-based rules, resource ownership validation, context-based rules (where authorization requires contextual information beyond user-provided credentials), and multi-tenancy checks.

Authorization can quickly lead to duplication in a code base. We can create a macro that encapsulates our needs for a typical feature, for now just controlling authentication and authorization.

(defmacro defn-feature
  [feature-name [env params] macro-params & body]

  (assert (and (some? macro-params)
               (contains? macro-params :feature-base/roles))
          "Macro params are required")

  `(defn ~feature-name
     [~env ~params]
     (let [user# ~(fetch-user env)
           p# ~macro-params]

        (if (some (set (:feature-base/roles p#)) (:user/claims user#))
          ~@body
          (throw (ex-info "Unauthorized" {:user user#}))))))

The macro creates a function with our feature decorating it with some behavior we need. We must provide a feature name, an environment, variables, and some parameters that will help tuning the actions of the macro:

(ns ccc-example
  (:require [feature-base :refer [defn-feature] :as fb]))

(defn-feature my-feature [env params]
  {::fb/roles [:super-admin :admin :user]}
  (println "Hello World"))

By using defn within our macro implementation, we deliberately maintain the familiar syntax of standard Clojure functions while extending their capabilities. We have abstracted and embedded in our future foundational piece our authorization needs. The function accepts one extra map where we can specify the roles.

Macro code is expanded at compile time, providing performance improvements. While these gains are often modest in typical scenarios, alternative approaches like Aspect-Oriented Programming (AOP) rely on interop and reflection, which can introduce overhead.

More significantly, the macro's error management capabilities enable compile-time error detection rather than runtime discovery. For instance, the assert statement that validates macro parameters is enforced during compilation, requiring all macro consumers to specify the necessary parameters.

Instrumentation and telemetry

Instrumentation code can often be messy. It usually involves wrapping domain code with functions that capture errors and send events, and tipically these operations require transforming data from one format (the one used in our application) to another (the one used in the external telemetry platform, if we use one). Interop can even complicate the picture more.

Our previous example contained a function that fulfilled just one goal, to print "Hello, world" in the screen. Let's write an example wrapping the code with instrumentation code:

(defn my-feature [env params]
  (telemetry/wrap-transaction
   (fn []
     (try
       (telemetry/set-transaction-name "my-feature")
       (println "Hello World")
       (catch Exception e
         (telemetry/inform-error e {:feature "my-feature"
                                    :correlation-id (:correlation-id env)
                                    :id (:params/id params)}))))))

This can be similarly extracted to a macro:

(defn with-telemetry
  [feature-name env params error-info-fn & body-fn]
  (telemetry/wrap-transaction
   (fn []
     (try
       (telemetry/set-transaction-name feature-name)
       (body-fn env params)
       (catch Exception e
         (let [error-info (merge {:feature feature-name}
                                 (error-info-fn env params))]
           (telemetry/inform-error e error-info)
           (throw e)))))))

One of the mechanical aspects that often complicates this type of refactoring is that sometimes, it's necessary to adapt data that exists in the function to inform the monitoring and telemetry system. One example can be error communication. In case of an exception, we have a function that extracts only the information we want from the environment and parameters.

When implementing telemetry and error monitoring in enterprise applications, it's crucial to be selective about the information sent to third-party systems. Transmitting sensitive customer data, PII (Personally Identifiable Information), or business-critical information can raise significant privacy and compliance concerns under regulations like GDPR or HIPAA. Beyond legal considerations, there are practical implications: excessive data can inflate costs in volume-based pricing models, overwhelm storage limits, and dilute the signal-to-noise ratio during troubleshooting.

Security risks also emerge when internal architecture details or credentials are inadvertently exposed. This is why implementing functions that deliberately extract only necessary information for error reporting represents a best practice—it maintains explicit control over data boundaries and ensures that monitoring remains both useful and responsible.

This function is the fourth argument of the macro. Cleaning quite a bit the result again (but still quite noisy):

(defn my-feature-2 [env params]
  (with-telemetry "my-feature"
                  env
                  params
                  (fn [env params]
                    {:correlation-id (:correlation-id env)
                     :id (:params/id params)})
                  (fn [env params]
                    (println "Hello World"))))

Composability

Macro composition happens also at compile time, and not runtime. One easy way to compose macros is through nesting. We can extract the work we previously did with authorization to a macro called with-authorization:

(defmacro with-authorization [env user-roles & body]
  `(let [user# (fetch-user ~env)
         user-claims# (:user/claims user#)]
     (if (some (set ~user-roles) user-claims#)
       (do ~@body)
       (throw (ex-info "Unauthorized" {:user user#})))))

Combine with telemetry nesting:

(defmacro defn-feature
  [feature-name [env params] macro-params & body]

  (assert (and (some? macro-params)
               (contains? macro-params :feature-base/roles))
          "Macro params are required")

  `(defn ~feature-name
     [~env ~params]
     (with-telemetry ~(name feature-name)
                     ~env
                     ~params
                     ~(if (contains? macro-params :feature-base/error-parameters)
                        (:feature-base/error-parameters macro-params)
                        `(fn [~env ~params] {}))
                     (fn [~env ~params]
                       (with-authorization ~env (:feature-base/roles ~macro-params)
                                          ~@body)))))

And finally, we end up with the same code we wrote at the begining:

(defn-feature my-feature [env params]
  {::fb/roles [:super-admin :admin :user]
   ::fb/error-parameters (fn [env params]
                           {:correlation-id (:correlation-id env)
                            :id (:params/id params)})}
  (println "Hello World"))

Ending

Clojure macros offer a powerful approach to managing cross-cutting concerns, allowing developers to maintain domain models free from the pollution of authentication, validation, instrumentation, and other secondary concerns. The homoiconic nature of the language sets Clojure apart. Java relies on annotations and bytecode manipulation, JavaScript on metaprogramming hacks, and C# on attributes and reflection, while Clojure offers direct access to the abstract syntax tree without ceremony.

We've shown how macros enable clean abstraction, composability, and reduced code duplication. This approach not only enhances code clarity for human readers but also optimizes for LLM-assisted development by preserving the semantic layers that allow developers to focus on domain logic rather than implementation details. By leveraging macros to handle cross-cutting concerns, we create more maintainable systems where the intent of our code remains transparent while its mechanisms properly hidden.