From shared dependency to microservice: when to stop importing common code
We had a conversational assistant working in one project and wanted to use it in the other three. We packaged it as a Python dependency and each project imported it. For a few months it looked like the right decision. It stopped being one as soon as we had to update it.
Does your team have a slow calculation or a data layer nobody wants to touch?
Let's talkWhat breaks when you share code between projects?
The problem was not the code: it was that the code had been born for a single project.
Once it was packaged as a dependency, each product ended up with a different version installed. Every change meant rebuilding everyone's images. And worst of all: updating it for one project broke it in another, because the decisions built into it had been made with the original case in mind, not a common service.
A shared package born from one specific case is not reusable. It is the same specific case, now installed in four places.
Why not just publish the package?
It was the first thing we considered: upload it to a package index, version it properly and have each project pin its version. We ruled it out for two reasons.
The first is that it solved synchronisation but not the underlying problem. It would still be code designed for one case, running inside four different processes.
The second is about sizing: publishing a package makes sense when third parties consume it. Here only we use it, in four products. Setting up that ceremony is infrastructure we would have to maintain without needing it. With a repository of its own and a deployed service we get the same single-version guarantee with fewer pieces.
The problem that was not about code: a single vector store
Here is the part we did not see at first.
The assistant answers with RAG over each product's content, and that means embeddings. In the original design there was a single vector store, the one for the project it was born for. It worked for that chatbot and no other: putting the others' content into it would have made each one answer with information from the rest.
It was the same mistake as the package, one layer down. Something designed for one case, forced to serve four.
With the microservice, each project has its own vector store and its own chatbot parameters. The service is shared; the data and the configuration belong to each project. That separation is what makes the assistant truly reusable — not the fact that it lives in a separate repository.
What did the change cost us?
A whole afternoon reconfiguring the production infrastructure. And in exchange we accepted three permanent costs.
A network hop where there used to be a function call: what was instant now depends on another service answering.
A single point of failure. Before, if the assistant failed, it failed in one project. Now all four depend on the same service. That is why degradation is part of the design and not a patch: when the assistant does not respond, the page loads in full and the chat shows a message asking the user to try again later. Never a raw error or an endless loading indicator. A shared service will go down at some point, and what the user sees at that moment decides whether the application looks broken or looks well made.
And one more deployment to maintain, with its own release cycle. In exchange, we update one repository and the change reaches all four products: no rebuilding images, no touching four repos, no finding out three weeks later that one was left behind.
How do we know one project cannot read another's data?
Because we checked it by attacking it, not by reading the code.
The project's identity is derived from the credential, never from the request. Each tenant authenticates with its own secret and the service works out which one it is by comparing it; there is no header, parameter or body field that can change it. Verified four different ways: tenant header, body field, another tenant's session identifier, and all three combined.
Content isolation is by destination, not by filter. The service does not store anyone's embeddings: it queries the project's own backend, with a different URL and outbound token for each one. There is no shared store with a condition that could be forgotten on some query path.
A cross-tenant attempt returns 404, not 403. A 403 would confirm that the resource exists. It is a one-digit difference and it decides whether an attacker can enumerate.
And a clarification worth making even though it takes something away: sessions and messages share a table, separated by a column. What is physically separate is the vector store, which is where the client's content lives.
What we found when we audited it: the empty string
We asked for an isolation audit with an explicit instruction: “tell me the truth even if it is uncomfortable, an 'it is not guaranteed' is just as useful to me”. Twelve cross-tenant attacks. Eleven bounced. One did not.
With a token inherited from the single-project days, the conversation list returned those of every project. Not the message content, but their titles — and the titles are generated by the model from the user's first question, so they were content, not metadata.
The cause fit in one line: the project filter was conditional. If the project was the empty string, the condition was false and the query went out unfiltered.
A detail that explains why it was not caught earlier: there were 126 green tests, and among them one that checked the list and another that checked the inherited token. Each half of the fault was covered. Their intersection was not.
The real cause was not the line. It was that the empty string meant two things at once: "no project" and, by omission of the filter, "all projects". A value that represents the absence of identity has to close a query, not open it.
Why was the fix not to reject?
The obvious way out was "no project, reject". It was the wrong one, and that was clear before writing any code: the two live consumers had 100% of their conversations under that value. Rejecting would have left both of them without a list.
The fix was to strip its special status: always filter, with no branches, so that the empty string becomes an ordinary, closed namespace — the sessions of whoever has no project, and nothing else. No wildcards and no breaking anyone.
The tests were written first, and red. One of them derives the operations by introspection instead of listing them, so any future operation inherits the rule without anyone having to remember to add it to a list. From 126 tests to 142, none broken, and the twelve original attacks re-run without changes.
When would this apply and when not?
Yes, when shared code was born for one specific case and you are on the second forced adaptation.
No, if there are only two consumers and the code is stable: a versioned package costs far less than a deployment.
The question that decides it is not how many projects use it, but whether the shared code has state or configuration specific to each consumer. If it does, sooner or later it needs to be a service. If not, a package is enough.
A sentinel value that means "absence" ends up meaning "all" as soon as someone puts it in a condition. The solution is not to forbid it: it is to make it stop being special.
Tech stack
- Django
- Django REST Framework
- Ports and adapters
- Per-project vector store
- Inference on a local model
- Federation against each project's backend