Return to site

๐Ÿƒ๐Ÿ—„๏ธ DATABASE MULTITENANCY with Spring Boot & ARCONIA

One SaaS. Many customers. How far should data isolation go?

ยท spring

๐Ÿ”ธ TL;DR

A multitenant SaaS can store every customer's data in the same tables, separate schemas, or separate databases. Arconia brings the tenant context + database routing needed to implement the database-per-tenant model cleanly with Spring Boot.

Section image

๐Ÿ”ธ 1๏ธโƒฃ WHAT IS ARCONIA?

Arconia is an open-source add-on framework for Spring Boot focused on modern enterprise applications.

It provides modules for:

โ–ช๏ธ Multitenancy

โ–ช๏ธ Dev Services

โ–ช๏ธ OpenTelemetry

โ–ช๏ธ AI observability

โ–ช๏ธ Cloud-native tooling

It complements Spring Boot rather than replacing it.

For database multitenancy, two pieces are particularly interesting:

HTTP request
     โ†“
Arconia TenantContext
     โ†“
TenantDataSource
     โ†“
Customer database

Arconia resolves the current tenant, propagates it through the request, then its Data JDBC module can route JDBC connections to the corresponding database.

๐Ÿ”ธ 2๏ธโƒฃ WHAT IS MULTITENANCY?

A tenant is usually a customer organization, not an individual user.

Imagine a CRM SaaS:

CRM SaaS
 โ”œโ”€โ”€ ACME
 โ”‚    โ”œโ”€โ”€ Alice
 โ”‚    โ””โ”€โ”€ Bob
 โ”‚
 โ””โ”€โ”€ GLOBEX
      โ”œโ”€โ”€ John
      โ””โ”€โ”€ Sarah

Everyone runs on the same SaaS product, but:

ACME must never see GLOBEX's CRM data.

The architectural question becomes:

Where do we create that isolation boundary?

๐Ÿ”ธ 3๏ธโƒฃ THE 3๏ธโƒฃ DATABASE MULTITENANCY STRATEGIES

There are three classic approaches.

1๏ธโƒฃ SHARED DATABASE + SHARED TABLES

customers
--------------------------------
id | tenant_id | customer_name
1  | ACME      | Foo Corp
2  | GLOBEX    | Bar Corp

Every tenant shares the same tables.

Isolation comes from something like:

SELECT *
FROM customers
WHERE tenant_id = 'ACME';

โœ… Cheapest and easiest to operate

โš ๏ธ A missing tenant filter can expose another customer's data

-

2๏ธโƒฃ SHARED DATABASE + SEPARATE SCHEMAS

crm_db
 โ”œโ”€โ”€ acme.customers
 โ””โ”€โ”€ globex.customers

Same database server, but each tenant gets its own schema.

โœ… Stronger logical isolation

โœ… Tables don't require tenant_id everywhere

โš ๏ธ More schemas and migrations to manage

-

3๏ธโƒฃ DATABASE PER TENANT

ACME   โ†’ crm_acme
GLOBEX โ†’ crm_globex
FOO    โ†’ crm_foo

Each customer gets its own database.

โœ… Strongest of these three data-isolation boundaries

โš ๏ธ More databases, pools, migrations and operations to manage

Hibernate also describes these as discriminator-, schema-, and database-based multitenancy.

Section image

๐Ÿ”ธ 4๏ธโƒฃ WHY DATABASE-PER-TENANT?

Why not simply add tenant_id everywhere?

Because sometimes strong isolation is worth the operational cost.

โ–ช๏ธ Data isolation A query against ACME's DB cannot accidentally return GLOBEX rows.

โ–ช๏ธ Smaller blast radius A bad query such as:

DELETE FROM customers;

damages one tenant database rather than every tenant stored in the same tables.

โ–ช๏ธ Independent backup & restore Restore ACME without restoring the complete SaaS dataset.

โ–ช๏ธ Independent scaling A huge customer can move to stronger infrastructure.

โ–ช๏ธ Data residency European and Asian customers can potentially live in different infrastructure locations.

โ–ช๏ธ Lifecycle management Onboarding, migration or deletion can happen database by database.

But database-per-tenant isn't free: Arconia notes that dynamically created tenant data sources each hold a connection pool, so the number of tenant data sources must be controlled.

And separate databases don't automatically mean separate physical infrastructure: if several databases share the same DB server, a server-level failure can still affect them all.

๐Ÿ”ธ 5๏ธโƒฃ USE CASE: A MULTITENANT CRM SaaS

Imagine:

CRM SaaS
                 โ”‚
        Spring Boot API
                 โ”‚
         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
         โ”‚               โ”‚
      ACME             GLOBEX
         โ”‚               โ”‚
    crm_acme DB      crm_globex DB

Alice authenticates as an ACME user.

Her request carries:

tenant = ACME

The application determines the tenant once:

Request
   โ†“
Tenant = ACME
   โ†“
TenantContext
   โ†“
TenantDataSource
   โ†“
crm_acme

Your business code can still simply ask:

customerRepository.findAll();

It doesn't need:

findAllByTenantId("ACME");

The database connection itself is already pointing at ACME's database.

That's the interesting part.

๐Ÿ”ธ 6๏ธโƒฃ SET IT UP WITH ARCONIA IN 5๏ธโƒฃ STEPS

Arconia Multitenancy currently requires Java 25+, because its tenant context uses Java ScopedValue.

1๏ธโƒฃ ADD ARCONIA MULTITENANCY

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>io.arconia</groupId>
            <artifactId>arconia-bom</artifactId>
            <version>0.30.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>io.arconia</groupId>
        <artifactId>arconia-multitenancy-web-spring-boot-starter</artifactId>
    </dependency>

    <dependency>
        <groupId>io.arconia</groupId>
        <artifactId>arconia-multitenancy-data-jdbc</artifactId>
    </dependency>
</dependencies>

The Web starter resolves and propagates the tenant. arconia-multitenancy-data-jdbc provides TenantDataSource, which implements database-per-tenant JDBC routing. The Data JDBC module intentionally has no auto-configuration.

-

2๏ธโƒฃ DECLARE YOUR CRM TENANTS

arconia:
  multitenancy:
    details:
      tenants:
        - identifier: acme
          enabled: true
        - identifier: globex
          enabled: true

Arconia can maintain a known set of tenants and reject unknown or disabled identifiers. For dynamic SaaS onboarding, tenant details can instead come from JDBC or your own TenantDetailsService.

-

3๏ธโƒฃ CONFIGURE THE CUSTOMER DATABASES

acme:
  url: jdbc:postgresql://db-acme/crm
  username: ${ACME_DB_USER}
  password: ${ACME_DB_PASSWORD}

globex:
  url: jdbc:postgresql://db-globex/crm
  username: ${GLOBEX_DB_USER}
  password: ${GLOBEX_DB_PASSWORD}

Each CRM tenant maps to connection details controlled by the application. Don't construct JDBC URLs directly from an untrusted tenant identifier: use it as a validated lookup key.

-

4๏ธโƒฃ ROUTE CONNECTIONS WITH TenantDataSource

@Bean
TenantDataSource tenantDataSource(
        DataSource acmeDataSource,
        DataSource globexDataSource) {

    return TenantDataSource.builder()
        .dataSource("acme", acmeDataSource)
        .dataSource("globex", globexDataSource)
        .build();
}

TenantDataSource checks the current tenant and delegates JDBC connections to its database. It works with plain JDBC, JdbcClient, JdbcTemplate, and Spring Data JDBC.

-

5๏ธโƒฃ LET ARCONIA RESOLVE THE TENANT

@GetMapping("/customers")
List<Customer> customers(
        @TenantIdentifier String tenant) {

    return customerRepository.findAll();
}
curl \
  -H "X-TenantId: acme" \
  http://localhost:8080/customers
https://docs.arconia.io/arconia/latest/multitenancy/web/

By default, the Web starter resolves X-TenantId, validates it and binds it to TenantContext. JDBC access then reaches ACME's datasource rather than requiring tenant_id in each repository query.

The resulting architecture is:

X-TenantId: acme
       โ”‚
       โ–ผ
TenantContext = acme
       โ”‚
       โ–ผ
TenantDataSource
       โ”‚
       โ–ผ
crm_acme
       โ”‚
       โ–ผ
customers

๐Ÿ”ธ TAKEAWAYS

โ–ช๏ธ Multitenancy does not automatically mean one DB per customer.

โ–ช๏ธ Shared tables + tenant_id are often the simplest and cheapest model.

โ–ช๏ธ Separate schemas provide an intermediate isolation level.

โ–ช๏ธ Database-per-tenant increases isolation and reduces the potential blast radius of tenant-specific data failures.

โ–ช๏ธ The price is operational complexity: more databases, migrations and connection pools.

โ–ช๏ธ Arconia makes the database-per-tenant model interesting because tenant resolution and JDBC routing become infrastructure concerns instead of business-code concerns. (findAll() and not findAllById("Acme"))

Your repository can remain:

customerRepository.findAll();

while the architecture decides whether that means:

ACME โ†’ crm_acme

or:

GLOBEX โ†’ crm_globex

Same Spring Boot application. Different tenant. Different database. ๐Ÿ—„๏ธ

#Java #Spring #SpringBoot #Arconia #Multitenancy #SaaS #PostgreSQL #JDBC #SoftwareArchitecture #CloudNative

See Spring Dev Advocate video talking of that topic: https://youtu.be/uZepsaaASO4?si=_VzzOeYH6JYvvwzY

Go further with Java certification:

Java๐Ÿ‘‡

Spring๐Ÿ‘‡

SpringBook๐Ÿ‘‡

JavaFullstackBook๐Ÿ‘‡