Schema-per-tenant in Spring Boot: ten schemas, ten Flyway streams, and the connection math nobody does
Multi-tenant isolation with a Postgres schema per bounded context. Separate Flyway migration streams, a separate EntityManagerFactory each, and the HikariCP pool sizing that quietly decides whether your decomposition survives contact with max_connections.
Ten Postgres schemas in one database. Each with its own Flyway migration stream, its own EntityManagerFactory, its own tables that no other schema is allowed to join against. Seventy-two migrations and a hundred and seventy-six indexes across them.
People hear "schema per tenant" and think it is about tenants. In this system it is about contexts, and the distinction is the whole point.
What the schemas actually separate
A schema boundary is the only isolation Postgres will enforce for you for free. Not a naming convention, not a code review rule, not a comment. If module A's entities live in schema A and module B's live in schema B, then a developer who tries to join across them has to do something visibly deliberate to make it work.
That is worth more than it sounds. Every modular monolith dies the same death: someone writes a join across a boundary because the data was right there, and does it a hundred more times, and now the modules are one module wearing a trench coat. You cannot decompose that later. The join is a dependency and it is invisible in the module graph.
Put the boundary in the database and the shortcut stops being convenient.
Separate Flyway streams
Each schema owns its migrations. Not one db/migration folder with everything in date order, one stream per schema, versioned independently.
The reason is ownership, not tidiness. If a module's migrations live with the module, then extracting that module later means taking its migration history with it, and the extracted service starts life with a complete, replayable schema history. If everything shared one stream, extraction means archaeology: reading a hundred files to work out which ones were about the thing you are pulling out.
Two of the services have since been extracted. Both took their migration streams with them and neither needed a hand-written baseline. That is the entire payoff, and it arrives years after the decision.
The connection math
Here is the part that gets skipped, and it is the part that will page you.
Ten schemas, ten EntityManagerFactory beans, ten connection pools. HikariCP's default pool is ten connections. Ten pools times ten connections is a hundred connections from one application.
Postgres ships with max_connections at 100.
So the naive configuration is one application that, at full saturation, consumes the entire connection budget of the database and leaves nothing for migrations, for psql, for a backup job, or for the second replica of itself. The failure mode is not a slow query. It is FATAL: sorry, too many clients already, at the worst possible moment, on every connection including the one you would use to fix it.
Now project that forward. The plan is to decompose into roughly eleven services. Eleven services at the default ten connections each is a hundred and ten connections, against a limit of a hundred, before a single one of them scales past one replica.
A default that is fine for one service is a production incident for eleven. Nobody changes it, because nobody re-reads a default that has never hurt them.
The pool sizes are reasoned in the config, with the arithmetic written down next to them, because the number is meaningless without the count it was derived from. When the service count changes, the comment tells the next person what has to change with it.
The general shape: a connection pool should be sized from what the database can give, divided by everything that wants some, not from what one service would enjoy having. And a pool bigger than your CPU count mostly buys you queueing inside Postgres instead of queueing inside your app, which is the same wait wearing a different hat.
When schema-per-context is the wrong call
It costs something. Ten EntityManagerFactory beans is real configuration. Cross-context reads become explicit API calls or events rather than joins, which is the point, but it is also work. Transactions do not span schemas without care.
If the modules are genuinely one thing and always will be, this is ceremony. The test I use: could you imagine deploying this module separately in three years? If the honest answer is no, share the schema and stop paying for isolation you will never collect on.
If the answer is yes, put the wall in now. Walls are cheap to build in an empty room and expensive to build around furniture.
The rule
Isolation you have to remember to maintain is not isolation. It is a habit, and habits leave when people do.
The DanceClub platform is a Kotlin and Spring Boot modular monolith built this way from the start, with architecture rules enforced at build time so the boundaries fail the build rather than fail review. It is being decomposed service by service now, and the only reason that is a series of ordinary sprints instead of a rewrite is that the walls were poured before there was anything leaning on them.
Get the next one in your inbox.
No schedule, no spam. One email when I publish something new.
If this resonated, you might like the manifesto or the archive.
Edvard Grei · edvone.dev