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.

Isolation contains a degraded dependency With one shared pool, slow reporting queries consume every connection and the checkout path fails too. With separate pools the reporting pool saturates alone and the checkout path is unaffected. one shared pool checkout path reporting queries pool of 20 — all 20 held by reports checkout fails too a reporting slowdown becomes a revenue outage isolated pools checkout path reporting queries pool of 14 — healthy pool of 6 — saturated checkout unaffected the blast radius is the workload that caused it The total connection count is identical in both cases — isolation costs nothing at the database, only a little configuration.
Isolation does not need more connections, only a partition of the ones you already have. The gain is that a degraded workload can no longer take down an unrelated one.

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 Aurora cluster below capacity, as long as the new per-instance totals still fit during the overlap window when old and new pods coexist.

Bean wiring for two isolated pools Each data source needs its own properties bean, pool bean with configuration properties bound, transaction manager and entity manager, with exactly one of each marked primary. writer — @Primary DataSourceProperties — @ConfigurationProperties HikariDataSource — bind .hikari properties too TransactionManager EntityManagerFactory reporting — qualified DataSourceProperties — @ConfigurationProperties HikariDataSource — separate pool-name for metrics TransactionManager EntityManagerFactory Eight beans where auto-configuration previously supplied four — and every omission fails silently Distinct pool-name values are what keep the two pools separable in Actuator metrics and in pg_stat_activity
Two data sources require the full bean set twice over. The pool-name on each is what makes them distinguishable afterwards in metrics and on the database.

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.

Splitting one allowance between two pools The service's total connection allowance is divided between the writer and reporting pools in proportion to measured demand, so isolation costs configuration rather than additional connections. Service allowance: 20 connections per process writer — 14 (measured p95 concurrency 11) reporting — 6 why not simply add 6 more? because the allowance is a share of a fixed database budget — six more here is six fewer somewhere else what the split buys reporting can consume its entire share without ever affecting the writer path Size each pool from its own measured concurrency, not by halving — the two workloads rarely have similar demand. Leave a little headroom on the writer; the reporting pool can queue without user impact.
Isolation is a partition of an existing allowance, not an increase. Each side is sized from its own measured concurrency rather than by splitting the total evenly.

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?