So, I've run into a situation I'm unsure how to handle. Let's say I have the following components: task, database, and in-memory. I know that I can use configurable dependency to determine which persistence component to use, but how should I handle that in the task component. Should the task component implement adapters inside itself, or should I have two separate task components one for database interactions and another for in-memory interactions? I'm leaning towards the adapters, but I'm not sure if that violates the Polylith pattern.
The only "pattern" in Polylith is that bricks use components via their interface only.
Are the database and in-memory components alternatives with a common interface? Is the choice between them made at build-time or should it be available at run-time?
If it's a built-time choice and you can have a common interface for both implementations, then profiles and projects deps.edn making the selection is the right way to go (within Polylith).
Is the common interface defined in the task component? For example, maybe define a protocol called TaskRepository and then define the adapters for the sql interaction and the in-memory interaction. Or do you mean define a common Repository interface?
It resembles the ports and adapters architecture, but I might be misunderstanding something.
The way you spoke about the (theoretical) components, it sounded like database and in-memory were alternate implementations of the same thing -- a persistence interface.
For the database and the in-memory components, my thought was that they would be lifecycle-managed components to manage either the state of the database connection or an atom that wraps a map.
An example from work: we have an i18n component connected to a database and an implementation of it that uses JSON as the data format. The API is identical: we have an i18n component and an i18n-preview component -- both have ws.i18n.interface as their (identical) API, with different implementations. The database-backed component is in the :+default alias (profile) and the JSON-backed component is in the :+preview alias (profile).
Do you need to be able to choose at runtime? Or build-time? That's the key decision.
I think the choice would be at build time.
Basically, what I'm think is that I should be able to swap the database component for the in-memory component and the task component shouldn't have to change.
When we build most of our projects, we select the database-backed i18n component. But our preview project selects the JSON-backed i18n-preview component. All the code uses ws.i18n.interface so it can't tell whether the i18n stuff is in the database or the JSON file.
Have you read the "profiles" stuff in the Polylith docs?
No, I haven't got to that point yet.
https://cljdoc.org/d/polylith/clj-poly/0.2.22-SNAPSHOT/doc/profile explains how to have alternate implementations, selected at build time.
So, in your example, are i18n and i18n-preview separate components that have the same interface?
Yes.
They both have src/ws/i18n/interface.clj and it's identical for both the i18n component and the i18n-preview component in terms of the same functions with the same signatures.
But the implementations are very different (although, right now, those are both ws.i18n.impl -- they could have different names since the implementation ns is irrelevant to calling code).
Hmm, is that interface defined in two separate places, or is there a single interface and the components are defined separately? I might be missing something.
There is literally src/ws/i18n/interface.clj in both components.
Ah, okay. That makes sense now.
So in my case there would be a task-sql component and a task-in-memory component?
All the other code requires and uses ws.i18n.interface. The projects deps.edn file select either the database-backed version (`i18n`) or the JSON-backed version (`i18n-preview`).
The :dev alias does not contain either. :+default contains the one I use for development (the database-backed one) but there's a :+preview alias if I want to develop against that instead.
Or something like that and they would have an identical interface.
You could have a task-sql component with <top-ns>.task.interface and a task-in-memory component with <top-ns>.task.interface
The fns/signatures would be identical. The implementations would be different (and the dependencies -- libraries -- would be different too).
Here's the profiles in our deps.edn (workspace-level):
:+default {:extra-deps {;; by default, we use the real i18n and web server:
poly/i18n {:local/root "components/i18n"}
poly/web-server {:local/root "components/web-server"}}
:extra-paths ["components/i18n/test"
"components/web-server/test"]}
:+preview {:extra-deps {;; or the preview i18n and the real web server:
poly/i18n {:local/root "components/i18n-preview"}
poly/web-server {:local/root "components/web-server"}}
:extra-paths ["components/i18n-preview/test"
"components/web-server/test"]}(we used to have a dummy web server for another context, hence the duplication above)
This is the preview version of ws.i18n.interface
This is projects/preview/deps.edn
All the other projects use :local/root "../../components/i18n"
Hopefully, that example helps?
Yeah this is super helpful. So if the interface needs to change, then both component interfaces need to change.
Right. One common interface across all implementation components.
I'm reading through the profile section now, and I've also read through the FAQ on the Polylith page which covers some of these questions, too -> https://polylith.gitbook.io/polylith/conclusion/faq