Some Polylith tricks, without Polylith

Polylith is no doubt an attractive framework with huge benefits. However their documentation assumes you want to go all in. This article serves to guide the setup of your code so as to use two of Polylith's 'tricks' to achieve multiple implementations of an interface without runtime dispatch. Best used for an architectural level 'interface API' where choosing the implementation at build or REPL-start time is more benefit than hassle.

We will use the example of having two different database backends, PostgreSQL and Datomic, and one simple function q-by-ident. We will start with the directory structure then look at that from the point of view of deps.edn, and lastly look at the code.

Directory Structure

project/
├── interfaces/                            # Switchable facade layer
│   ├── storage/
│   │   └── src/your_domain/storage.clj       # Generic storage API
│   └── users-storage/
│       └── src/your_domain/users_storage.clj # Users API
│
├── packages/                              # Implementation layer
│   ├── storage-datomic/
│   │   └── src/your_domain/storage_datomic.clj
│   ├── storage-postgresql/
│   │   └── src/your_domain/storage_postgresql.clj
│   │
│   ├── users-storage-datomic/
│   │   └── src/your_domain/users_storage_datomic.clj
│   └── users-storage-postgresql/
│       ├── src/your_domain/users_storage_postgresql.clj
│       ├── src/your_domain/users_query.clj
│       └── src/your_domain/users_txn.clj
│
└── src/your-domain/                     # Consumer code
    ├── starter_app/
    │   └── auth.clj                     # Uses your-domain.users-storage
    └── restaurant/
        └── main.clj                     # Uses your-domain.storage

Here we are showing two APIs: storage and users-storage, implemented in PostgreSQL and Datomic. I'm using the term package, that in Polylith would be the implementation of a component. An equivalent Polylith directory structure would be more involved and require multiple deps.edn files.

deps.edn

This is where your project's classpath is setup. If I wanted to use the PostgreSQL API then I might have this as the :paths map-entry:

:paths 
["src" "src-contrib" "resources"
 "interfaces/storage/src" "packages/storage-postgresql/src"
 "interfaces/users-storage/src" "packages/users-storage-postgresql/src"
]

For Datomic it would be this:

:paths 
["src" "src-contrib" "resources"
 "interfaces/storage/src" "packages/storage-datomic/src"
 "interfaces/users-storage/src" "packages/users-storage-datomic/src"
]

I would then comment out the Datomic artifact coordinates from :deps and make sure the PostgreSQL ones are not commented out. As a more sophisticated deps.edn user you might employ separate aliases for datomic and postgresql each having :extra-paths and :extra-deps, and selectively use them for development and build.

code

The interface namespace acts as a switchboard, requiring and exposing functions from the chosen implementation:

File: interfaces/storage/src/your_domain/storage.clj

(ns your-domain.storage
  (:require [your-domain.storage-postgresql :as impl]))

(def q-by-ident impl/q-by-ident)

Or for Datomic:

(ns your-domain.storage
  (:require [your-domain.storage-datomic :as impl]))

(def q-by-ident impl/q-by-ident)

The implementations are of course completely different:

File: packages/storage-postgresql/src/your_domain/storage_postgresql.clj

(ns your-domain.storage-postgresql
  (:require [next.jdbc :as jdbc]))

(defn q-by-ident [db-conn ident]
  (let [[attr val] ident]
    (jdbc/execute-one! db-conn
      [(str "SELECT * FROM entities WHERE " (name attr) " = ?") val])))

File: packages/storage-datomic/src/your_domain/storage_datomic.clj

(ns your-domain.storage-datomic
  (:require [datomic.api :as d]))

(defn q-by-ident [db ident]
  (d/pull db '[*] ident))

Consumer code requires the interface, never the implementation:

File: src/starter_app/auth.clj

(ns starter-app.auth
  (:require [your-domain.storage :as storage]))

(defn lookup-user [sys username]
  (storage/q-by-ident sys [:user/username username]))

To switch backends: change the require in the interface namespace, update your deps.edn paths, and restart your REPL. Consumer code remains unchanged.

Conclusion

This approach gives you Polylith's core architectural benefits - complete backend isolation and zero runtime overhead - without the need to put your entire codebase into Polylith's structure and without the complexity of the Polylith CLI toolchain or multiple deps.edn files. The two key tricks are:

  1. Namespace indirection: Interface namespaces act as load-time switchboards that require and expose implementation functions
  2. Build-time path selection: Only the implementation you're using appears on the classpath, keeping deployable artifacts clean

Polylith offers sophisticated change detection, incremental testing and multiple deployable artifacts from the same codebase. When you need those features, you're already most of the way there - the working code exists, you just need to reorganize into Polylith's structure.

Published: 2025-12-09

Tagged: Polylith Clojure

Archive