# I Built a Spring Boot 4 App to Put PgBouncer to the Test

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](https://blog.hbrown.dev/pgbouncer-spring-boot-4-setup-benchmark) 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):

```plaintext
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:

```kotlin
// 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:

```kotlin
// 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:

```kotlin
// 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:

```kotlin
// 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:

```kotlin
// 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):

```kotlin
// 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:

```json
{
  "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:

```plaintext
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):

```kotlin
// 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.toml` version catalog.
    
*   **Modular starters** throughout — `spring-boot-starter-webmvc` (not the deprecated `spring-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 `-test` starter (`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 up` isn'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.

```sql
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

```bash
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.

```bash
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`:

```bash
./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 shared `TestContainersConfiguration`.
    
*   **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.
    

```bash
./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:

```plaintext
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](https://blog.hbrown.dev/pgbouncer-spring-boot-4-setup-benchmark) 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](https://github.com/hgbrown/spring-demo-pgbouncer)
