A polyglot stack is usually an accident — the residue of whoever was hired when. Ours is a decision, and the decision is defensible only because of where we put the boundaries.
The allocation
| Layer | Language | Chosen for |
|---|---|---|
| Core domain | Java, modular monolith | Business rules that stay refactorable years later, by people who did not write them |
| AI orchestration | Python, separate service | Where the ecosystem lives — and where the churn is quarantined |
| Surface | TypeScript | One language across clients, sharing types with the API contract |
| Substrate | SQL, versioned migrations | Schema changes as reviewed artefacts, not ORM side effects |
The real reason for the Java choice
“Java is good for large codebases” sounds like taste. The specific mechanism is worth naming, because it is the whole argument.
A static type system with a mature refactoring toolchain means a rename or a signature change is a mechanical operation with a compiler-verified result. In a dynamically typed codebase the same change is a search, a judgement, and a prayer: you find the call sites you thought of, and production finds the rest. That difference is invisible in month one and dominant in year three — which is exactly when the people making the change are no longer the people who wrote the code.
We pay for it on a specific axis, and it is fair to name it here rather than in a footnote. The Java service takes about 196 seconds to cold-start from sleep on free compute, against about 22 for the Python one. Startup time is the JVM’s worst axis and we landed on the wrong side of it. Refactorability in year three against three minutes of cold start today is a trade we made deliberately, not one we discovered afterwards.
Why AI orchestration is quarantined
Putting Python in its own service is not about language preference. It is about rate of change. The AI layer is where a dependency ships a breaking change on a Tuesday, a model is deprecated with sixty days’ notice, and the accepted way to do something differs from how it was six months ago.
Fast-moving and slow-moving code should not share a release.
That is the whole reason for the seam — not that Python and Java cannot coexist, but that rules about how your business works should not be redeployed every time a model client changes its constructor signature. A separate service means the churn has a documented boundary and a bounded blast radius.
Why this is not four teams’ worth of overhead
Because only one of these owns the domain. The Python service orchestrates inference; it does not hold business rules. The TypeScript surface renders and collects; it does not decide. The domain lives in exactly one place, in one language, and everything else is a surface onto it or a provider behind it.
The failure mode of polyglot systems is not the number of languages. It is business logic quietly accumulating in three of them, so that answering “what are the rules?” requires reading all three.
It is worth being concrete about how that leak happens, because it never arrives announced. It arrives as a validation. The frontend needs to grey out a button, so it checks a condition locally rather than asking. The condition is correct the day it is written. Six months later the rule changes in the domain, the frontend still enforces the old one, and now there are two answers to one question with no way to tell which is authoritative.
The rule we apply is narrow enough to be checkable in review: a surface may decide how to display a state. It may not decide what the state is.
What the boundary costs
- Contract maintenance. Types shared with the API contract are only useful if generating and checking them is automatic. The moment that becomes a manual step, it becomes an out-of-date step.
- A network hop that used to be a method call. The AI boundary is real, with timeouts, retries, and partial failure. That is the price of the blast radius and it is not small.
- Four toolchains. Four dependency managers, four build systems, four sets of security advisories to read every week.
We think that bill is worth paying. We would not think so for a fifth language added because one library was convenient — the bar for a new language is a layer it is uniquely suited to, not a task it happens to be good at.
The connection to ownership
Choosing the right language per layer is only possible if you own the layers. A stack assembled around a single vendor’s managed runtime tends to converge on whatever that vendor supports well — which is a technical decision made by someone else’s product roadmap.
Ownership is the precondition for intelligence. That applies to language choice as much as anything else: a system you cannot reshape per layer can only be as good as its least suitable default.
Try this
Do not copy this stack. Four languages on a solo project is a way to be slow in four ways at once, and the discipline that makes it survivable is expensive.
Copy the question instead. For each part of your system: what is its rate of change, and does it share a release with something that changes at a very different rate? That question produced this architecture. The language count is downstream of the answer.
Own every layer.