# 1. Setting up exactly-once on Spring Boot 4
> Verified against Spring Boot 4.1.0, Spring Kafka 4.1.0, kafka-clients 4.2.1, JDK 25.0.3.
## 1.1 The dependency trap that will cost you an hour
On Spring Boot 3 this was enough:
```xml
org.springframework.kafka
spring-kafka
```
…because `KafkaAutoConfiguration` lived in the monolithic `spring-boot-autoconfigure` jar that every
Boot application already had on the classpath.
**Boot 4 split auto-configuration into per-technology modules.** Kafka's now lives in
`org.springframework.boot:spring-boot-kafka`. Depend on `spring-kafka` alone and you get the
library but *no* auto-configuration: no `ProducerFactory`, no `KafkaTemplate`, no
`KafkaTransactionManager`.
The failure looks like this, and it looks like a generics problem, which it is not:
```
Error creating bean with name 'orderProcessor': Unsatisfied dependency expressed through
constructor parameter 0: No qualifying bean of type
'org.springframework.kafka.core.KafkaTemplate' available
```
Use the starter:
```xml
org.springframework.boot
spring-boot-starter-kafka
```
which pulls in both `spring-kafka` and `spring-boot-kafka`.
## 1.2 The configuration
The whole exactly-once switch is one property:
```yaml
spring:
kafka:
producer:
transaction-id-prefix: order-processor-tx-
```
Spring Boot sees it, auto-configures a `KafkaTransactionManager`, and attaches it to every listener
container. From that point the container begins a Kafka transaction before invoking your listener,
writes the consumer offsets *into* that transaction, and commits — or aborts everything if your
listener throws.
Two more properties matter and neither is on by default:
```yaml
spring:
kafka:
consumer:
isolation-level: read_committed # default is read_uncommitted!
enable-auto-commit: false
```
`isolation-level` is the one people forget. Producing transactionally while consuming
`read_uncommitted` gives you all of the cost of transactions and none of the benefit — your
consumers will happily read records from transactions that were aborted. Proven in
[`TransactionMarkerOffsetTest`](../src/test/java/com/ankurm/kafka/eos/_02_transactions/TransactionMarkerOffsetTest.java).
## 1.3 Config worth stating even though it is already the default
```yaml
acks: all
properties:
enable.idempotence: true
max.in.flight.requests.per.connection: 5
```
All three are defaults on Kafka 3.0+. Stating them explicitly is still worth it, because
`enable.idempotence: true` converts a *silent* downgrade into a *startup failure* if anyone later
sets `acks=1` for latency. See [02-idempotence.md](02-idempotence.md) — this is the single most
common way a system that believes it is exactly-once is not.
## 1.4 Testing without Docker
Kafka 4.x removed ZooKeeper entirely, so `spring-kafka-test` now only ships
`EmbeddedKafkaKraftBroker`. It runs a real broker in-JVM:
```java
@SpringBootTest(classes = Application.class)
@EmbeddedKafka(
partitions = 1,
topics = {"orders.incoming", "orders.validated"},
brokerProperties = {
"transaction.state.log.replication.factor=1",
"transaction.state.log.min.isr=1",
"offsets.topic.replication.factor=1"
})
class MyTest { }
```
**Those three `brokerProperties` are mandatory for any transactional test.** The transaction state
log defaults to 3 replicas, which a single-broker embedded cluster cannot provide. Without them
every transactional test fails with a `COORDINATOR_NOT_AVAILABLE` that gives no hint as to why.
Two further limitations of the KRaft embedded broker:
- **You cannot pin ports.** `KafkaClusterTestKit` does not support it, so the `ports` attribute of
`@EmbeddedKafka` is ignored. Read the address from `EmbeddedKafkaBroker.getBrokersAsString()` or
the `spring.embedded.kafka.brokers` system property.
- Broker start-up costs a couple of seconds per distinct configuration. Spring's context caching
will reuse a broker across test classes whose configuration matches exactly, so keeping
`@EmbeddedKafka` annotations identical across classes is a real speed-up.
---
Next: [02-idempotence.md](02-idempotence.md).