// Transmissions — What We Shipped
Chosen for one job: shipping and running a production service on the JVM. Each entry is what problem it exists for, the model to hold in your head, its named parts, and the way it goes wrong — because knowing the failure mode is what separates using a tool from choosing it.
| # | Technology | The problem it exists for |
|---|---|---|
| 1 | Spring Boot | An application platform, so you write features not plumbing |
| 2 | Gradle / Maven | The dependency graph, reproducibly |
| 3 | JPA + Hibernate | Objects ⇄ relational rows |
| 4 | Flyway / Liquibase | Schema as versioned, ordered history |
| 5 | PostgreSQL + HikariCP | The store, and the pool in front of it |
| 6 | JUnit + Mockito + Testcontainers | Confidence at three altitudes |
| 7 | Docker + Kubernetes | Packaging and running the process |
| 8 | Kafka | Decoupling services in time |
| 9 | OpenTelemetry + Micrometer | Knowing what the process is doing |
| 10 | Virtual threads / Reactor | Concurrency at scale, and the choice between two models |
The default application platform, and the reason "Java" and "Spring" are used almost interchangeably in industry. It is Spring Framework (the container of Part II) plus opinionated auto-configuration plus an embedded server, so the deliverable is a single runnable artefact rather than something deployed into an app server.
Parts to know: starters (curated dependency sets), auto-configuration (conditional beans), application.yml and profiles, Actuator (health, metrics, environment), and the module family — Web, Data, Security, Batch — which are separate products sharing one container.
Boot is a set of defaults with escape hatches. Learn the escape hatch for each default you rely on, and you own the framework instead of the other way round.
Nobody can explain why a bean exists. Auto-configuration is excellent until you must debug it; the condition report is the antidote. Also: Spring Security is a filter chain, not a checkbox — it repays real study before you put anything valuable behind it.
Java's build tools do far more than PHP's Composer: compile, run tests, resolve a transitive dependency graph, produce artefacts, and publish. Maven is declarative XML with a fixed lifecycle — predictable, verbose, boring in the good way. Gradle is programmable, faster on big builds thanks to incremental work and a build cache, and rope enough to hang yourself with.
Ideas to know: the transitive graph and how conflicts resolve; scopes (compile vs runtime vs test); a lockfile or BOM to pin versions; and the dependency-tree command, which is the single most useful thing either tool does.
Two libraries want different versions of a third. The build picks one silently and something breaks at runtime with a missing-method error. That error means "you have a version conflict", and reading it correctly saves hours.
Covered mechanically in Part II; here is why it is on the list rather than a hand-rolled alternative. It gives you an identity map, dirty checking, caching and portable queries — and it costs you a layer of indirection between you and SQL that you must be able to see through.
Parts: entities and their identity, the persistence context, fetch strategies, JPQL versus native SQL, projections (returning a purpose-built shape rather than an entity), and the second-level cache.
You are editing an in-memory object graph; the framework decides what SQL that implies, and when. If you cannot predict the queries a request will issue, you do not yet understand the code.
N+1 selects; lazy access after the transaction closed; entities serialised straight to JSON, which drags the whole object graph into the response and couples your API to your schema. The mature move is to know when to drop to plain SQL — for reporting and complex reads, an ORM is usually the wrong tool and admitting that early is a seniority marker.
Schema changes as versioned, ordered, checksummed migrations applied automatically at startup, with a table recording what has run. Non-negotiable the moment more than one environment exists. Flyway is SQL-first and simple; Liquibase is abstracted and database-agnostic.
The schema is an append-only history, not a current state you edit. You never modify an applied migration — you add one. Changing an applied file breaks its checksum, which is the tool correctly refusing to let two environments silently diverge.
A migration takes a lock on a large table and takes the service down with it. At scale, schema changes must be written to be online and backwards compatible — expand, migrate, contract — so old and new code can run simultaneously during a deploy.
Postgres is the sane default store, and the JVM's relationship with it runs through JDBC and a connection pool — Hikari, which Boot uses by default. The pool matters more than people expect: connections are expensive and finite, so the pool is where your real concurrency limit lives.
Pool size is not "bigger is better". Every connection is memory and a backend process on the database; a pool larger than the database can serve just moves the queue somewhere less visible. A small pool with a short timeout fails fast and honestly.
Pool exhaustion — every request blocks waiting for a connection and the app appears hung with an idle CPU. Usually caused by a connection held across a slow external call, or a transaction left open. Learn to read pool metrics; they diagnose this in seconds.
The testing trio. JUnit 5 is the harness; Mockito fakes collaborators; Testcontainers is the one that changed the game — it starts real dependencies in Docker for the duration of a test, so integration tests run against an actual Postgres or Kafka instead of an in-memory imitation that behaves differently.
Mock what you own and cannot cheaply run; run for real what you do not own. Mocking a database teaches you your mock's behaviour. Running a real one in a container teaches you the database's.
Over-mocking. A test that mocks every collaborator asserts that the code calls the methods it calls — it is a mirror, not a test, and it fails whenever you refactor while passing whenever you break behaviour.
Docker packages the JVM, your jar and its configuration into an immutable image; Kubernetes runs those images, restarts them, scales them and routes to them. For Java specifically there are three things to get right and they are all about the JVM being a memory-hungry, container-unaware program by history.
A durable, replayable, partitioned log. Not a queue: consumers track their own position and the data stays put, so a new consumer can read history and a broken one can rewind. That single property is why it anchors event-driven architectures.
Parts: topics and partitions (partition is the unit of parallelism and of ordering), consumer groups, offsets, retention and compaction, and delivery semantics — at-most-once, at-least-once, and the expensive conditions for exactly-once.
Ordering exists only within a partition, and a partition is chosen by key. "Events for one customer stay in order" is therefore a statement about your key choice, not about Kafka.
Used as a request/response transport. If the caller needs an answer now, you have built a database with extra steps and worse latency. Also: at-least-once is the practical default, so your consumers must be idempotent — plan for the same event twice.
Micrometer is the metrics façade — instrument once, export to Prometheus or anything else. OpenTelemetry is the vendor-neutral standard for traces, metrics and logs together, and it has won. The Java agent can instrument a service without touching its source.
The three signals, and what each is for: metrics are cheap aggregates that tell you something is wrong; traces follow one request across services and tell you where; logs carry the detail and tell you why. You need all three, joined by a correlation id.
Cardinality explodes — a metric tagged with a user id creates a series per user and takes down the metrics backend before it takes down anything else. Tag with bounded values only.
The last slot is a choice, not a tool. Java now has two concurrency models and picking one is an architectural commitment.
| Virtual threads (Loom) | Reactive (Reactor / WebFlux) | |
|---|---|---|
| Style | Ordinary blocking code | Declarative stream pipelines |
| Debugging | Normal stack traces | Assembly-time traces, harder |
| Backpressure | Not built in | First class |
| Learning cost | Almost none | Substantial |
| Best at | I/O-bound request handling | Streaming, fan-out, flow control |
Reactive programming was largely a workaround for threads being expensive. Loom makes them cheap. Default to virtual threads and plain blocking code; choose reactive when you genuinely need backpressure or streaming, not for throughput alone.
Virtual threads plus synchronized blocks around I/O can pin the carrier thread and undo the benefit — prefer the modern locks. And a reactive chain with one blocking call inside it is the worst of both worlds: all the complexity, none of the scalability.
Worth knowing they exist and what they are for: Jackson (JSON binding — the serialisation layer you will configure whether you meant to or not), MapStruct (compile-time mapping between entities and DTOs), Lombok (removes boilerplate; divisive, because it edits the compiler's view of your code), Redis (cache and distributed locks), Keycloak (identity, if you would rather not implement OAuth), gRPC (typed service-to-service calls), and GraalVM / Quarkus (native images, when startup and footprint beat peak throughput — see Part I).
Part IV — how to learn this with an agent →
Written for someone who has an agent for the syntax and needs the ideas instead. Nothing here is a code sample on purpose — if you can name the mechanism, you can ask for the code and judge what comes back.