Isolating Connection Pools for Multiple DataSources in Spring Boot

This guide is part of Spring Boot DataSource Configuration. When a Spring Boot service talks to more than one database — a primary writer and a read replica, or one schema per tenant — the default auto-configuration only wires a single HikariDataSource. The moment you declare a second bean without isolating its pool, sizing and metrics collapse into ambiguity. The first symptom is usually a startup failure: Parameter 0 of method ... required a single bean, but 2 were found: primaryDataSource, replicaDataSource. Once that is resolved with @Primary, the next failure is quieter and far more dangerous: two pools that each open maximumPoolSize connections, silently summing past the server’s max_connections and producing FATAL: remaining connection slots are reserved for non-replication superuser connections under load.

This page shows how to define multiple @ConfigurationProperties-bound HikariDataSources, size each pool so the totals stay under the database limit, give every pool a distinct poolName for metrics, and bind a transaction manager to each datasource.

Key operational takeaways:

  • Each datasource needs its own DataSourceProperties, HikariDataSource, EntityManagerFactory, and PlatformTransactionManager bean — partial wiring leaks one pool’s config into another.
  • The sum of every pool’s maximumPoolSize across all instances must stay below the server max_connections minus superuser and replication reservations.
  • A distinct poolName per datasource is the only way hikaricp_connections_* metrics stay separable in Prometheus.
  • @Primary resolves bean ambiguity but does not isolate pools; it only picks a default for unqualified injection points.

Rapid incident diagnosis

The two failure modes look different in the logs. A wiring problem fails at startup; a sizing problem fails at peak traffic.

Bean ambiguity surfaces immediately:

APPLICATION FAILED TO START
Parameter 0 of constructor in com.example.ReportService required a single bean,
but 2 were found:
	- primaryDataSource: defined by method 'primaryDataSource'
	- replicaDataSource: defined by method 'replicaDataSource'

Connection-slot exhaustion surfaces later, on the database side, and is the signature of unbounded pool totals:

org.postgresql.util.PSQLException: FATAL: remaining connection slots are reserved
  for non-replication superuser connections

When you see the second error, do not raise max_connections. First count what your pools actually demand. Query the live server to see how connections are distributed by application name — set a distinct ApplicationName per pool via the JDBC URL so the rows separate cleanly:

SELECT application_name, count(*)
FROM pg_stat_activity
GROUP BY application_name
ORDER BY count(*) DESC;

If two application names together approach max_connections, the pools are oversized, not the server undersized. If a single pool dominates with mostly idle rows, that pool’s maximumPoolSize is too high for its actual concurrency — see Detecting Connection Pool Saturation for distinguishing genuine saturation from idle over-allocation.

Mathematical sizing across multiple pools

The single-pool heuristic of (core_count * 2) + effective_spindle_count still anchors each pool, but with multiple datasources the binding constraint is the shared server limit, not any one pool. Treat the database max_connections as a budget split across pools and instances.

Let:

  • C = server max_connections
  • R = reserved slots (superuser + replication + migrations + ad-hoc admin), typically 10–15
  • N = number of running service instances
  • P = number of pools per instance

The per-instance budget is (C - R) / N. Distribute that budget across pools weighted by each pool’s traffic share. A writer that handles every mutation needs more than a replica that only serves a fraction of reads, but the inverse is common too: read-heavy services give the replica the larger slice.

Worked example. A PostgreSQL instance has max_connections = 100. Reserve R = 15. The service runs N = 4 replicas, each with a primary and replica pool (P = 2). Per-instance budget: (100 - 15) / 4 = 21.25, round down to 21. Split 60/40 toward the writer: primary maximumPoolSize = 12, replica maximumPoolSize = 8 (total 20, leaving 1 slot of headroom per instance). Worst case all four instances saturate both pools: 4 * 20 = 80 connections, comfortably under the 85 usable slots.

If you cannot fit, the lever is fewer connections per pool, not a higher server limit — a front proxy multiplexes far more cheaply than raw backend slots. For the broader sizing rationale and async caveats, see Tuning Spring Boot HikariCP for Microservices.

Pool Traffic share maximumPoolSize Per-instance total Cluster total (N=4)
primary 60% 12 20 80
replica 40% 8
reserved ® 15
server max_connections 100

Exact remediation and configuration

Bind each datasource to its own property prefix so HikariCP-specific settings are isolated. Nest the Hikari properties under a configuration key so DataSourceProperties hands off cleanly to the builder.

app:
  datasource:
    primary:
      url: jdbc:postgresql://primary.db.internal:5432/app?ApplicationName=app-primary
      username: app_writer
      password: ${PRIMARY_DB_PASSWORD}
      driver-class-name: org.postgresql.Driver
      configuration:
        pool-name: primary-pool
        maximum-pool-size: 12
        minimum-idle: 4
        connection-timeout: 3000
        max-lifetime: 1800000
        keepalive-time: 30000
    replica:
      url: jdbc:postgresql://replica.db.internal:5432/app?ApplicationName=app-replica
      username: app_reader
      password: ${REPLICA_DB_PASSWORD}
      driver-class-name: org.postgresql.Driver
      configuration:
        pool-name: replica-pool
        maximum-pool-size: 8
        minimum-idle: 2
        connection-timeout: 3000
        max-lifetime: 1800000
        read-only: true

The pool-name values become the pool label on every metric and the prefix on every HikariCP log line (replica-pool - Connection is not available...). The read-only: true on the replica pool sets the connection to read-only mode, which lets PostgreSQL reject accidental writes routed to the replica.

Now declare the beans. The @ConfigurationProperties annotation on each method is what isolates the binding; without it, both pools inherit whatever spring.datasource defaults are present.

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    @ConfigurationProperties("app.datasource.primary")
    public DataSourceProperties primaryDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    @Primary
    @ConfigurationProperties("app.datasource.primary.configuration")
    public HikariDataSource primaryDataSource(
            @Qualifier("primaryDataSourceProperties") DataSourceProperties props) {
        return props.initializeDataSourceBuilder()
                    .type(HikariDataSource.class)
                    .build();
    }

    @Bean
    @ConfigurationProperties("app.datasource.replica")
    public DataSourceProperties replicaDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    @ConfigurationProperties("app.datasource.replica.configuration")
    public HikariDataSource replicaDataSource(
            @Qualifier("replicaDataSourceProperties") DataSourceProperties props) {
        return props.initializeDataSourceBuilder()
                    .type(HikariDataSource.class)
                    .build();
    }
}

@Primary on the primary beans resolves the ambiguity error: any unqualified injection of DataSource or JdbcTemplate gets the writer. Code that needs the replica must ask for it explicitly with @Qualifier("replicaDataSource").

Each datasource also needs its own transaction manager. A PlatformTransactionManager is bound to exactly one datasource; sharing one manager across two pools means @Transactional only ever controls the primary, and replica work runs in autocommit with no rollback semantics.

@Configuration
public class TransactionConfig {

    @Bean
    @Primary
    public PlatformTransactionManager primaryTransactionManager(
            @Qualifier("primaryDataSource") DataSource ds) {
        return new DataSourceTransactionManager(ds);
    }

    @Bean
    public PlatformTransactionManager replicaTransactionManager(
            @Qualifier("replicaDataSource") DataSource ds) {
        return new DataSourceTransactionManager(ds);
    }
}

Select the manager per method with @Transactional("replicaTransactionManager"). With JPA, declare a separate LocalContainerEntityManagerFactoryBean per datasource, each scanning its own entity package, and pair each with a JpaTransactionManager. For multi-tenant setups where every tenant maps to a separate schema or database, the same pattern scales: one DataSourceProperties + HikariDataSource + manager triple per tenant, registered programmatically and keyed by tenant id rather than declared statically.

Apply changes by rolling restart. Pool definitions are read at startup, so a config-map update plus a rolling deploy swaps sizing without dropping the cluster below capacity, as long as the new per-instance totals still fit during the overlap window when old and new pods coexist.

Validation and verification

After deploy, confirm each pool registered under its own name. Hit the Actuator metrics endpoint with the pool tag:

/actuator/metrics/hikaricp.connections.active?tag=pool:primary-pool
/actuator/metrics/hikaricp.connections.active?tag=pool:replica-pool

Two distinct responses confirm the pools are isolated; a 404 or a single merged figure means a pool-name is missing or duplicated.

Cross-check against the server. Because each pool sets a distinct ApplicationName, the rows separate:

SELECT application_name, state, count(*)
FROM pg_stat_activity
WHERE application_name IN ('app-primary', 'app-replica')
GROUP BY application_name, state
ORDER BY application_name;

The total per application name must never exceed that pool’s maximumPoolSize times the instance count. If it does, a stale pool from a previous deploy is still holding connections, or minimum-idle plus churn is briefly overshooting — confirm by watching the count settle after traffic drains.

Finally, assert the budget holds under load. Run a load test that saturates both pools simultaneously and watch the global count against max_connections. The sum of hikaricp_connections_max across both pools, multiplied by replica count, is the hard ceiling your test must stay under.

Frequently Asked Questions

Does @Primary isolate the connection pools?
Why do I need a separate transaction manager per datasource?
How do I keep the pools from exhausting max_connections?
Why give each pool a distinct poolName?
Can this pattern handle per-tenant databases?