I Built a Spring Boot 4 App to Put PgBouncer to the Test
Part 1: Build a production-style Kotlin testbed with hexagonal architecture, PostgreSQL, Testcontainers, and a repeatable concurrency benchmark

This post describes a small books-and-authors catalogue, but its real job is to be a testbed: it demonstrates what PgBouncer, a lightweight connection pool manager, actually buys you in front of PostgreSQL, and it does so with a Spring Boot 4 / Kotlin codebase built the way I'd build a production service — hexagonal architecture, modular starters, structured logging, and a full test pyramid including a concurrency benchmark.
This post walks through the project step by step: the domain, the architecture, the database, the PgBouncer setup, and finally the benchmark numbers. If you only care about PgBouncer itself, the companion post: PgBouncer with Spring Boot 4: Setup, Gotchas, and the Proof is a narrower, standalone walkthrough of just that piece.
The domain
Books, authors, and tags, with the usual relationships:
A Book has a title, ISBN, and subject, belongs to exactly one Author, and can carry any number of Tags.
An Author has a name and can write many books.
A Tag has a value and can be attached to many books.
A single search endpoint looks across all three: title for books, name for authors, value for tags — case-insensitive, partial match.
Architecture: hexagonal, feature-packaged
The core rule this codebase follows: the domain and business logic never import Spring, JPA, or Jakarta Validation. Everything framework-specific lives behind a port interface, implemented by an adapter. Packages are named by feature (book, author,tag, search), not by technical layer — there's no controllers or services package sitting at the top level.
Each feature module looks like this (using book as the example):
book/
├── domain/Book.kt # plain Kotlin data class, no annotations
├── port/
│ ├── input/BookUseCase.kt # what the outside world can ask the core to do
│ └── output/BookRepository.kt # what the core needs from persistence
├── service/BookService.kt # implements BookUseCase, depends on BookRepository
└── adapter/
├── web/ # RestController + request/response DTOs + Jakarta Validation
└── persistence/ # JPA entity + Spring Data repository + mapper
port/input and port/output rather than the more common port/in/port/out — in is a Kotlin keyword, and backtick-escaping a package name everywhere it's used isn't worth the readability cost.
The domain model itself is unapologetically plain:
// book/domain/Book.kt
data class Book(
val id: Long? = null,
val title: String,
val isbn: String,
val subject: String,
val author: Author,
val tags: Set<Tag> = emptySet(),
)
And the input port is just an interface — the service class is the only thing that knows it's backed by Spring:
// book/port/input/BookUseCase.kt
interface BookUseCase {
fun createBook(title: String, isbn: String, subject: String, authorId: Long, tagIds: Set<Long>): Book
fun getBook(id: Long): Book
fun getAllBooks(): List<Book>
fun updateBook(id: Long, title: String, isbn: String, subject: String, authorId: Long, tagIds: Set<Long>): Book
fun deleteBook(id: Long)
fun searchBooks(query: String): List<Book>
}
Where does validation live? Jakarta Validation (@NotBlank, @NotNull) is a framework dependency, so it never touches the domain/port/service packages. It's applied to the web adapter's request DTOs instead:
// book/adapter/web/BookRequest.kt
data class BookRequest(
@field:NotBlank val title: String,
@field:NotBlank val isbn: String,
@field:NotBlank val subject: String,
@field:NotNull val authorId: Long?,
val tagIds: Set<Long> = emptySet(),
)
@Valid @RequestBody on the controller method triggers validation before the request ever reaches BookUseCase. A GlobalExceptionHandler (@RestControllerAdvice) turns MethodArgumentNotValidException into a 400 with the field errors, and a small EntityNotFoundException in common/exception becomes a 404 the same way.
Entity graphs for the Book ↔ Author/Tag relationships
Book has a lazy @ManyToOne to Author and a lazy @ManyToMany to Tag (through a book_tags join table). Lazy is the right default, but list/detail endpoints need both loaded in one query — that's what @NamedEntityGraph is for:
// book/adapter/persistence/BookEntity.kt
@Entity
@Table(name = "books")
@NamedEntityGraph(
name = "Book.withAuthorAndTags",
attributeNodes = [NamedAttributeNode("author"), NamedAttributeNode("tags")],
)
class BookEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null,
var title: String = "",
var isbn: String = "",
var subject: String = "",
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "author_id", nullable = false) var author: AuthorEntity? = null,
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "book_tags", joinColumns = [JoinColumn(name = "book_id")], inverseJoinColumns = [JoinColumn(name = "tag_id")])
var tags: MutableSet<TagEntity> = mutableSetOf(),
)
The Spring Data repository references the graph by name on the finder methods that need it, so a findAll()-style call doesn't pay for eager loading it doesn't need:
// book/adapter/persistence/SpringDataBookRepository.kt
interface SpringDataBookRepository : JpaRepository<BookEntity, Long> {
@EntityGraph(value = "Book.withAuthorAndTags")
@Query("select b from BookEntity b where b.id = :id")
fun findWithAuthorAndTagsById(id: Long): BookEntity?
@EntityGraph(value = "Book.withAuthorAndTags")
@Query("select b from BookEntity b")
fun findAllWithAuthorAndTags(): List<BookEntity>
@EntityGraph(value = "Book.withAuthorAndTags")
fun findByTitleContainingIgnoreCase(query: String): List<BookEntity>
}
With spring.jpa.open-in-view=false (Open Session In View is a footgun, not a convenience), every query that gets mapped back to a domain Book has to fetch what it needs up front — the entity graph is what makes that possible without N+1 queries.
Cross-aggregate search
The search feature doesn't belong to book, author, or tag — it reads from all three. It gets its own module that depends on the other three modules' output ports (not their internals):
// search/service/SearchService.kt
@Service
class SearchService(
private val bookRepository: BookRepository,
private val authorRepository: AuthorRepository,
private val tagRepository: TagRepository,
) : SearchUseCase {
override fun search(query: String): List<SearchResult> {
val books = bookRepository.searchByTitleContainingIgnoreCase(query)
.map { SearchResult.BookResult(requireNotNull(it.id), it.title) }
val authors = authorRepository.searchByNameContainingIgnoreCase(query)
.map { SearchResult.AuthorResult(requireNotNull(it.id), it.name) }
val tags = tagRepository.searchByValueContainingIgnoreCase(query)
.map { SearchResult.TagResult(requireNotNull(it.id), it.value) }
return books + authors + tags
}
}
GET /api/search?query=dune returns the matches grouped by type:
{
"books": [
{
"id": 1,
"title": "Dune"
},
{
"id": 2,
"title": "Children of Dune"
}
],
"authors": [],
"tags": []
}
Logging
Every request/response boundary crossing logs at INFO, in a fixed static text, var1=[value1] var2=[value2] format so log lines stay greppable:
HTTP request received to create book, title=[Dune] authorId=[1]
HTTP response sent for create book, bookId=[1]
Errors are written twice, once to the class logger (no exception, safe to alert on) and once to a dedicated error.logger (with the exception, for full stack traces):
// common/adapter/web/GlobalExceptionHandler.kt
@ExceptionHandler(Exception::class)
fun handleUnexpected(ex: Exception): ResponseEntity<ErrorResponse> {
val message = "Unexpected error handling request, exceptionType=[${ex.javaClass.simpleName}]"
log.error(message)
errorLog.error(message, ex)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorResponse(message = "Internal server error"))
}
common/logging/Logging.kt provides the small helpers every class uses: logger() for a class-scoped Logger via companion object { val log = logger() }, and inline debug { "expensive to build" } / info { } extensions so the message lambda only runs when the level is actually enabled.
Tech stack
Spring Boot 4.1.0 on Kotlin 2.4.10 / Java 21, built with the Gradle Kotlin DSL and a
gradle/libs.versions.tomlversion catalog.Modular starters throughout —
spring-boot-starter-webmvc(not the deprecatedspring-boot-starter-web),spring-boot-starter-data-jpa,spring-boot-starter-validation,spring-boot-starter-actuator,spring-boot-starter-opentelemetry, each paired with its own-teststarter (spring-boot-starter-webmvc-test, etc.) rather than one do-everything test starter.Testcontainers for persistence tests (real Postgres, not H2), spring-boot-docker-compose for local dev so
docker compose upisn't something you have to remember to run separately.Actuator (
/actuator/health,/actuator/info,/actuator/metrics) and OpenTelemetry (spring-boot-starter-opentelemetry) for traces and metrics.
Database
schema.sql is the single source of truth for the schema — it drops and recreates authors, tags, books, and the book_tags join table on every startup (spring.sql.init.mode=always), and Hibernate is set to ddl-auto=validate rather than letting it generate DDL. data.sql seeds five authors, six tags, and eight books so there's something to query immediately.
CREATE TABLE books
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title VARCHAR(255) NOT NULL,
isbn VARCHAR(32) NOT NULL,
subject VARCHAR(255) NOT NULL,
author_id BIGINT NOT NULL REFERENCES authors (id)
);
Running it
docker compose up -d
./gradlew bootRun
Spring Boot's Docker Compose support will also start compose.yml automatically if you skip the first step and just run ./gradlew bootRun — it detects the running services either way.
curl localhost:8080/api/authors
curl localhost:8080/api/books
curl "localhost:8080/api/search?query=dune"
curl -X POST localhost:8080/api/authors -H "Content-Type: application/json" -d '{"name":"Liu Cixin"}'
To run against PgBouncer instead of Postgres directly, activate the pgbouncer profile, which points the datasource at port 6432 instead of 5432:
./gradlew bootRun --args='--spring.profiles.active=pgbouncer'
Testing
Every layer has its own kind of test:
Service tests use handwritten fake ports (no mocking framework needed — the ports are small interfaces) to test business logic in isolation.
Persistence tests (
@DataJpaTest+@AutoConfigureTestDatabase(replace = NONE)) run against a real Postgres via Testcontainers, imported through a sharedTestContainersConfiguration.Web slice tests (
@WebMvcTest) mock the use case port and assert on HTTP status, validation errors, and JSON shape.A concurrency benchmark (below) spins up Postgres and PgBouncer in Testcontainers and fires the same load at both.
./gradlew test
The PgBouncer benchmark
PgBouncerConcurrencyBenchmarkTest starts a Postgres container and a PgBouncer container (in transaction pooling mode) on a shared Testcontainers network, opens a Hikari pool against each, and fires 80 concurrent workers × 25 queries at both — then logs total time and p50/p95/max latency for each:
PgBouncer concurrency benchmark completed, concurrency=[80] queriesPerWorker=[25]
directTotalMs=[127] directP50Ms=[0] directP95Ms=[2] directMaxMs=[114]
pgbouncerTotalMs=[110] pgbouncerP50Ms=[0] pgbouncerP95Ms=[3] pgbouncerMaxMs=[103]
At this concurrency (80 clients, comfortably under Postgres's default max_connections=100), the two are neck-and-neck — PgBouncer adds a fraction of a millisecond of hop overhead per query, which is exactly what you'd hope for: it isn't free, but it's close enough to free not to matter. That's not the interesting part of the story, though — see PgBouncer with Spring Boot 4: Setup, Gotchas, and the Proof for why PgBouncer earns its keep at higher concurrency, where direct connections start hitting Postgres's connection ceiling and PgBouncer just keeps going.
Source code for this application is available in my Github repo: spring-demo-pgbouncer





