<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Java on Vitalii Sukach</title><link>https://sukachvitally.github.io/tags/java/</link><description>Recent content in Java on Vitalii Sukach</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Sat, 17 Oct 2026 13:01:45 +0000</lastBuildDate><atom:link href="https://sukachvitally.github.io/tags/java/index.xml" rel="self" type="application/rss+xml"/><item><title>RAG in Java with LangChain4j: Teaching a Local LLM About a Company That Doesn’t Exist</title><link>https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/</link><pubDate>Sat, 17 Oct 2026 13:01:45 +0000</pubDate><guid>https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/</guid><description>&lt;p>&lt;em>Chunks, vectors, a bit of geometry, and no magic. Everything runs on your laptop.&lt;/em>&lt;/p>
&lt;p>&lt;img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-01.png">&lt;/p>
&lt;p>Ask an LLM “When does the nightly settlement batch run at Harbourline Payments?” and one of two things happens.&lt;/p>
&lt;p>The polite model says it doesn’t know. The confident model says “Usually around midnight!” with the energy of a student who didn’t read the book but really wants a good mark.&lt;/p>
&lt;p>Both are fair, because Harbourline Payments doesn’t exist. I made it up. It’s a fictional company with a fictional engineering handbook: founders, services, deploy rules, on-call pay, and even a famous incident called “the Double Tap”. No model has seen this text during training.&lt;/p></description><content:encoded><![CDATA[<p><em>Chunks, vectors, a bit of geometry, and no magic. Everything runs on your laptop.</em></p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-01.png"></p>
<p>Ask an LLM “When does the nightly settlement batch run at Harbourline Payments?” and one of two things happens.</p>
<p>The polite model says it doesn’t know. The confident model says “Usually around midnight!” with the energy of a student who didn’t read the book but really wants a good mark.</p>
<p>Both are fair, because Harbourline Payments doesn’t exist. I made it up. It’s a fictional company with a fictional engineering handbook: founders, services, deploy rules, on-call pay, and even a famous incident called “the Double Tap”. No model has seen this text during training.</p>
<p>That’s exactly why it’s a good test. If the model answers “02:30 UK time” correctly, it didn’t remember it. It <strong>found</strong> it. That’s RAG.</p>
<p>In this article we’ll build a small RAG service in Java with Spring Boot, LangChain4j, PostgreSQL with pgvector, and Ollama. No cloud, no API keys, no bill at the end of the month.</p>
<p>The full code is on GitHub: <a href="https://github.com/SukachVitally/rag-demo">SukachVitally/rag-demo</a>.</p>
<h3 id="what-is-rag-in-oneexam">What is RAG, in one exam</h3>
<p>RAG stands for <strong>Retrieval-Augmented Generation</strong>. Long name, simple idea.</p>
<p>Think about two exams:</p>
<ul>
<li><strong>Closed-book exam.</strong> The student answers from memory. This is a normal LLM. It knows a lot, but only what it learned during training. It knows nothing about your company’s wiki, last week’s incident, or your internal rules. If it doesn’t know, it may still try to sound smart.</li>
<li><strong>Open-book exam.</strong> Before answering, the student finds the right pages in the book and reads them. This is RAG.</li>
</ul>
<p>So RAG doesn’t make the model smarter. It gives the model a cheat sheet for each question: “Here are 5 pieces of text that look relevant. Answer using only these.”</p>
<p>Why not just put the whole handbook into the prompt? For one small file you can. But real knowledge bases have thousands of documents. They don’t fit into the context window, and even when they do, you pay for every token on every question. RAG sends only the few pieces that matter.</p>
<h3 id="the-bigidea">The big idea</h3>
<p>The whole thing is two flows.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-02.png"></p>
<p><strong>Ingest (once per document):</strong></p>
<ol>
<li>Take the text and split it into small pieces called <strong>chunks</strong>.</li>
<li>Ask an <strong>embedding model</strong> to turn each chunk into a vector: a long list of numbers.</li>
<li>Save each chunk together with its vector in the database.</li>
</ol>
<p><strong>Ask (every question):</strong></p>
<ol>
<li>Ask the <strong>same</strong> embedding model to turn the question into a vector.</li>
<li>Find the chunks whose vectors are <strong>closest</strong> to the question vector.</li>
<li>Send the question and those chunks to a <strong>chat model</strong> and ask it to answer using only these chunks.</li>
</ol>
<p>That’s it. The rest of this article is details.</p>
<p>Notice that there are <strong>two different models</strong>:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-03.png"></p>
<p>The embedding model is small and fast. The chat model is the big one. Both run locally in Ollama.</p>
<h3 id="wait-what-is-thisvector">Wait, what is this vector?</h3>
<p>This is the most interesting part, so let’s slow down a little.</p>
<p>An embedding model reads a piece of text and returns a fixed-size array of numbers. nomic-embed-text returns <strong>768</strong> numbers for any text, whether it&rsquo;s one word or a full paragraph:</p>
<pre tabindex="0"><code>&#34;settlement batch runs at 02:30&#34;  →  [-0.064, 0.142, 0.009, ... 765 more ...]
</code></pre><p>The model is trained so that texts with <strong>similar meaning</strong> get vectors that point in a <strong>similar direction</strong>. “When does the nightly batch run?” and “settlement-service runs the nightly batch at 02:30” share almost no grammar, but their meaning is close, so their vectors are close too.</p>
<p>Now the fun part. It’s natural to think that each number means something: dimension 17 is “money”, dimension 302 is “time of day”, dimension 555 is “how angry the on-call engineer is”. It doesn’t work like that.</p>
<p><strong>No single dimension has a physical meaning.</strong> Nobody designed these axes. The model learned the layout by itself during training. Meaning is spread across all 768 numbers at once, a bit like a smell is spread across many different molecules. You can’t point at one number and say “this is the time part”. Only the vector as a whole means something, and only when you compare it with other vectors.</p>
<p>Here’s a 2D sketch of what that looks like. The real space has 768 dimensions, and I have only two axes on my screen:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-04.png"></p>
<p>The question vector points almost in the same direction as the “settlement batch runs at 02:30” chunk. The sourdough recipe chunk points somewhere else entirely. So for a computer, “similar meaning” becomes simple geometry: <strong>a small angle between two vectors</strong>.</p>
<p>The usual way to measure this is <strong>cosine similarity</strong>, the cosine of that angle:</p>
<ul>
<li>1: same direction, very similar</li>
<li>0: unrelated</li>
<li>-1: opposite direction</li>
</ul>
<p>LangChain4j turns it into a friendlier score from 0 to 1: (1 + cos θ) / 2. That&rsquo;s the score you&rsquo;ll see in the API response later.</p>
<p>One important rule: vectors from different embedding models are <strong>not compatible</strong>. Each model builds its own space with its own layout. If you change the embedding model, you have to re-embed all your documents. It’s like changing the map projection: the old coordinates don’t match the new map.</p>
<h3 id="the-stack">The stack</h3>
<ul>
<li><strong>Java 25</strong> and <strong>Spring Boot 4.1</strong>: the usual REST app.</li>
<li><strong>LangChain4j 1.22</strong>: the Java library that does the AI work.</li>
<li><strong>Ollama</strong>: runs both models locally.</li>
<li><strong>PostgreSQL 17 + pgvector</strong>: stores chunks and vectors, and searches by similarity.</li>
<li><strong>Testcontainers</strong>: for functional tests with a real database.</li>
</ul>
<p>Run the infrastructure with one command:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/docker-compose.yaml"><em>docker-compose.yaml</em></a></p>
<pre tabindex="0"><code>services:
  db:
    image: pgvector/pgvector:pg17
    environment:
      POSTGRES_DB: rag
      POSTGRES_USER: rag
      POSTGRES_PASSWORD: rag
    ports: [&#34;5432:5432&#34;]
  ollama:
    image: ollama/ollama
    ports: [&#34;11434:11434&#34;]
    volumes:
      - ollama-models:/root/.ollama   # keeps downloaded models between restarts
  ollama-init:
    image: ollama/ollama
    depends_on: [ollama]
    environment:
      OLLAMA_HOST: ollama:11434
    entrypoint: [&#34;sh&#34;, &#34;-c&#34;, &#34;sleep 5 &amp;&amp; ollama pull nomic-embed-text &amp;&amp; ollama pull llama3.1:8b&#34;]
volumes:
  ollama-models:
</code></pre><p>The ollama-init container is a one-off job that downloads the models. The first time it pulls a few GB, so it&rsquo;s a good moment to make some tea.</p>
<h3 id="langchain4j-ai-lego-forjava">LangChain4j: AI LEGO for Java</h3>
<p>LangChain4j is to AI roughly what Spring Data is to databases. It gives you small interfaces with many implementations, so your code doesn’t depend on a specific vendor.</p>
<p>The demo uses four building blocks, plus one helper that glues three of them together:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-05.png"></p>
<ul>
<li><strong>DocumentSplitter</strong> cuts text into chunks.</li>
<li><strong>EmbeddingModel</strong> turns text into a vector. Here it&rsquo;s OllamaEmbeddingModel.</li>
<li><strong><code>EmbeddingStore&lt;TextSegment&gt;</code></strong> saves vectors and finds the nearest ones. Here it&rsquo;s PgVectorEmbeddingStore.</li>
<li><strong>ChatModel</strong> sends a prompt and gets an answer. Here it&rsquo;s OllamaChatModel.</li>
<li><strong>EmbeddingStoreIngestor</strong> runs split → embed → store in one call.</li>
</ul>
<p>The nice part is the bottom of that picture. Want OpenAI instead of Ollama? Change one bean. Want Qdrant instead of Postgres? Change one bean. The services don’t care.</p>
<h3 id="dependencies">Dependencies</h3>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/build.gradle"><em>build.gradle</em></a></p>
<pre tabindex="0"><code>dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-jdbc&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-webmvc&#39;
    implementation &#39;dev.langchain4j:langchain4j:1.22.0&#39;
    implementation &#39;dev.langchain4j:langchain4j-ollama:1.22.0&#39;
    implementation &#39;dev.langchain4j:langchain4j-pgvector:1.22.0-beta32&#39;
    runtimeOnly &#39;org.postgresql:postgresql&#39;
}
</code></pre><p>Three LangChain4j modules: the core, the Ollama integration and the pgvector store. That’s all.</p>
<h3 id="wiring-thebeans">Wiring the beans</h3>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/src/main/java/org/example/ragdemo/config/LangChain4jConfig.java"><em>LangChain4jConfig.java</em></a></p>
<pre tabindex="0"><code>@Configuration
public class LangChain4jConfig {
    public static final String CHUNK_TABLE = &#34;chunk_embedding&#34;;
    @Bean
    EmbeddingModel embeddingModel(OllamaClientProperty property) {
        return OllamaEmbeddingModel.builder()
                .baseUrl(property.baseUrl())
                .modelName(property.embedModel())
                .build();
    }
    @Bean
    ChatModel chatModel(OllamaClientProperty property) {
        return OllamaChatModel.builder()
                .baseUrl(property.baseUrl())
                .modelName(property.chatModel())
                .build();
    }
    @Bean
    DocumentSplitter documentSplitter(ChunkProperty property) {
        return DocumentSplitters.recursive(property.size(), property.overlap());
    }
    @Bean
    EmbeddingStore&lt;TextSegment&gt; embeddingStore(DataSource dataSource) {
        return PgVectorEmbeddingStore.datasourceBuilder()
                .datasource(dataSource)
                .table(CHUNK_TABLE)
                .createTable(false)
                .skipCreateVectorExtension(true)
                .metadataStorageConfig(DefaultMetadataStorageConfig.builder()
                        .storageMode(MetadataStorageMode.COMBINED_JSONB)
                        .columnDefinitions(List.of(&#34;metadata JSONB NULL&#34;))
                        .build())
                .build();
    }
}
</code></pre><p>The embedding store reuses Spring’s DataSource, so there&rsquo;s one connection pool and one config. I also told it <strong>not</strong> to create the table (createTable(false)) because I prefer to own the schema myself:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/src/main/resources/schema.sql"><em>schema.sql</em></a></p>
<pre tabindex="0"><code>CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS chunk_embedding (
    embedding_id UUID PRIMARY KEY,
    embedding    vector(768) NOT NULL,
    text         TEXT NULL,
    metadata     JSONB NULL
);
CREATE INDEX IF NOT EXISTS chunk_embedding_hnsw
    ON chunk_embedding USING hnsw (embedding vector_cosine_ops);
</code></pre><p>Two things to notice:</p>
<ul>
<li>vector(768) is the pgvector column type. 768 must match the embedding model&rsquo;s output. If you switch to a model with 1024 dimensions, this column has to change too.</li>
<li>The <strong>HNSW index</strong> with vector_cosine_ops makes nearest-neighbour search fast. Without it, Postgres compares the question with every row. That&rsquo;s fine for 9 chunks, but not for 9 million. HNSW is an approximate search: it trades a tiny bit of accuracy for a lot of speed.</li>
</ul>
<p>Everything is configurable in application.yaml:</p>
<pre tabindex="0"><code>app:
  olama-client:
    base-url: http://localhost:11434
    embed-model: nomic-embed-text
    chat-model: llama3.1:8b
  chunk:
    size: 1000
    overlap: 150
</code></pre><h3 id="part-1-ingest-or-feeding-thedatabase">Part 1: Ingest, or feeding the database</h3>
<p>Here’s what happens to a document on the way in:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/rag-in-java-with-langchain4j-teaching-a-local-llm-about-a-company-that-doesnt-exist/image-06.png"></p>
<h3 id="why-chunks">Why chunks?</h3>
<p>Why not embed the whole document as one vector? Because one vector for a whole handbook would be an average of everything: founders, deploy rules, on-call pay, the sourdough recipe someone accidentally uploaded. It would be “kind of about everything”, and that means it’s close to nothing.</p>
<p>Small chunks keep the meaning sharp. One chunk is about the settlement batch, another is about deploy windows. Now the search can find the right one.</p>
<p>The demo uses DocumentSplitters.recursive(1000, 150):</p>
<ul>
<li><strong>1000</strong>: the maximum chunk size in characters.</li>
<li><strong>150</strong>: overlap. Each chunk repeats about 150 characters from the end of the previous one. So if an important sentence lands on a border, it’s still complete in at least one chunk.</li>
<li><strong>Recursive</strong> means it tries to cut nicely. First by paragraphs. If a paragraph is too big, then by lines, then by sentences, then by words. It cuts the text like a polite butcher, not with a chainsaw.</li>
</ul>
<h3 id="the-ingestservice">The ingest service</h3>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/src/main/java/org/example/ragdemo/service/IngestService.java"><em>IngestService.java</em></a></p>
<pre tabindex="0"><code>@Service
@RequiredArgsConstructor
public class IngestService {
    public static final String SOURCE = &#34;source&#34;;
    private final DocumentSplitter splitter;
    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore&lt;TextSegment&gt; store;
    
    public int ingest(String source, String text) {
        if (text.isBlank()) return 0;
        var chunks = new AtomicInteger();
        EmbeddingStoreIngestor.builder()
                .documentSplitter(splitter)
                .textSegmentTransformer(segment -&gt; { chunks.incrementAndGet(); return segment; })
                .embeddingModel(embeddingModel)
                .embeddingStore(store)
                .build()
                .ingest(Document.from(text, Metadata.from(SOURCE, source)));
        return chunks.get();
    }
}
</code></pre><p>EmbeddingStoreIngestor does all three steps for us: split, embed, store. The textSegmentTransformer is a hook that runs for every chunk. Here I only use it to count chunks for the API response, but it&rsquo;s also the right place to clean text or add extra metadata.</p>
<p>The source metadata (the file name) travels with every chunk into the metadata JSONB column. Later it lets the answer say <em>where</em> the information came from.</p>
<p>The controller is a plain multipart upload:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/src/main/java/org/example/ragdemo/controller/RagController.java"><em>RagController.java</em></a></p>
<pre tabindex="0"><code>@PostMapping(value = &#34;/ingest&#34;, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Map&lt;String, Object&gt; upload(@RequestParam MultipartFile file) throws IOException {
    String text = new String(file.getBytes(), StandardCharsets.UTF_8);
    return Map.of(&#34;chunks&#34;, ingest.ingest(file.getOriginalFilename(), text));
}
</code></pre><p>Let’s feed it the Harbourline handbook:</p>
<pre tabindex="0"><code>curl -F file=@docs/notes.md http://localhost:8080/api/ingest
</code></pre><pre tabindex="0"><code>{ &#34;chunks&#34;: 9 }
</code></pre><p>Nine chunks, nine vectors, nine rows. The database now knows about a company that doesn’t exist.</p>
<h3 id="part-2-ask-or-the-open-book-exam">Part 2: Ask, or the open-book exam</h3>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/rag-demo/blob/main/src/main/java/org/example/ragdemo/service/RagService.java"><em>RagService.java</em></a></p>
<pre tabindex="0"><code>@Service
@RequiredArgsConstructor
public class RagService {
    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore&lt;TextSegment&gt; store;
    private final ChatModel chatModel;
    private static final String SYSTEM = &#34;&#34;&#34;
        Answer using ONLY the provided context. If the answer is not in the
        context, say you don&#39;t know. Cite sources as [1], [2]...&#34;&#34;&#34;;
    
public record Answer(String answer, List&lt;Hit&gt; sources) {}
    public Answer ask(String question) {
        var q = embeddingModel.embed(question).content();
        var hits = store.search(EmbeddingSearchRequest.builder().queryEmbedding(q).maxResults(5).build())
                .matches().stream()
                .map(m -&gt; new Hit(m.embedded().metadata().getString(IngestService.SOURCE), m.embedded().text(), m.score()))
                .toList();
        var ctx = new StringBuilder();
        for (int i = 0; i &lt; hits.size(); i++)
            ctx.append(&#34;[&#34;).append(i + 1).append(&#34;] (&#34;).append(hits.get(i).source()).append(&#34;)\n&#34;)
                    .append(hits.get(i).content()).append(&#34;\n\n&#34;);
        String prompt = &#34;Context:\n&#34; + ctx + &#34;\nQuestion: &#34; + question;
        return new Answer(chatModel.chat(SystemMessage.from(SYSTEM), UserMessage.from(prompt)).aiMessage().text(), hits);
    }
}
</code></pre><p>This is the heart of the project, and it follows the plan exactly:</p>
<ol>
<li><strong>Embed the question</strong> with the same model we used for the chunks. Same model means same space, so the distances make sense.</li>
<li><strong>Search</strong> the store for the 5 nearest vectors. Under the hood, pgvector uses the HNSW index and cosine distance.</li>
<li><strong>Build the context</strong>: number each chunk and add its source.</li>
<li><strong>Ask the chat model</strong> with a strict system message: use only the context, say “I don’t know” if the answer isn’t there, and cite sources.</li>
</ol>
<p>This is roughly what llama actually receives:</p>
<pre tabindex="0"><code>SYSTEM:
Answer using ONLY the provided context. If the answer is not in the
context, say you don&#39;t know. Cite sources as [1], [2]...
</code></pre><pre tabindex="0"><code>USER:
Context:
[1] (notes.md)
... settlement-service — runs the nightly settlement batch at 02:30 UK time ...
</code></pre><pre tabindex="0"><code>[2] (notes.md)
...
</code></pre><pre tabindex="0"><code>Question: When does the nightly settlement batch run?
</code></pre><p>The chat model never sees the database, the vectors or the other chunks. It sees a small, focused cheat sheet and a question. Its job is now easy: read and summarise.</p>
<p>Let’s try it:</p>
<pre tabindex="0"><code>curl -H &#39;Content-Type: application/json&#39; \
     -d &#39;{&#34;question&#34;: &#34;When does the nightly settlement batch run?&#34;}&#39; \
     http://localhost:8080/api/ask
</code></pre><pre tabindex="0"><code>{
  &#34;answer&#34;: &#34;According to the context in [3] (notes.md), the nightly settlement batch runs at 02:30 UK time.&#34;,
  &#34;sources&#34;: [
    { &#34;source&#34;: &#34;notes.md&#34;, &#34;content&#34;: &#34;...&#34;, &#34;score&#34;: 0.79 }
  ]
}
</code></pre><p>02:30 UK time. Correct, and with a source. Open-book exam passed. 🎓</p>
<p>The sources list matters as much as the answer. You can show it to users, so they can check where the answer came from instead of trusting the model blindly. Trust, but verify, especially with an 8B model.</p>
<h3 id="things-to-keep-inmind">Things to keep in mind</h3>
<p>The demo is small on purpose. Before you take it to production, think about these:</p>
<ul>
<li><strong>Chunk size is a trade-off.</strong> Too small, and a chunk loses context (“it runs at 02:30”, but what is “it”?). Too big, and the meaning gets blurry again. 1000/150 is a reasonable start, not a law.</li>
<li><strong>The top 5 are always returned,</strong> even when nothing is relevant. Ask about pizza, and you’ll still get the 5 “least irrelevant” chunks. The strict system prompt helps the model say “I don’t know”. In a real system I’d also add a minimum score (EmbeddingSearchRequest supports minScore).</li>
<li><strong>Changing the embedding model means re-ingesting everything.</strong> Different model, different space, different vector(N) column.</li>
<li><strong>Re-uploading the same file creates duplicates.</strong> The demo only appends. A real system needs to update or delete chunks by source.</li>
<li><strong>Garbage in, garbage out.</strong> RAG is only as good as the documents you give it. If the wiki is wrong, the answer will be wrong, but now with a citation.</li>
</ul>
<h3 id="summary">Summary</h3>
<p>RAG sounds like a fancy AI term, but in code it’s a short pipeline:</p>
<ul>
<li><strong>Ingest:</strong> split text into chunks → embed each chunk into a vector → store the text and vector in pgvector.</li>
<li><strong>Ask:</strong> embed the question → find the closest chunk vectors → give the question and those chunks to the chat model → answer with sources.</li>
</ul>
<p>The vectors are the only “magic” part. They are 768 numbers with no human meaning in any single dimension, but their direction captures meaning. Similar text means a small angle, and a small angle is something a database can search.</p>
<p>LangChain4j turns each step into one small interface, so the whole service is a handful of Spring beans. You can swap Ollama for OpenAI, or pgvector for Qdrant, without touching the business code.</p>
<p>And now a local 8B model knows when Harbourline’s settlement batch runs. Harbourline still doesn’t exist, but at least its batch is on time. ⏰</p>
<p>The full code is on GitHub: <a href="https://github.com/SukachVitally/rag-demo">SukachVitally/rag-demo</a>. Clone it, run docker compose up -d, upload your own notes and ask them questions.</p>
]]></content:encoded></item><item><title>Building an MCP Server in Java with Spring AI: Let Claude Code Use Your Database Without Seeing a Password</title><link>https://sukachvitally.github.io/posts/building-an-mcp-server-in-java-with-spring-ai-let-claude-code-use-your-database-without-seeing-a/</link><pubDate>Fri, 25 Sep 2026 14:46:28 +0000</pubDate><guid>https://sukachvitally.github.io/posts/building-an-mcp-server-in-java-with-spring-ai-let-claude-code-use-your-database-without-seeing-a/</guid><description>&lt;p>&lt;em>Four annotated methods, one security config, and no JSON-RPC code of your own.&lt;/em>&lt;/p>
&lt;p>&lt;img loading="lazy" src="https://sukachvitally.github.io/posts/building-an-mcp-server-in-java-with-spring-ai-let-claude-code-use-your-database-without-seeing-a/image-01.png">&lt;/p>
&lt;p>In my &lt;a href="https://medium.com/@sukach.v.v/mcp-the-protocol-that-finally-taught-ai-to-stop-being-useless-759de3745b79">previous article&lt;/a> I built an MCP server from scratch in Python. I wrote the JSON-RPC 2.0 message handling, declared tool schemas by hand and wired up the STDIO transport myself. It’s a good way to learn how the protocol works on the wire.&lt;/p>
&lt;p>Python isn’t the only option, though. Most of the systems I work on are Java and Spring, and that’s where the data lives. So this time I built the same kind of server in Java.&lt;/p></description><content:encoded><![CDATA[<p><em>Four annotated methods, one security config, and no JSON-RPC code of your own.</em></p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/building-an-mcp-server-in-java-with-spring-ai-let-claude-code-use-your-database-without-seeing-a/image-01.png"></p>
<p>In my <a href="https://medium.com/@sukach.v.v/mcp-the-protocol-that-finally-taught-ai-to-stop-being-useless-759de3745b79">previous article</a> I built an MCP server from scratch in Python. I wrote the JSON-RPC 2.0 message handling, declared tool schemas by hand and wired up the STDIO transport myself. It’s a good way to learn how the protocol works on the wire.</p>
<p>Python isn’t the only option, though. Most of the systems I work on are Java and Spring, and that’s where the data lives. So this time I built the same kind of server in Java.</p>
<p>It turns out you don’t need to implement JSON-RPC yourself. Spring AI has a Boot starter for MCP servers. It handles the protocol, the transport, schema generation and tool registration. You write ordinary Spring beans and add a few annotations.</p>
<p>The full code is on GitHub: <a href="https://github.com/SukachVitally/mcp-demo">SukachVitally/mcp-demo</a>.</p>
<h3 id="why-bother-the-server-is-theboundary">Why bother: the server is the boundary</h3>
<p>The business logic here is deliberately boring. It’s a task list: you create tasks, move them through statuses, and finish or cancel them. Swap in your own domain; the MCP part stays the same.</p>
<p>The point is how Claude Code reaches the data:</p>
<pre tabindex="0"><code>Claude Code ──HTTP + API key──▶ MCP server (Spring) ──JDBC──▶ PostgreSQL
</code></pre><ul>
<li><strong>Claude never sees database credentials.</strong> The server holds the connection. Claude holds only an API key that works for the MCP endpoint and nothing else.</li>
<li><strong>Claude can only do what you expose.</strong> There’s no SQL console. If you don’t write a delete_task tool, tasks can&rsquo;t be deleted, however the prompt is phrased.</li>
<li><strong>Your validation and transactions still apply.</strong> Every call goes through the same service layer as the rest of your application.</li>
</ul>
<p>Giving an agent a database URL and a password is quick, but you can’t take it back. An MCP server gives the agent a small, typed and auditable API instead.</p>
<h3 id="what-werebuilding">What we’re building</h3>
<p>A PostgreSQL table of tasks. Each task has a name and one of four statuses: NEW, IN_PROGRESS, DONE or CANCELLED. The server exposes four tools:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/building-an-mcp-server-in-java-with-spring-ai-let-claude-code-use-your-database-without-seeing-a/image-02.png"></p>
<p>Stack: Java 25, Spring Boot 4, Spring AI 2.0 MCP server, PostgreSQL 18 with Liquibase, and Spring Security.</p>
<h3 id="step-1-dependencies">Step 1: Dependencies</h3>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/build.gradle"><em>build.gradle</em></a></p>
<pre tabindex="0"><code>dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-data-jpa&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-liquibase&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-webmvc&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-security&#39;
    implementation &#39;org.springframework.boot:spring-boot-starter-validation&#39;
    implementation platform(&#39;org.springframework.ai:spring-ai-bom:2.0.1&#39;)
    implementation &#39;org.springframework.ai:spring-ai-starter-mcp-server-webmvc&#39;
    implementation &#39;org.springaicommunity:mcp-server-security-spring-boot:0.1.14&#39;
    runtimeOnly &#39;org.postgresql:postgresql&#39;
}
</code></pre><p>Two lines do the MCP work:</p>
<ul>
<li>spring-ai-starter-mcp-server-webmvc is the MCP server over HTTP on Spring MVC. There&rsquo;s a -webflux variant for reactive apps, and a plain spring-ai-starter-mcp-server for stdio.</li>
<li>mcp-server-security-spring-boot from the <a href="https://github.com/spring-ai-community/mcp-security">spring-ai-community/mcp-security</a> project adds API-key and OAuth2 support on top of Spring Security. We&rsquo;ll come back to it.</li>
</ul>
<h3 id="step-2-configure-theserver">Step 2: Configure the server</h3>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/src/main/resources/application.yaml"><em>application.yaml</em></a></p>
<pre tabindex="0"><code>spring:
  ai:
    mcp:
      server:
        protocol: STATELESS
        type: sync
        name: &#34;Task MCP demo&#34;
        instructions: &gt;-
          Manages a simple task list. Create tasks with add_task, look them up with get_task or
          get_task_list, and move them through the statuses NEW, IN_PROGRESS, DONE and CANCELLED
          with update_task_status.
        streamable-http:
          mcp-endpoint: /mcp
</code></pre><p>What these settings mean:</p>
<ul>
<li><strong>protocol: STATELESS</strong>: plain HTTP request and response, with no session kept between calls. Our tools never need to push anything back to the client, so we don&rsquo;t need sessions, and the server scales horizontally like any other REST service. Use STREAMABLE if you need server-to-client messages. The older SSE option is deprecated since Spring AI 2.0.</li>
<li><strong>type: sync</strong>: tools are ordinary blocking methods. With async, they return Reactor Mono/Flux types instead.</li>
<li><strong>instructions</strong>: sent to the client when it connects. It&rsquo;s the server&rsquo;s short explanation to the model of how the tools fit together. Treat it as part of the prompt.</li>
<li><strong>mcp-endpoint: /mcp</strong>: the URL that Claude Code will call.</li>
</ul>
<h3 id="step-3-business-logic-keep-itboring">Step 3: Business logic (keep it boring)</h3>
<p>Nothing here knows about MCP. It’s a normal JPA entity, repository and service:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/src/main/java/org/example/mcpdemo/service/TaskService.java"><em>TaskService.java</em></a></p>
<pre tabindex="0"><code>@Service
@Validated
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TaskService {
    public static final int NAME_MAX_LEN
GTH = 255;
    private final TaskRepository repository;
    private final TaskMapper mapper;
    @Transactional
    public TaskDto create(@NotBlank @Size(max = NAME_MAX_LENGTH) String name) {
        var entity = new TaskEntity();
        entity.setName(name);
        entity.setStatus(TaskStatus.NEW);
        return mapper.toDto(repository.save(entity));
    }
    @Transactional
    public TaskDto updateStatus(Long id, @NotNull TaskStatus status) {
        var entity = findEntity(id);
        entity.setStatus(status);
        return mapper.toDto(entity);
    }
    // get(id), getList(), findEntity(id) throws TaskNotFoundException ...
}
</code></pre><p>The service returns a small record, not the entity:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/src/main/java/org/example/mcpdemo/dto/TaskDto.java"><em>TaskDto.java</em></a></p>
<pre tabindex="0"><code>public record TaskDto(Long id, String name, TaskStatus status) {}
</code></pre><p>That matters more than it looks. Whatever a tool returns is serialized and handed to the model, so the DTO decides exactly which fields leave your system.</p>
<h3 id="step-4-taskmcptool-the-actual-mcpserver">Step 4: TaskMcpTool, the actual MCP server</h3>
<p>This is the whole MCP layer:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/src/main/java/org/example/mcpdemo/mcp/TaskMcpTool.java"><em>TaskMcpTool.java</em></a></p>
<pre tabindex="0"><code>@Component
@RequiredArgsConstructor
public class TaskMcpTool {
    private final TaskService service;
    @McpTool(name = &#34;add_task&#34;, description = &#34;Create a new task with status NEW and return it&#34;)
    public TaskDto add(@McpToolParam(description = &#34;Task name, 1-255 characters&#34;) String name) {
        return service.create(name);
    }
    @McpTool(name = &#34;get_task&#34;, description = &#34;Get a task by its id. Fails if the task does not exist&#34;)
    public TaskDto get(@McpToolParam(description = &#34;Task id&#34;) Long id) {
        return service.get(id);
    }
    @McpTool(name = &#34;update_task_status&#34;,
            description = &#34;Change the status of a task and return the updated task. Fails if the task does not exist&#34;)
    public TaskDto updateStatus(@McpToolParam(description = &#34;Task id&#34;) Long id,
                                @McpToolParam(description = &#34;New task status&#34;) TaskStatus status) {
        return service.updateStatus(id, status);
    }
    @McpTool(name = &#34;get_task_list&#34;, description = &#34;List all tasks&#34;)
    public List&lt;TaskDto&gt; getList() {
        return service.getList();
    }
}
</code></pre><p>Only a thin adapter: each method delegates to the service. Most of the work happens in the annotations.</p>
<h3 id="how-the-tools-get-registered">How the tools get registered</h3>
<p>The class is a plain @Component. When the application starts, Spring AI&rsquo;s annotation scanner finds every bean method annotated with @McpTool and registers it as an MCP tool. You don&rsquo;t need a registry, a ToolCallback list or any manual wiring. To add a tool, write another annotated method.</p>
<h3 id="mcptool-the-name-and-description-the-modelsees">@McpTool: the name and description the model sees</h3>
<ul>
<li><strong>name</strong> is the tool id the model calls. I use snake_case verbs (add_task, get_task_list) because that&rsquo;s the convention across MCP servers, and models pick such names up reliably. Without a name, the Java method name is used, and add on its own tells the model very little.</li>
<li><strong>description</strong> is the most important string in the class. It&rsquo;s the only thing the model reads when deciding which tool to call and what to expect back. Say what the tool does, what it returns and when it fails (&ldquo;Fails if the task does not exist&rdquo;). A vague description gives you a model that guesses.</li>
</ul>
<h3 id="mcptoolparam-the-schema-is-generated-foryou">@McpToolParam: the schema is generated for you</h3>
<p>Each parameter gets a description, and Spring AI builds a JSON Schema from the Java type. Here’s what the server actually returns for update_task_status when Claude Code asks for tools/list:</p>
<pre tabindex="0"><code>{
  &#34;name&#34;: &#34;update_task_status&#34;,
  &#34;description&#34;: &#34;Change the status of a task and return the updated task. Fails if the task does not exist&#34;,
  &#34;inputSchema&#34;: {
    &#34;type&#34;: &#34;object&#34;,
    &#34;properties&#34;: {
      &#34;id&#34;:     { &#34;type&#34;: &#34;integer&#34;, &#34;format&#34;: &#34;int64&#34;, &#34;description&#34;: &#34;Task id&#34; },
      &#34;status&#34;: { &#34;type&#34;: &#34;string&#34;,
                  &#34;enum&#34;: [&#34;NEW&#34;, &#34;IN_PROGRESS&#34;, &#34;DONE&#34;, &#34;CANCELLED&#34;],
                  &#34;description&#34;: &#34;New task status&#34; }
    },
    &#34;required&#34;: [&#34;id&#34;, &#34;status&#34;]
  }
}
</code></pre><p>Things to notice:</p>
<ul>
<li><strong>Long became</strong> <strong>integer with</strong> <strong>int64 format.</strong> You don&rsquo;t write any schema by hand.</li>
<li><strong>Parameters are required by default</strong> (required = true). Set @McpToolParam(required = false) for optional ones.</li>
<li><strong>The</strong> <strong>TaskStatus enum became a JSON</strong> <strong>enum.</strong> The model sees the four allowed values up front, so it can&rsquo;t invent a status like DELETED. If it tries, the call is rejected by schema validation before your method runs:</li>
</ul>
<pre tabindex="0"><code>Tool (update_task_status) input validation failed: ... /status: does not 
have a value in the enumeration [&#34;NEW&#34;, &#34;IN_PROGRESS&#34;, &#34;DONE&#34;, &#34;CANCELLED&#34;]
</code></pre><p>Use enums for anything with a fixed set of values. You get validation and documentation in one place.</p>
<h3 id="return-values-anderrors">Return values and errors</h3>
<p>You return a normal Java object, and Spring AI serializes it to JSON as the tool result. <code>TaskDto</code> and <code>List&lt;TaskDto&gt;</code> just work.</p>
<p>Exceptions work too. When TaskService throws TaskNotFoundException, the client doesn&rsquo;t get a stack trace or a broken JSON-RPC response. It gets a tool result with isError: true and the exception message as the text:</p>
<pre tabindex="0"><code>{ 
  &#34;content&#34;: [{ &#34;type&#34;: &#34;text&#34;, &#34;text&#34;: &#34;Task with id -1 is not found&#34; }], 
  &#34;isError&#34;: true 
}
</code></pre><p>That’s the behaviour you want: the model reads the message, understands what went wrong and can recover, for example by calling get_task_list to find the right id. Write exception messages for the model to read, not just for your logs.</p>
<p>The same applies to Bean Validation. A blank name fails @NotBlank in the service and comes back to the model as an error result, not a 500.</p>
<h3 id="a-tip-add-behaviour-hints">A tip: add behaviour hints</h3>
<p>If you look at the full tools/list response, every tool reports &ldquo;readOnlyHint&rdquo;: false, &ldquo;destructiveHint&rdquo;: true, including get_task_list. Those are the defaults. Clients such as Claude Code can use these hints to decide how cautious to be, so it&rsquo;s worth being honest about read-only tools:</p>
<pre tabindex="0"><code>@McpTool(name = &#34;get_task_list&#34;, description = &#34;List all tasks&#34;,
        annotations = @McpTool.McpAnnotations(readOnlyHint = true, 
                                              destructiveHint = false))
</code></pre><p>These are hints, not enforcement. What actually protects your data is the fact that the tool only reads.</p>
<h3 id="step-5-securityconfig-briefly">Step 5: SecurityConfig, briefly</h3>
<p>By default, an HTTP MCP endpoint is an unauthenticated JSON-RPC endpoint. Anyone who can reach /mcp can call your tools. The mcp-security library closes it with a few lines:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/src/main/java/org/example/mcpdemo/config/SecurityConfig.java"><em>SecurityConfig.java</em></a></p>
<pre tabindex="0"><code>@Configuration
@EnableWebSecurity
class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http,
                                            ApiKeyEntityRepository&lt;ApiKeyEntityImpl&gt; apiKeyRepository) {
        return http
                .authorizeHttpRequests(auth -&gt; auth.anyRequest().authenticated())
                .with(mcpServerApiKey(), apiKey -&gt; apiKey.apiKeyRepository(apiKeyRepository))
                .build();
    }
    @Bean
    ApiKeyEntityRepository&lt;ApiKeyEntityImpl&gt; apiKeyRepository(@Value(&#34;${app.mcp.api-key.secret}&#34;) String secret) {
        var key = ApiKeyEntityImpl.builder()
                .name(&#34;demo key&#34;)
                .id(&#34;demo&#34;)
                .secret(secret)
                .build();
        return new InMemoryApiKeyEntityRepository&lt;&gt;(List.of(key));
    }
}
</code></pre><ul>
<li>mcpServerApiKey() plugs API-key authentication into the normal Spring Security filter chain.</li>
<li>The client sends an X-API-key header in the form <code>&lt;id&gt;.&lt;secret&gt;</code>, so <code>demo.&lt;your secret&gt;</code> here. Requests without it get 401.</li>
</ul>
<h3 id="step-6-run-it-and-connect-claudecode">Step 6: Run it and connect Claude Code</h3>
<p>Start PostgreSQL and the app:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/docker-compose.yaml"><em>docker-compose.yaml</em></a><em>,</em><a href="https://github.com/SukachVitally/mcp-demo/blob/main/.env.tpl"><em>.env.tpl</em></a><em>,</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/README.md"><em>README</em></a></p>
<pre tabindex="0"><code>cp .env.tpl .env          # sets MCP_API_KEY_SECRET
docker compose up -d
./gradlew bootRun
</code></pre><p>Register the server with Claude Code:</p>
<pre tabindex="0"><code>claude mcp add --transport http todos http://localhost:8080/mcp \
  --header &#34;X-API-key: demo.mcp-demo-test-secret&#34;
</code></pre><p>Then ask in plain language:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/building-an-mcp-server-in-java-with-spring-ai-let-claude-code-use-your-database-without-seeing-a/image-03.png"></p>
<p>“Add todo with name test” becomes an add_task call. &ldquo;Update task with id 1 to DONE&rdquo; becomes update_task_status with a valid enum value. Claude never saw a JDBC URL, a username or a password.</p>
<p>To inspect the server without Claude, use the MCP Inspector (npx @modelcontextprotocol/inspector). Choose the Streamable HTTP transport and add the same header.</p>
<h3 id="testing">Testing</h3>
<p>Because the server speaks standard MCP, the tests can use the official Java MCP client against a real server started by @SpringBootTest, with PostgreSQL in Testcontainers:</p>
<p><em>Source:</em> <a href="https://github.com/SukachVitally/mcp-demo/blob/main/src/test/java/org/example/mcpdemo/mcp/TaskMcpToolTest.java"><em>TaskMcpToolTest.java</em></a></p>
<pre tabindex="0"><code>var transport = HttpClientStreamableHttpTransport
        .builder(&#34;http://localhost:&#34; + port)
        .httpRequestCustomizer((builder, method, endpoint, body, context) -&gt;
                builder.header(&#34;X-API-key&#34;, &#34;demo.&#34; + API_KEY_SECRET))
        .build();
client = McpClient.sync(transport).build();
client.initialize();
var result = client.callTool(McpSchema.CallToolRequest.builder(&#34;get_task&#34;)
        .arguments(Map.of(&#34;id&#34;, -1L)).build());
assertThat(result.isError()).isTrue();
</code></pre><p>This covers the whole chain in one test: authentication, schema, serialization, error mapping and the database.</p>
<h3 id="takeaways">Takeaways</h3>
<ul>
<li><strong>You don’t write JSON-RPC in Java.</strong> spring-ai-starter-mcp-server-webmvc handles the protocol, the transport and tool discovery.</li>
<li><strong>An MCP tool is an annotated Spring bean method.</strong> @McpTool names and describes it, and @McpToolParam describes its inputs. The JSON Schema is generated from your Java types.</li>
<li><strong>Descriptions and error messages are prompts.</strong> The model reads them, so write them for the model.</li>
<li><strong>Use enums and DTOs</strong> to control what the model can send in and what it gets back.</li>
<li><strong>Secure the endpoint.</strong> API keys via mcp-security take ten lines, and OAuth2 is available when you need it.</li>
<li><strong>The MCP server is the security boundary.</strong> Claude Code works with your data through the operations you choose, never with your credentials.</li>
</ul>
<p>The Python version showed how MCP works on the wire. The Spring version shows how little code you need once a framework handles it. Take the repo, replace tasks with your own domain, and give your agent an API instead of a password.</p>
<p><strong>Links</strong></p>
<ul>
<li>Demo code: <a href="https://github.com/SukachVitally/mcp-demo">github.com/SukachVitally/mcp-demo</a></li>
<li>Spring AI MCP Server Boot Starter: <a href="https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html">docs.spring.io</a></li>
<li>MCP security for Spring: <a href="https://github.com/spring-ai-community/mcp-security/tree/main">github.com/spring-ai-community/mcp-security</a></li>
<li>Previous article, MCP in Python from scratch: <a href="https://medium.com/@sukach.v.v/mcp-the-protocol-that-finally-taught-ai-to-stop-being-useless-759de3745b79">MCP: The Protocol That Finally Taught AI to Stop Being Useless</a></li>
</ul>
]]></content:encoded></item><item><title>The Java Memory Model, Explained Without the Headache</title><link>https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/</link><pubDate>Thu, 03 Sep 2026 16:18:18 +0000</pubDate><guid>https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/</guid><description>&lt;h4 id="what-actually-happens-to-your-data-between-two-threadsand-the-small-set-of-tools-that-let-you-controlit">What actually happens to your data between two threads — and the small set of tools that let you control it&lt;/h4>
&lt;p>&lt;img loading="lazy" src="https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/image-01.png">&lt;/p>
&lt;p>Ask ten Java developers what “the Java Memory Model” means and you’ll get two different answers.&lt;/p>
&lt;p>Half of them will draw a box called &lt;em>Heap&lt;/em> and another called &lt;em>Stack&lt;/em>. The other half will start talking about volatile and happens-before.&lt;/p>
&lt;p>Both groups are talking about something real, but only the second group is talking about the actual &lt;strong>Java Memory Model (JMM)&lt;/strong>. The first group is describing the &lt;strong>runtime data areas&lt;/strong> — where objects physically live.&lt;/p></description><content:encoded><![CDATA[<h4 id="what-actually-happens-to-your-data-between-two-threadsand-the-small-set-of-tools-that-let-you-controlit">What actually happens to your data between two threads — and the small set of tools that let you control it</h4>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/image-01.png"></p>
<p>Ask ten Java developers what “the Java Memory Model” means and you’ll get two different answers.</p>
<p>Half of them will draw a box called <em>Heap</em> and another called <em>Stack</em>. The other half will start talking about volatile and happens-before.</p>
<p>Both groups are talking about something real, but only the second group is talking about the actual <strong>Java Memory Model (JMM)</strong>. The first group is describing the <strong>runtime data areas</strong> — where objects physically live.</p>
<p>You need both to write correct concurrent code, so let’s go through them in order. Memory layout first, because it’s the easier one, then the JMM proper.</p>
<h3 id="part-1-where-your-datalives">Part 1: Where your data lives</h3>
<p>When the JVM starts, it carves the process memory into a few distinct regions. Each has an owner and a lifetime.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/image-02.png"></p>
<p><strong>Heap</strong> — every object and array you ever create lives here. It’s shared, which is exactly why concurrency is hard. Garbage collection happens here. Modern collectors split it further (young generation, old generation, or regions in G1/ZGC), but that’s a GC detail, not a memory model one.</p>
<p><strong>Metaspace</strong> — class metadata, bytecode, and static fields. It sits in native memory since Java 8, which is why PermGen errors disappeared and Metaspace OOM errors appeared instead.</p>
<p><strong>Thread stack</strong> — each thread gets its own. Every method call pushes a frame containing local variables, parameters, and the return address. Frame pops when the method returns. This is where StackOverflowError comes from. Nothing here is shared, so locals are never a concurrency problem — unless the <em>value</em> they point to is a shared object.</p>
<p><strong>PC register</strong> — the address of the instruction the thread is currently executing. One per thread.</p>
<p><strong>Native method stack</strong> — the same idea, but for native (JNI) calls.</p>
<p>One thing worth burning in:</p>
<pre tabindex="0"><code>Order order = new Order();
</code></pre><p>The variable order is a reference sitting on the <strong>stack</strong>. The Order object itself sits on the <strong>heap</strong>. Two threads can hold two separate stack references pointing to the same heap object. That&rsquo;s the whole source of the trouble.</p>
<h3 id="part-2-the-actual-java-memorymodel">Part 2: The actual Java Memory Model</h3>
<p>The runtime areas tell you <em>where</em> data sits. They tell you nothing about <em>when</em> one thread sees a write made by another.</p>
<p>That question is what the JMM answers. It’s a specification (JSR-133, in Chapter 17 of the Java Language Specification) that defines exactly what guarantees you get about reads and writes across threads.</p>
<h4 id="the-problem-it-exists-tosolve">The problem it exists to solve</h4>
<p>Modern CPUs are fast because they cheat, aggressively and correctly. Two things they do:</p>
<p><strong>1. Caching.</strong> Each core has its own L1/L2 cache. When a thread writes to a field, that write may sit in the core’s cache or store buffer for a while before it becomes visible to other cores.</p>
<p><strong>2. Reordering.</strong> The compiler, the JIT, and the CPU are all allowed to reorder instructions, as long as the result looks the same <em>for a single thread</em>. That last clause is the trap — nobody promised anything about how it looks to other threads.</p>
<p>So the JMM works with an abstraction:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/image-03.png"></p>
<p><em>Thread A and Thread B never see each other’s working memory. Everything they share has to travel through main memory — and the JMM decides when it does.</em></p>
<p>Every thread has a conceptual <strong>working memory</strong> (registers, cache, store buffers) holding copies of the variables it touches. Threads never talk to each other directly — they communicate only through <strong>main memory</strong>. The JMM specifies when a value must be flushed to main memory and when it must be re-read from it.</p>
<p>Here’s the classic demonstration:</p>
<pre tabindex="0"><code>public class StopFlag {
    private boolean running = true;   // no volatile
    
    public void run() {
        while (running) {
            // do work
        }
        System.out.println(&#34;stopped&#34;);
    }
    public void stop() {
        running = false;
    }
}
</code></pre><p>Thread A runs run(). Thread B calls stop(). Very often, thread A never stops.</p>
<p>Nothing is broken. The JIT sees that running is never modified inside the loop, so it&rsquo;s free to hoist the read out:</p>
<pre tabindex="0"><code>boolean r = running;
while (r) { /* work */ }   // now an infinite loop
</code></pre><p>Without a memory model rule forcing it, the JVM has no obligation to ever re-read that field. Your bug is not a race in the “two writes collided” sense — it’s a <strong>visibility</strong> failure.</p>
<h4 id="the-three-guarantees">The three guarantees</h4>
<p>Every concurrency problem in Java falls into one of these buckets.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/the-java-memory-model-explained-without-the-headache/image-04.png"></p>
<p>count++ is the textbook atomicity failure — it&rsquo;s three operations (read, add, write), and two threads can interleave in the middle. Two increments, one result.</p>
<h3 id="the-base-components-of-thejmm">The base components of the JMM</h3>
<p>Here’s the toolbox. It’s smaller than most people expect.</p>
<h4 id="1-happens-beforethe-rule-that-underpins-everything">1. happens-before — the rule that underpins everything</h4>
<p>Everything else is a special case of this one relation. If action <strong>A</strong> <em>happens-before</em> action <strong>B</strong>, then:</p>
<ul>
<li>everything A wrote is visible to B, and</li>
<li>A is guaranteed to appear to occur before B.</li>
</ul>
<p>It is <strong>not</strong> just “A ran earlier in wall-clock time.” Two actions can be ordered in time and still have no happens-before relationship, which means B may see stale data.</p>
<p>The rules the JLS gives you:</p>
<ol>
<li><strong>Program order</strong> — within one thread, each statement happens-before the ones written after it.</li>
<li><strong>Monitor lock</strong> — unlocking a monitor happens-before any later lock of the <em>same</em> monitor.</li>
<li><strong>volatile</strong> — a write to a volatile field happens-before every later read of that field.</li>
<li><strong>Thread start</strong> — Thread.start() happens-before anything in the started thread.</li>
<li><strong>Thread join</strong> — everything in a thread happens-before another thread’s successful join() on it.</li>
<li><strong>Interruption</strong> — a call to interrupt() happens-before the interrupted thread detects it.</li>
<li><strong>Constructor / finalizer</strong> — the end of a constructor happens-before the start of that object’s finalizer.</li>
<li><strong>Transitivity</strong> — if A hb B and B hb C, then A hb C.</li>
</ol>
<p>That last one does most of the practical work. It’s why writing a plain field and <em>then</em> a volatile flag safely publishes the plain field.</p>
<h4 id="2-volatilevisibility-and-ordering-no-atomicity">2. volatile — visibility and ordering, no atomicity</h4>
<pre tabindex="0"><code>private volatile boolean running = true;
</code></pre><p>This single keyword fixes the StopFlag example. It gives you:</p>
<ul>
<li><strong>Visibility</strong> — a write goes to main memory immediately; a read comes from main memory, never a cache.</li>
<li><strong>Ordering</strong> — the JVM inserts memory barriers so reads and writes can’t be reordered across the volatile access.</li>
</ul>
<p>What it does <strong>not</strong> give you is atomicity:</p>
<pre tabindex="0"><code>private volatile int count = 0;
public void increment() {
    count++;   // STILL BROKEN
}
</code></pre><p>Use volatile for flags and for single-writer state. For compound operations, use something else.</p>
<h4 id="3-synchronizedall-three-guarantees">3. synchronized — all three guarantees</h4>
<pre tabindex="0"><code>private int count = 0;
public synchronized void increment() {
    count++;   // correct
}
public synchronized int get() {
    return count;   // also needs to be synchronized
}
</code></pre><p>Entering the monitor gives mutual exclusion (atomicity). Exiting flushes to main memory, entering invalidates the local copy (visibility). Barriers around the block prevent reordering out of it.</p>
<p>The most common mistake here is synchronizing the writer but not the reader. Both sides need the <em>same</em> lock, or there’s no happens-before edge between them.</p>
<p>ReentrantLock gives you the same memory guarantees, plus tryLock, timeouts, fairness, and multiple conditions.</p>
<h4 id="4-finalsafe-publication-forfree"><strong>4.</strong> <strong>final — safe publication for free</strong></h4>
<pre tabindex="0"><code>public class Config {
    private final String url;
    public Config(String url) {
        this.url = url;   // guaranteed visible after construction
    }
}
</code></pre><p>The JMM guarantees that any thread that sees a properly constructed object sees the correct values of its final fields — no synchronization required. This is why immutable objects are the easiest possible concurrency strategy.</p>
<p>The catch is “properly constructed.” If the constructor leaks this (registering a listener, starting a thread), the guarantee is void.</p>
<h4 id="5-atomics-and-varhandle">5. Atomics and VarHandle</h4>
<p>java.util.concurrent.atomic gives you lock-free compound operations built on CPU compare-and-swap instructions:</p>
<pre tabindex="0"><code>private final AtomicInteger count = new AtomicInteger();
public void increment() {
    count.incrementAndGet();   // atomic, visible, ordered
}
</code></pre><p>Under contention LongAdder usually beats AtomicLong — it spreads writes across cells and sums them on read, avoiding cache-line contention. VarHandle (Java 9+) is the modern, supported way to reach for finer-grained modes like getAcquire / setRelease when you know exactly what you&rsquo;re doing.</p>
<h3 id="three-bugs-thisexplains">Three bugs this explains</h3>
<p><strong>Unsafe publication.</strong> Building an object and handing the reference to another thread without a happens-before edge. The other thread can see a non-null reference to a half-initialized object — fields still at their defaults. Publish through a volatile field, a lock, a final field, or a concurrent collection.</p>
<p><strong>Double-checked locking without</strong> <strong>volatile.</strong> The infamous one:</p>
<pre tabindex="0"><code>private static volatile Singleton instance;   // volatile is mandatory
public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
    }
    return instance;
}
</code></pre><p>Drop the volatile and it breaks, because instance = new Singleton() is three steps — allocate, construct, assign — and the last two can be reordered. Another thread can grab a reference to an object whose constructor hasn&rsquo;t finished.</p>
<p><strong>Check-then-act.</strong> if (!map.containsKey(k)) map.put(k, v); is two atomic operations, not one atomic operation. Use putIfAbsent or computeIfAbsent.</p>
<h3 id="the-practical-cheatsheet">The practical cheat sheet</h3>
<ul>
<li><strong>Shared and mutable?</strong> It needs synchronization. Every time.</li>
<li><strong>One thread writes a flag, others read it?</strong> volatile.</li>
<li><strong>Compound operation on one variable?</strong> An atomic class.</li>
<li><strong>Compound operation across several variables?</strong> synchronized or ReentrantLock.</li>
<li><strong>Can you make it immutable instead?</strong> Do that. final fields, no setters, no shared mutation, no memory model to reason about.</li>
<li><strong>Reading is also access.</strong> Unsynchronized reads of synchronized state are still broken.</li>
<li><strong>“It works on my machine” proves nothing.</strong> x86 has a strong memory model that hides many of these bugs. ARM (Apple Silicon, Graviton) doesn’t. Code that ran fine for years on Intel servers can fail the week you move it to ARM instances.</li>
</ul>
<h3 id="wrapping-up">Wrapping up</h3>
<p>The runtime areas — heap, stacks, metaspace — tell you where your data sits. The Java Memory Model tells you when one thread’s writes become visible to another, and it comes down to a handful of pieces: happens-before as the underlying rule, and volatile, synchronized, final, and the atomics as the tools that create it.</p>
<p>You don’t need to memorize the specification. You need one habit: whenever two threads touch the same data, ask what creates the happens-before edge between them. If you can’t name it, it isn’t there.</p>
<p><em>If this was useful, the natural next step is Brian Goetz’s</em> Java Concurrency in Practice <em>— still the best treatment of this material, twenty years on.</em></p>
]]></content:encoded></item><item><title>DateTime Handling in Java and PostgreSQL: A Practical Guide</title><link>https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/</link><pubDate>Sat, 15 Aug 2026 14:24:50 +0000</pubDate><guid>https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/</guid><description>&lt;p>&lt;em>Everything I wish someone had told me before I stored a meeting time in a&lt;/em> &lt;em>timestamp column.&lt;/em>&lt;/p>
&lt;p>&lt;img loading="lazy" src="https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/image-01.png">&lt;/p>
&lt;p>There is a specific kind of bug that only shows up in production, only in October, and only for users in one country. It gets closed as “not reproducible” twice before someone finally sits down with a coffee and reads the column definition.&lt;/p>
&lt;p>It is almost always a datetime bug.&lt;/p>
&lt;p>The frustrating part is that both Java and PostgreSQL have &lt;em>excellent&lt;/em> date/time support. java.time is one of the best-designed APIs in the JDK. PostgreSQL&amp;rsquo;s temporal types are precise and well documented. The bugs happen at the seam — in the assumptions each side makes about what the other means, and in the two or three words in the type names that mean the opposite of what you&amp;rsquo;d guess.&lt;/p></description><content:encoded><![CDATA[<p><em>Everything I wish someone had told me before I stored a meeting time in a</em> <em>timestamp column.</em></p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/image-01.png"></p>
<p>There is a specific kind of bug that only shows up in production, only in October, and only for users in one country. It gets closed as “not reproducible” twice before someone finally sits down with a coffee and reads the column definition.</p>
<p>It is almost always a datetime bug.</p>
<p>The frustrating part is that both Java and PostgreSQL have <em>excellent</em> date/time support. java.time is one of the best-designed APIs in the JDK. PostgreSQL&rsquo;s temporal types are precise and well documented. The bugs happen at the seam — in the assumptions each side makes about what the other means, and in the two or three words in the type names that mean the opposite of what you&rsquo;d guess.</p>
<p>This guide walks through both models, then spends real time on the part that actually causes outages: time zones.</p>
<p>Every code output and query result below was executed — Java 21 and PostgreSQL 16 — not written from memory.</p>
<h3 id="part-1-the-javaside">Part 1: The Java Side</h3>
<h4 id="ask-three-questions-get-onetype">Ask three questions, get one type</h4>
<p>Every java.time class is an answer to some combination of three questions:</p>
<ol>
<li>Do I know the <strong>date</strong>?</li>
<li>Do I know the <strong>time of day</strong>?</li>
<li>Do I know which <strong>point on the global timeline</strong> this is?</li>
</ol>
<p>That last one is the important one, and it’s the one people skip. Here’s the map:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/image-02.png"></p>
<p>The single most useful sentence in this article: <strong>LocalDateTime is not a point in time.</strong> 2026-08-15T12:00 is twelve o&rsquo;clock <em>somewhere</em>, and until you say where, it refers to a 26-hour-wide smear across the planet. If you have an Instant and you call .toLocalDateTime(), you have thrown away information and the compiler will not warn you.</p>
<h4 id="instant-is-the-boring-correctdefault">Instant is the boring, correct default</h4>
<pre tabindex="0"><code>Instant now = Instant.now();     // 2026-08-15T10:00:00.123456789Z
</code></pre><p>An Instant is nanoseconds since the epoch. No zone, no offset, no calendar. When it prints with a Z, that isn&rsquo;t a stored time zone — it&rsquo;s the ISO-8601 way of rendering an epoch value.</p>
<p>Anything that <em>happened</em> — a login, a payment, a row insert — is an Instant. Reach for something else only when you have a reason.</p>
<h4 id="zoneid-vs-zoneoffset-not-the-samething">ZoneId vs ZoneOffset: not the same thing</h4>
<p>This distinction matters more than any other in this article.</p>
<pre tabindex="0"><code>ZoneId.of(&#34;Europe/London&#34;)  // -&gt; java.time.ZoneRegion
ZoneId.of(&#34;+01:00&#34;)         // -&gt; java.time.ZoneOffset
</code></pre><p>A <strong>ZoneOffset</strong> is a fixed number of hours and minutes from UTC. +01:00. That&rsquo;s the whole thing. It&rsquo;s a fact about one moment.</p>
<p>A <strong>ZoneId</strong> (specifically a ZoneRegion, like Europe/London) is a <em>rulebook</em>. It knows that London was +00:00 in January, +01:00 in August, and it knows the exact instants at which it flips — going back decades and forward as far as legislators have declared.</p>
<p>ZoneOffset is a value. ZoneId is a function from instant to offset. Confusing them is the root cause of most scheduling bugs.</p>
<h4 id="offsetdatetime-vs-zoneddatetime">OffsetDateTime vs ZonedDateTime</h4>
<p>Both identify a real instant. The difference is what survives.</p>
<pre tabindex="0"><code>ZonedDateTime nyc = ZonedDateTime.of(2026, 7, 4, 12, 0, 0, 0,
                                     ZoneId.of(&#34;America/New_York&#34;));
nyc                    // 2026-07-04T12:00-04:00[America/New_York]
nyc.toOffsetDateTime() // 2026-07-04T12:00-04:00
nyc.toInstant()        // 2026-07-04T16:00:00Z
</code></pre><p>All three describe the same moment. But the ZonedDateTime still knows it&rsquo;s in New York, so if you add six months to it, it will correctly land on -05:00. The OffsetDateTime has been flattened — it remembers <em>that</em> the offset was -04:00, not <em>why</em>. Add six months and you get a wrong wall-clock time.</p>
<p>Rule of thumb: ZonedDateTime for calculating, OffsetDateTime for transporting, Instant for storing.</p>
<h4 id="duration-vsperiod">Duration vs Period</h4>
<pre tabindex="0"><code>LocalDate.of(2026, 1, 31).plusMonths(1)  // 2026-02-28
</code></pre><p>Duration is exact elapsed time — seconds and nanos. Period is calendar time — years, months, days. &ldquo;One month&rdquo; is not a fixed number of seconds, and plusMonths clamps rather than overflowing. Both behaviors are correct; mixing them up is not.</p>
<p>This distinction resurfaces the moment DST enters the picture, and we’ll come back to it.</p>
<h4 id="a-word-on-the-oldapi">A word on the old API</h4>
<p>java.util.Date is a mislabeled Instant (it has no time zone despite what toString() implies). Calendar is mutable and lenient. java.sql.Timestamp extends java.util.Date and adds nanoseconds while breaking the equals contract.</p>
<p>If you’re on a legacy codebase, convert at the boundary and never let them into your domain:</p>
<pre tabindex="0"><code>Instant i = oldDate.toInstant();
Date    d = Date.from(instant);
</code></pre><h3 id="part-2-the-postgresql-side">Part 2: The PostgreSQL Side</h3>
<h4 id="timestamptz-does-not-store-a-timezone">timestamptz does not store a time zone</h4>
<p>This is the single biggest source of confusion, and it comes straight from the type name. TIMESTAMP WITH TIME ZONE is an 8-byte value. So is TIMESTAMP WITHOUT TIME ZONE. There is no room in there for a zone, and PostgreSQL doesn&rsquo;t try:</p>
<pre tabindex="0"><code>SELECT pg_column_size(&#39;2026-08-15 12:00:00&#39;::timestamp)     AS ts_bytes,     
       --  8
       pg_column_size(&#39;2026-08-15 12:00:00+02&#39;::timestamptz) AS tstz_bytes,  
       --  8
       pg_column_size(&#39;12:00:00+02&#39;::timetz)                 AS timetz_bytes; 
       -- 12
</code></pre><p>What timestamptz actually does is <strong>convert to UTC on write and convert back on read</strong>. The offset you supplied on input is used and then discarded. From the <a href="https://www.postgresql.org/docs/current/datatype-datetime.html">official docs</a>:</p>
<blockquote>
<p><em>In either case, the value is stored internally as UTC, and the originally stated or assumed time zone is not retained.</em></p></blockquote>
<p>A better mental model: timestamptz is Instant. timestamp is LocalDateTime. The names lie; the semantics don&rsquo;t.</p>
<h4 id="the-demo-that-makes-itclick">The demo that makes it click</h4>
<p>Insert the same two literals into both column types, with the session zone set to London:</p>
<pre tabindex="0"><code>SET TimeZone = &#39;Europe/London&#39;;
CREATE TABLE t (id int, ts timestamp, tstz timestamptz);
INSERT INTO t VALUES (1, &#39;2026-08-15 12:00:00&#39;,    &#39;2026-08-15 12:00:00&#39;);
INSERT INTO t VALUES (2, &#39;2026-08-15 12:00:00+00&#39;, &#39;2026-08-15 12:00:00+00&#39;);
SELECT * FROM t;
</code></pre><pre tabindex="0"><code> id |         ts          |          tstz
----+---------------------+------------------------
  1 | 2026-08-15 12:00:00 | 2026-08-15 12:00:00+01
  2 | 2026-08-15 12:00:00 | 2026-08-15 13:00:00+01
</code></pre><p>Two things happened. Row 1’s tstz had no offset, so PostgreSQL <em>assumed</em> the session zone. Row 2&rsquo;s explicit +00 was honoured and re-rendered as 13:00+01. Meanwhile the ts column ignored the offset entirely and stored the digits.</p>
<p>Now reconnect from New York and run the identical SELECT:</p>
<pre tabindex="0"><code> id |         ts          |          tstz
----+---------------------+------------------------
  1 | 2026-08-15 12:00:00 | 2026-08-15 07:00:00-04
  2 | 2026-08-15 12:00:00 | 2026-08-15 08:00:00-04
</code></pre><p>The tstz column re-renders per client. The ts column is frozen. <strong>Neither row changed on disk.</strong></p>
<p>That last point deserves emphasis, because it’s where the panic starts: when a developer sees a different value than QA saw, the instinct is “the data is corrupted.” It isn’t. Two clients with different TimeZone settings are looking at the same UTC value through different windows.</p>
<h4 id="at-time-zone-runs-bothways">AT TIME ZONE runs both ways</h4>
<p>AT TIME ZONE is the one operator you need, and its behavior flips depending on the input type:</p>
<pre tabindex="0"><code>-- timestamptz -&gt; timestamp: &#34;what does the clock read there?&#34;
SELECT &#39;2026-08-15 12:00:00+00&#39;::timestamptz AT TIME ZONE &#39;Asia/Tokyo&#39;;
-- 2026-08-15 21:00:00   (type: timestamp without time zone)
</code></pre><pre tabindex="0"><code>-- timestamp -&gt; timestamptz: &#34;this wall clock reading WAS in Tokyo&#34;
SELECT &#39;2026-08-15 12:00:00&#39;::timestamp AT TIME ZONE &#39;Asia/Tokyo&#39;;
-- 2026-08-15 03:00:00+00   (type: timestamp with time zone)
</code></pre><p>One removes zone context, the other attaches it. Read it as “interpret this value in that zone.” The function form timezone(&lsquo;Asia/Tokyo&rsquo;, value) is identical.</p>
<h4 id="the-rest-of-thefamily">The rest of the family</h4>
<ul>
<li><strong>date</strong> — 4 bytes, no zone, no ambiguity. Great for things that are genuinely dates.</li>
<li><strong>time</strong> — time of day, no zone.</li>
<li><strong>timetz</strong> — 12 bytes, and <a href="https://www.postgresql.org/docs/current/datatype-datetime.html">PostgreSQL itself recommends against it</a>. A time of day with an offset but no date can&rsquo;t resolve DST, so it&rsquo;s not meaningfully anchored. It won&rsquo;t even accept a zone name:</li>
</ul>
<pre tabindex="0"><code>SELECT &#39;12:00:00 Europe/London&#39;::timetz; -- ERROR: invalid input syntax for type time with time zone
</code></pre><ul>
<li><strong>interval</strong> — 16 bytes storing months, days, and microseconds <em>separately</em>, which is exactly why it can be DST-aware.</li>
</ul>
<h4 id="now-vs-localtimestamp">now() vs localtimestamp</h4>
<pre tabindex="0"><code>SELECT pg_typeof(now()),            -- timestamp with time zone
       pg_typeof(current_timestamp), -- timestamp with time zone
       pg_typeof(localtimestamp),    -- timestamp without time zone
       pg_typeof(current_date);      -- date
</code></pre><p>now() and current_timestamp are timestamptz and are safe. localtimestamp and current_date are evaluated in the session zone, which means a DEFAULT current_date column produces different answers for different clients. Be deliberate.</p>
<h4 id="so-which-do-youpick">So which do you pick?</h4>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/image-03.png"></p>
<p>The short version: <strong>use</strong> <strong>timestamptz for essentially everything.</strong> Reach for timestamp only when you genuinely mean a floating wall-clock time that must not shift — &ldquo;the store opens at 09:00 local, whatever local means at that branch&rdquo; — and store the zone ID beside it.</p>
<h3 id="part-3-time-zonesproperly">Part 3: Time Zones, Properly</h3>
<p>This is where the real complexity lives, so let’s slow down.</p>
<h4 id="three-things-called-timezone">Three things called “time zone”</h4>
<ol>
<li><strong>An offset</strong> — +01:00. A number.</li>
<li><strong>An IANA zone ID</strong> — Europe/London. A named set of historical and future rules. PostgreSQL ships ~499 of these in pg_timezone_names; Java reads the same tzdata.</li>
<li><strong>An abbreviation</strong> — EST, BST, IST. Ambiguous, unversioned, and a trap.</li>
</ol>
<p>That third one is genuinely dangerous:</p>
<pre tabindex="0"><code>SELECT &#39;2026-08-15 12:00:00 EST&#39;::timestamptz,               -- 2026-08-15 17:00:00+00
       &#39;2026-08-15 12:00:00 America/New_York&#39;::timestamptz;  -- 2026-08-15 16:00:00+00
</code></pre><p>One hour apart. EST is a <em>fixed</em> -05:00 in PostgreSQL — it does not mean &ldquo;Eastern time,&rdquo; it means &ldquo;Eastern Standard Time, always.&rdquo; In August, New York is on -04:00. If you accept zone abbreviations from user input, you have already shipped a bug. (IST is worse — it can mean India, Ireland, or Israel depending on who you ask.)</p>
<p><strong>Always use full IANA names.</strong></p>
<p>Offsets aren’t zones either. Nepal is +05:45; the Chatham Islands are +12:45:</p>
<pre tabindex="0"><code>ZonedDateTime.of(2026,8,15,12,0,0,0, ZoneId.of(&#34;Asia/Kathmandu&#34;))
// 2026-08-15T12:00+05:45[Asia/Kathmandu]
</code></pre><p>If your API contract says “offset in whole hours,” it’s wrong.</p>
<h3 id="where-postgresqls-session-zone-comesfrom">Where PostgreSQL’s session zone comes from</h3>
<p>The TimeZone GUC is a <strong>per-connection setting</strong>. Its value is decided by, in order: an explicit SET TimeZone, the connection&rsquo;s startup parameters, the postgresql.conf value, and finally the server&rsquo;s OS zone.</p>
<p>That last fallback is why the same query returns different results on a developer laptop and in CI. And with connection pooling, SET TimeZone on a borrowed connection leaks to whoever gets it next.</p>
<p>Pin it. Set timezone = &lsquo;UTC&rsquo; in postgresql.conf, and add it to the JDBC URL so it survives pool reuse:</p>
<pre tabindex="0"><code>jdbc:postgresql://host:5432/db?options=-c%20TimeZone%3DUTC
</code></pre><p>Then make the client responsible for rendering. Formatting a timestamp for a human is a presentation concern, not a database one.</p>
<h4 id="the-spring-forward-gap">The spring-forward gap</h4>
<p>On 29 March 2026, London jumps from 01:00 to 02:00. The local times between them <strong>do not exist</strong>.</p>
<p>Ask Java for one anyway:</p>
<pre tabindex="0"><code>LocalDateTime gap = LocalDateTime.of(2026, 3, 29, 1, 30);
gap.atZone(ZoneId.of(&#34;Europe/London&#34;));
// 2026-03-29T02:30+01:00[Europe/London]
ZoneId.of(&#34;Europe/London&#34;).getRules().getTransition(gap);
// Transition[Gap at 2026-03-29T01:00Z to +01:00]
</code></pre><p>Java doesn’t throw. It silently pushes the value forward by the length of the gap. PostgreSQL does the same thing:</p>
<pre tabindex="0"><code>SET TimeZone=&#39;Europe/London&#39;;
SELECT &#39;2026-03-29 01:30:00&#39;::timestamptz;
-- 2026-03-29 02:30:00+01
</code></pre><p>Both are “correct” — but neither told you. If a user sets a 01:30 alarm on that date, you should be validating with getTransition() and telling them, not letting the platform quietly reschedule.</p>
<h4 id="the-fall-back-overlapwhere-java-and-postgresql-disagree">The fall-back overlap — where Java and PostgreSQL disagree</h4>
<p>On 25 October 2026, London goes from 02:00 back to 01:00. Local times between 01:00 and 02:00 happen <strong>twice</strong>, one hour apart.</p>
<p>Java’s rules expose both:</p>
<pre tabindex="0"><code>ZoneId london = ZoneId.of(&#34;Europe/London&#34;);
LocalDateTime overlap = LocalDateTime.of(2026, 10, 25, 1, 30);
london.getRules().getValidOffsets(overlap);
// [+01:00, Z]
ZonedDateTime earlier = overlap.atZone(london);
// 2026-10-25T01:30+01:00[Europe/London]  instant = 2026-10-25T00:30:00Z
ZonedDateTime later = earlier.withLaterOffsetAtOverlap();
// 2026-10-25T01:30Z[Europe/London]       instant = 2026-10-25T01:30:00Z
</code></pre><p>Java’s default for an ambiguous local time is the <strong>earlier</strong> offset — 00:30Z.</p>
<p>Now ask PostgreSQL the same question:</p>
<pre tabindex="0"><code>SELECT &#39;2026-10-25 01:30:00&#39;::timestamp AT TIME ZONE &#39;Europe/London&#39;;
-- 2026-10-25 01:30:00+00
</code></pre><p>PostgreSQL picked 01:30Z — the <strong>later</strong> one.</p>
<p>Same input, same tzdata, one hour apart. If your application resolves an ambiguous local time in Java and your reporting job resolves it in SQL, they will disagree for one hour, twice a year, in every DST zone.</p>
<p>There is no clever fix. The lesson is: <strong>never let a</strong> <strong>LocalDateTime cross the boundary and get resolved on the other side.</strong> Resolve to an Instant in exactly one place, with explicit overlap handling, and send the instant.</p>
<h4 id="dst-arithmetic-plusdays-is-not-plushours24">DST arithmetic: plusDays is not plusHours(24)</h4>
<pre tabindex="0"><code>ZonedDateTime start = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, london);
// 2026-03-28T12:00Z[Europe/London]
start.plusDays(1);                 // 2026-03-29T12:00+01:00  (23 real hours)
start.plus(Duration.ofDays(1));    // 2026-03-29T13:00+01:00  (24 real hours)
start.plusHours(24);               // 2026-03-29T13:00+01:00
</code></pre><p>plusDays preserves the wall clock. Duration preserves elapsed time. PostgreSQL draws the same line, using the same value:</p>
<pre tabindex="0"><code>SET TimeZone=&#39;Europe/London&#39;;
SELECT &#39;2026-03-28 12:00:00+00&#39;::timestamptz + interval &#39;1 day&#39;,   -- 2026-03-29 12:00:00+01
       &#39;2026-03-28 12:00:00+00&#39;::timestamptz + interval &#39;24 hours&#39;; -- 2026-03-29 13:00:00+01
</code></pre><p>This is one place the two platforms agree beautifully — interval &lsquo;1 day&rsquo; and plusDays(1) mean the same thing, and interval &lsquo;24 hours&rsquo; and Duration.ofDays(1) mean the same thing. &ldquo;Every day at 9am&rdquo; is plusDays / interval &lsquo;1 day&rsquo;. &ldquo;Every 24 hours&rdquo; is Duration / interval &lsquo;24 hours&rsquo;. Pick the one the product actually means.</p>
<h4 id="future-events-an-instant-is-notenough">Future events: an instant is not enough</h4>
<pre tabindex="0"><code>ZoneId ny = ZoneId.of(&#34;America/New_York&#34;);
ZonedDateTime.of(2027, 1, 15, 9, 0, 0, 0, ny).withZoneSameInstant(UTC); // 2027-01-15T14:00Z
ZonedDateTime.of(2027, 7, 15, 9, 0, 0, 0, ny).withZoneSameInstant(UTC); // 2027-07-15T13:00Z
</code></pre><p>“9am in New York” is a different UTC instant depending on the date. So if a user schedules a recurring 9am standup and a government later changes DST rules — which happens several times a year somewhere — the stored instant becomes wrong.</p>
<p>For <strong>past events</strong>, store the instant. That’s history; it can’t change.</p>
<p>For <strong>future user-scheduled events</strong>, store three things:</p>
<pre tabindex="0"><code>CREATE TABLE meetings (
  id            bigserial PRIMARY KEY,
  local_start   timestamp   NOT NULL,  -- 2027-01-15 09:00
  zone_id       text        NOT NULL,  -- &#39;America/New_York&#39;
  computed_utc  timestamptz NOT NULL   -- cache; recompute on tzdata update
);
</code></pre><p>The first two are the truth. The third is a derived index for querying, refreshed when tzdata changes.</p>
<h4 id="tzdata-is-a-movingtarget">tzdata is a moving target</h4>
<p>Java bundles a copy of the IANA database (2025b on the JDK I ran this on); PostgreSQL bundles its own; your OS has a third. They drift. When Java and PostgreSQL are on different tzdata versions, they will resolve recently-changed zones differently.</p>
<p>Check what you have:</p>
<pre tabindex="0"><code>ZoneRulesProvider.getVersions(&#34;Europe/London&#34;).lastKey();  // 2025b
</code></pre><pre tabindex="0"><code>SELECT name, utc_offset, is_dst FROM pg_timezone_names WHERE name = &#39;Europe/London&#39;;
</code></pre><p>Pin tzdata in your base images, and treat a tzdata bump as a deployment that requires recomputing cached future instants.</p>
<h3 id="part-4-wiring-java-to-postgresql">Part 4: Wiring Java to PostgreSQL</h3>
<h4 id="the-mappingtable">The mapping table</h4>
<p>Per the <a href="https://jdbc.postgresql.org/documentation/query/">pgJDBC documentation</a>:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/image-04.png"></p>
<p>Note the omission, in the driver’s own words:</p>
<blockquote>
<p><em>ZonedDateTime, Instant and OffsetTime / TIME WITH TIME ZONE are not supported.</em></p></blockquote>
<p>So don’t hand the driver an Instant and hope — depending on the driver version you&rsquo;ll either get a Can&rsquo;t infer the SQL type to use for an instance of java.time.Instant exception or undocumented behavior. Convert explicitly at the boundary instead:</p>
<pre tabindex="0"><code>// write
ps.setObject(1, instant.atOffset(ZoneOffset.UTC));
// read
Instant i = rs.getObject(&#34;occurred_at&#34;, OffsetDateTime.class).toInstant();
</code></pre><p>Note that reading a timestamptz gives you an OffsetDateTime normalized to UTC — the driver&rsquo;s offset is always Z, because that&rsquo;s what the backend holds. The offset carries no information. Convert straight to Instant and don&rsquo;t be tempted to read meaning into it.</p>
<h4 id="avoid-settimestamp--gettimestamp">Avoid setTimestamp / getTimestamp</h4>
<p>The java.sql.Timestamp overloads convert using the <strong>JVM default time zone</strong> unless you pass an explicit Calendar. That makes your persistence layer&rsquo;s correctness depend on the container&rsquo;s TZ environment variable. Use the java.time setObject/getObject methods instead — they&rsquo;re unambiguous.</p>
<p>If you must keep the JVM zone from mattering at all, set it explicitly at startup:</p>
<pre tabindex="0"><code>-Duser.timezone=UTC
</code></pre><p>Belt and braces, not a substitute for correct types.</p>
<h4 id="precision-java-has-nanos-postgresql-hasmicros">Precision: Java has nanos, PostgreSQL has micros</h4>
<pre tabindex="0"><code>Instant.parse(&#34;2026-08-15T10:00:00.123456789Z&#34;)           // .123456789
    .truncatedTo(ChronoUnit.MICROS)                        // .123456
</code></pre><p>PostgreSQL stores microseconds. Those last three digits are gone on the round trip, and PostgreSQL may <strong>round</strong> rather than truncate:</p>
<pre tabindex="0"><code>SELECT &#39;2026-08-15 10:00:00.123456789&#39;::timestamptz;  -- 10:00:00.123457
</code></pre><p>If you have an equality test or an optimistic-lock check comparing an in-memory Instant to one that&rsquo;s been through the database, truncate to micros <em>before</em> you persist so both sides agree.</p>
<h4 id="dont-wrap-the-column-in-afunction">Don’t wrap the column in a function</h4>
<p>Because timestamptz is stored as UTC, &ldquo;give me everything that happened on 1 Feb in New York&rdquo; tempts you into this:</p>
<pre tabindex="0"><code>EXPLAIN SELECT count(*) FROM events
WHERE (occurred_at AT TIME ZONE &#39;America/New_York&#39;)::date = DATE &#39;2026-02-01&#39;;
--  Seq Scan on events
</code></pre><p>Index gone. Compute the boundary instants instead and do a half-open range scan:</p>
<pre tabindex="0"><code>SELECT (DATE &#39;2026-02-01&#39; + time &#39;00:00&#39;) AT TIME ZONE &#39;America/New_York&#39;,  -- 2026-02-01 05:00+00
       (DATE &#39;2026-02-02&#39; + time &#39;00:00&#39;) AT TIME ZONE &#39;America/New_York&#39;;  -- 2026-02-02 05:00+00
EXPLAIN SELECT count(*) FROM events
WHERE occurred_at &gt;= &#39;2026-02-01 05:00:00+00&#39;
  AND occurred_at &lt;  &#39;2026-02-02 05:00:00+00&#39;;
--  Index Only Scan using events_occurred_at_idx
</code></pre><p>Note that this “day” is 24 hours only because February has no DST transition. On a transition day it’s 23 or 25 — which the boundary-instant approach handles correctly and a naive + interval &lsquo;24 hours&rsquo; does not. Half-open ranges (&gt;= and &lt;) also dodge the microsecond-boundary problem that BETWEEN creates.</p>
<h3 id="part-5-jpa-and-hibernate">Part 5: JPA and Hibernate</h3>
<p>Hibernate 6 made zoned types a first-class concern via TimeZoneStorageType:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/datetime-handling-in-java-and-postgresql-a-practical-guide/image-05.png"></p>
<p>For PostgreSQL, the practical consequence is settled by the database, not the ORM: timestamptz discards the offset, so under DEFAULT your OffsetDateTime round-trips as UTC. Which is fine — as long as you expected it.</p>
<p>Sensible defaults:</p>
<pre tabindex="0"><code>spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          time_zone: UTC
        timezone:
          default_storage: NORMALIZE_UTC
</code></pre><p>And in the entity, be explicit about intent:</p>
<pre tabindex="0"><code>@Entity
public class Booking {
    
    // A moment. Maps to timestamptz.
    @Column(nullable = false)
    private Instant createdAt;
    
    // A date. Not a moment.
    @Column(nullable = false)
    private LocalDate serviceDate;
    
    // A wall-clock time that must not shift. Maps to timestamp.
    @Column(nullable = false)
    private LocalDateTime localStart;
    
    @Column(nullable = false)
    private String zoneId;   // &#39;Europe/London&#39;
    
    public ZonedDateTime start() {
        return localStart.atZone(ZoneId.of(zoneId));
    }
}
</code></pre><p>If you genuinely need the user’s original offset preserved, @TimeZoneStorage(TimeZoneStorageType.COLUMN) with @TimeZoneColumn writes it to a companion column. Use it when the offset is <em>data</em> — an audit trail of what the user&rsquo;s device claimed — not as a default.</p>
<h3 id="part-6-testing-thisstuff">Part 6: Testing This Stuff</h3>
<p>Datetime bugs are only cheap if you catch them before the clocks change. Three habits do most of the work.</p>
<h4 id="1-inject-aclock">1. Inject a Clock</h4>
<p>Instant.now() scattered through business logic is untestable. Inject java.time.Clock and you can move time at will:</p>
<pre tabindex="0"><code>@Bean Clock clock() { return Clock.systemUTC(); }
// in a test
Clock fixed = Clock.fixed(Instant.parse(&#34;2026-10-25T00:30:00Z&#34;), ZoneOffset.UTC);
</code></pre><h4 id="2-run-ci-in-a-hostile-timezone">2. Run CI in a hostile time zone</h4>
<p>If your JVM, your database, and your CI runner are all UTC, every zone bug hides until production. Force one of them somewhere awkward:</p>
<pre tabindex="0"><code>-Duser.timezone=Pacific/Chatham
</code></pre><p>+12:45 and it observes DST. If your test suite survives Chatham, it will survive most things. Do the same for the PostgreSQL container:</p>
<pre tabindex="0"><code>@Container
static PostgreSQLContainer&lt;?&gt; pg = new PostgreSQLContainer&lt;&gt;(&#34;postgres:16&#34;)
        .withEnv(&#34;TZ&#34;, &#34;Asia/Kathmandu&#34;)
        .withUrlParam(&#34;options&#34;, &#34;-c TimeZone=Asia/Kathmandu&#34;);
</code></pre><p>The point isn’t that production runs there — it’s that a test which only passes in UTC is a test that has proven nothing about zone handling.</p>
<h4 id="3-write-the-four-transition-tests">3. Write the four transition tests</h4>
<p>For any code that converts between local and instant, pin these explicitly:</p>
<pre tabindex="0"><code>static final ZoneId LONDON = ZoneId.of(&#34;Europe/London&#34;);
@Test void gapTimesAreRejectedNotSilentlyShifted() {
    LocalDateTime gap = LocalDateTime.of(2026, 3, 29, 1, 30);
    assertThat(LONDON.getRules().getValidOffsets(gap)).isEmpty();
    assertThatThrownBy(() -&gt; scheduler.schedule(gap, LONDON))
        .isInstanceOf(NonExistentLocalTimeException.class);
}
@Test void overlapTimesResolveToTheDocumentedChoice() {
    LocalDateTime overlap = LocalDateTime.of(2026, 10, 25, 1, 30);
    assertThat(LONDON.getRules().getValidOffsets(overlap)).hasSize(2);
    assertThat(scheduler.resolve(overlap, LONDON))
        .isEqualTo(Instant.parse(&#34;2026-10-25T00:30:00Z&#34;)); // earlier, by policy
}
@Test void dailyRecurrenceKeepsWallClockAcrossDst() {
    ZonedDateTime start = ZonedDateTime.of(2026, 3, 28, 9, 0, 0, 0, LONDON);
    assertThat(recurrence.next(start).toLocalTime()).isEqualTo(LocalTime.of(9, 0));
}
@Test void instantSurvivesTheDatabaseRoundTrip() {
    Instant original = Instant.now().truncatedTo(ChronoUnit.MICROS);
    repository.save(new Event(original));
    assertThat(repository.findLatest().occurredAt()).isEqualTo(original);
}
</code></pre><p>The fourth one catches more real bugs than you’d expect — it’s the one that fails when someone quietly changes a column from timestamptz to timestamp.</p>
<h3 id="the-shortversion">The Short Version</h3>
<p>If you skim one section, make it this one.</p>
<p><strong>Schema</strong></p>
<ul>
<li>timestamptz by default. It&rsquo;s the same 8 bytes and it&rsquo;s unambiguous.</li>
<li>timestamp only for wall-clock times that must not shift — and store the zone ID beside it.</li>
<li>date for dates. timetz for nothing.</li>
<li>Pin timezone = &lsquo;UTC&rsquo; in postgresql.conf and in the JDBC URL.</li>
</ul>
<p><strong>Java</strong></p>
<ul>
<li>Instant for things that happened. LocalDate for dates. ZonedDateTime for scheduling. OffsetDateTime at the JDBC boundary.</li>
<li>LocalDateTime is not a point in time. Treat every use as a question to answer, not an answer.</li>
<li>Never accept zone abbreviations. Full IANA names only.</li>
<li>plusDays ≠ plus(Duration.ofDays(1)). Know which one your product means.</li>
</ul>
<p><strong>The seam</strong></p>
<ul>
<li>pgJDBC speaks LocalDate/LocalTime/LocalDateTime/OffsetDateTime. Convert Instant explicitly.</li>
<li>Avoid setTimestamp/getTimestamp — they use the JVM default zone.</li>
<li>Truncate to microseconds before persisting.</li>
<li>Don’t wrap indexed timestamp columns in AT TIME ZONE; compute boundary instants instead.</li>
</ul>
<p><strong>And the one that bites hardest</strong></p>
<p>Java resolves an ambiguous local time to the <em>earlier</em> offset. PostgreSQL picks the <em>later</em> one. Resolve ambiguity in exactly one place, deliberately, and pass instants across the boundary — never local times.</p>
<p>Store the instant, render the local time, and keep the zone rules in exactly one layer. Most datetime bugs are just that rule, broken somewhere.</p>
<p><em>All examples verified on OpenJDK 21.0.10 (tzdata 2025b) and PostgreSQL 16.13.</em></p>
<p><strong>Sources</strong></p>
<ul>
<li><a href="https://www.postgresql.org/docs/current/datatype-datetime.html">PostgreSQL: Date/Time Types</a></li>
<li><a href="https://jdbc.postgresql.org/documentation/query/">pgJDBC: Using Java 8 Date and Time classes</a></li>
<li><a href="https://docs.hibernate.org/orm/6.4/javadocs/org/hibernate/annotations/TimeZoneStorageType.html">Hibernate</a> <a href="https://docs.hibernate.org/orm/6.4/javadocs/org/hibernate/annotations/TimeZoneStorageType.html">TimeZoneStorageType Javadoc</a></li>
<li><a href="https://docs.hibernate.org/orm/6.2/migration-guide/">Hibernate 6 migration guide</a></li>
<li><a href="https://thorben-janssen.com/hibernate-6-offsetdatetime-and-zoneddatetime/">Thorben Janssen: TimezoneStorageType</a></li>
</ul>
]]></content:encoded></item><item><title>From Source to Running JVM: How Gradle Builds Your Java Application</title><link>https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/</link><pubDate>Sun, 19 Jul 2026 11:14:16 +0000</pubDate><guid>https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/</guid><description>&lt;p>Build phases, task graphs, dependency resolution, and what actually happens when the JVM opens your JAR&lt;/p>
&lt;p>You type gradle build, wait a few seconds, and out comes a JAR. Most Java developers use this pipeline every day without ever looking inside it. But when a build gets slow, a dependency conflict appears, or a NoClassDefFoundError shows up in production at 2 AM, understanding the machinery underneath is the difference between guessing and fixing.&lt;/p></description><content:encoded><![CDATA[<p>Build phases, task graphs, dependency resolution, and what actually happens when the JVM opens your JAR</p>
<p>You type gradle build, wait a few seconds, and out comes a JAR. Most Java developers use this pipeline every day without ever looking inside it. But when a build gets slow, a dependency conflict appears, or a NoClassDefFoundError shows up in production at 2 AM, understanding the machinery underneath is the difference between guessing and fixing.</p>
<p>This article walks the full journey: Gradle’s three build phases, the Java plugin’s task graph, how dependency resolution actually works stage by stage, what a JAR file really is, and how the JVM reads and executes it.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-01.png"></p>
<h3 id="part-1-the-three-phases-every-gradle-build-goesthrough">Part 1: The Three Phases Every Gradle Build Goes Through</h3>
<p>Before a single line of your code is compiled, Gradle itself runs through a fixed lifecycle of three phases. This is true for every build, every plugin, every project.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-02.png"></p>
<h4 id="phase-1initialization">Phase 1 — Initialization</h4>
<p>Gradle reads settings.gradle(.kts) and answers one question: <em>which projects are part of this build?</em> For a single-module app that&rsquo;s trivial; for a multi-module monorepo this is where include(&quot;:core&quot;, &ldquo;:api&rdquo;, &ldquo;:app&rdquo;) gets evaluated and a Project object is created for each module.</p>
<h4 id="phase-2configuration">Phase 2 — Configuration</h4>
<p>Now every build.gradle(.kts) script executes top to bottom. This is the phase people misunderstand most. Your build script is <em>code that runs</em> — and it runs here, on every invocation, regardless of which task you asked for. The output of this phase is the <strong>task graph</strong>: a directed acyclic graph (DAG) of all tasks and their dependencies. Tasks are registered and wired together, but none of their actions execute yet.</p>
<blockquote>
<p><em>A</em> <em>println at the top level of build.gradle prints during Configuration — even if you run</em> <em>gradle help. Task actions run later. This trips up nearly everyone once.</em></p></blockquote>
<h4 id="phase-3execution">Phase 3 — Execution</h4>
<p>Gradle takes the task you requested, computes the minimal subgraph of tasks needed to produce it, and executes them in dependency order. Tasks whose inputs and outputs haven’t changed since the last run are marked UP-TO-DATE and skipped entirely — this is Gradle&rsquo;s incremental build engine, and it&rsquo;s why the second build is always faster than the first.</p>
<h3 id="part-2-the-java-plugin-taskgraph">Part 2: The Java Plugin Task Graph</h3>
<p>Applying the java plugin registers a standard chain of tasks. When you run gradle build, this is the graph that gets executed:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-03.png"></p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-04.png"></p>
<p>The practical shortcuts worth memorizing:</p>
<pre tabindex="0"><code># fastest feedback: compile only
gradle compileJava
# package without running tests
gradle jar
# full build, skip test execution
gradle build -x test
# nuke build/ and start clean
gradle clean build
</code></pre><h3 id="part-3-how-gradle-dependency-resolution-actuallyworks">Part 3: How Gradle Dependency Resolution Actually Works</h3>
<p>This is the part of Gradle most developers treat as magic. You write three lines in a dependencies block, and somehow the right JARs appear on your classpath. Here&rsquo;s the machinery, stage by stage.</p>
<h4 id="configurations-buckets-of-dependencies">Configurations: buckets of dependencies</h4>
<p>Every dependency you declare goes into a named <strong>configuration</strong> — a bucket with specific rules about where those dependencies are visible:</p>
<pre tabindex="0"><code>dependencies {
    implementation &#39;org.springframework.boot:spring-boot-starter-web:3.3.1&#39;
    api &#39;com.fasterxml.jackson.core:jackson-databind:2.17.1&#39;
    compileOnly &#39;org.projectlombok:lombok:1.18.32&#39;
    annotationProcessor &#39;org.projectlombok:lombok:1.18.32&#39;
    runtimeOnly &#39;org.postgresql:postgresql:42.7.3&#39;
    testImplementation &#39;org.junit.jupiter:junit-jupiter:5.10.2&#39;
}
</code></pre><p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-05.png"></p>
<p>The implementation vs api distinction matters in multi-module builds: implementation dependencies don&rsquo;t appear on the compile classpath of modules that depend on yours. That means fewer recompilations when they change, and a cleaner API boundary. Default to implementation; reach for api only when a type from that dependency appears in your public method signatures.</p>
<h4 id="the-resolution-stages">The resolution stages</h4>
<p>Declared dependencies are just coordinates on paper. Resolution is the process of turning them into actual files, and it happens <strong>lazily</strong> — only when a task first needs a classpath (typically compileJava during Execution). The stages:</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-06.png"></p>
<p><strong>Stage 1 — Graph construction.</strong> For each declared coordinate (group:name:version), Gradle queries your repositories <em>in the order they&rsquo;re declared</em> and downloads only the <em>metadata</em> first: the POM file or Gradle Module Metadata. That metadata lists the dependency&rsquo;s own dependencies, so Gradle recursively walks outward until the full transitive graph is known. No JARs have been downloaded yet — just descriptors.</p>
<p><strong>Stage 2 — Conflict resolution.</strong> Real dependency graphs always contain the same library requested at different versions (your app wants Jackson 2.17, some transitive path wants 2.15). Gradle’s default strategy is <em>highest version wins</em> — unlike Maven, which picks the “nearest” declaration in the tree. This default is usually right, but you can override it:</p>
<pre tabindex="0"><code>configurations.all {
    resolutionStrategy {
        // hard-pin a version, overriding everything
        force &#39;com.fasterxml.jackson.core:jackson-databind:2.17.1&#39;
        // or turn conflicts into build failures
        failOnVersionConflict()
    }
}
</code></pre><p><strong>Stage 3 — Constraints and rules.</strong> Before the graph is finalized, Gradle applies platforms (BOMs), version constraints, exclusions, and dependency substitutions. This is how Spring Boot keeps hundreds of libraries version-aligned with one line:</p>
<pre tabindex="0"><code>dependencies {
    implementation platform(&#39;org.springframework.boot:spring-boot-dependencies:3.3.1&#39;)
    // now version numbers can be omitted — the BOM supplies them
    implementation &#39;org.springframework.boot:spring-boot-starter-web&#39;
}
</code></pre><p>Exclusions cut unwanted transitive branches at the declaration site:</p>
<pre tabindex="0"><code>implementation(&#39;org.springframework.boot:spring-boot-starter-web&#39;) {
    exclude group: &#39;org.springframework.boot&#39;, module: &#39;spring-boot-starter-tomcat&#39;
}
</code></pre><p><strong>Stage 4 — Artifact download.</strong> Only now, with the final graph settled, does Gradle download the actual JAR files — and only for modules that survived resolution. Everything lands in the shared cache at ~/.gradle/caches/modules-2, keyed by checksum, shared across all projects on your machine. A dependency is downloaded once, ever, per machine (dynamic versions like 1.+ and snapshots get re-checked periodically).</p>
<p><strong>Stage 5 — Classpath assembly.</strong> The resolved files are ordered into a classpath and handed to whichever task requested them: compileClasspath to javac, runtimeClasspath to the test JVM, and so on.</p>
<h4 id="debugging-resolution">Debugging resolution</h4>
<p>Two commands answer 90% of “why is this version on my classpath” questions:</p>
<pre tabindex="0"><code># full resolved tree per configuration
gradle dependencies --configuration runtimeClasspath
# trace one library: who pulls it in, which versions were requested,
# and why the winner won
gradle dependencyInsight --dependency jackson-databind
</code></pre><h3 id="part-4-what-a-jar-file-reallyis">Part 4: What a JAR File Really Is</h3>
<p>The output of the jar task is nothing exotic — it&rsquo;s a ZIP archive with an agreed-upon internal layout. Rename it to .zip and any archive tool will open it:</p>
<pre tabindex="0"><code>myapp.jar
├── META-INF/
│   └── MANIFEST.MF          ← metadata
├── com/example/App.class     ← bytecode, path = package name
├── com/example/util/Helper.class
└── application.properties    ← resources sit alongside classes
</code></pre><p>The manifest is a plain-text key-value file. The line that matters most for a runnable JAR:</p>
<pre tabindex="0"><code>Manifest-Version: 1.0
Main-Class: com.example.App
</code></pre><p>One important caveat: the plain jar task packages <em>only your classes</em>. Your dependencies are not inside. For a self-contained deployable artifact you need a fat JAR — with the Shadow plugin, or Spring Boot&rsquo;s bootJar, which nests dependency JARs and adds its own launcher.</p>
<pre tabindex="0"><code>plugins {
    id &#39;com.github.johnrengelman.shadow&#39; version &#39;8.1.1&#39;
}
// gradle shadowJar → one JAR containing everything
</code></pre><h3 id="part-5-how-the-jvm-reads-yourjar">Part 5: How the JVM Reads Your JAR</h3>
<p>You run java -jar myapp.jar. Here&rsquo;s the sequence, and it&rsquo;s more interesting than it looks.</p>
<h4 id="step-1the-launcher-reads-themanifest">Step 1 — The launcher reads the manifest</h4>
<p>The java launcher opens the JAR as a ZIP, reads META-INF/MANIFEST.MF, and extracts Main-Class. Note a classic gotcha: with -jar, any -cp argument on the command line is <strong>silently ignored</strong>. The JAR itself (plus manifest Class-Path entries) becomes the entire application classpath.</p>
<h4 id="step-2the-classloader-hierarchy">Step 2 — The classloader hierarchy</h4>
<p>Classes are loaded by a chain of classloaders using <strong>parent-first delegation</strong>: every load request travels up to the top of the chain first, and only comes back down if no ancestor can satisfy it.</p>
<p><img loading="lazy" src="https://sukachvitally.github.io/posts/from-source-to-running-jvm-how-gradle-builds-your-java-application/image-07.png"></p>
<h4 id="step-3lazyloading">Step 3 — Lazy loading</h4>
<p>The JVM does <strong>not</strong> load every class in the JAR at startup. A class is loaded the first time it’s actively used — instantiated, statically referenced, reflected upon. This is why a missing transitive dependency can let your app boot cleanly and then blow up with NoClassDefFoundError twenty minutes later, the first time a rarely-used code path runs.</p>
<h4 id="step-4load-link-initialize">Step 4 — Load, Link, Initialize</h4>
<p>Per the JVM specification, every class goes through three phases before it’s usable:</p>
<ol>
<li><strong>Loading</strong> — the class bytes are read from the JAR, parsed, and a Class object is created in metaspace.</li>
<li><strong>Linking</strong> — three sub-steps:
<ul>
<li>Verification: the bytecode is checked for structural and type safety (stack shapes, valid jumps, access rules).</li>
<li>Preparation: static fields are allocated and set to defaults — 0, null, false. Not your values yet.</li>
<li>Resolution: symbolic references to other classes, methods, and fields are resolved to concrete ones (often lazily).</li>
</ul>
</li>
<li><strong>Initialization</strong> — static initializer blocks and static field assignments run, in source order, exactly once, on first active use. This is the point where static { &hellip; } blocks fire.</li>
</ol>
<h3 id="step-5main-and-thejit">Step 5 — main() and the JIT</h3>
<p>Once Main-Class is loaded, linked, and initialized, the JVM invokes main(String[] args) on the main thread. Execution starts in the interpreter. As methods get hot — called thousands of times — the JIT compiler (tiered: C1 first for fast, lightly-optimized code, then C2 for aggressively optimized code) compiles them to native machine code. That&rsquo;s why JVM applications famously get <em>faster</em> after warm-up.</p>
<h3 id="the-wholepicture">The Whole Picture</h3>
<pre tabindex="0"><code>src/main/java  ──javac──▶  build/classes  ──jar──▶  myapp.jar
                                                        │
                                             java -jar myapp.jar
                                                        │
                       read MANIFEST.MF → find Main-Class
                                                        │
                 classloader delegation (parent-first)
                                                        │
                 Load → Link (verify/prepare/resolve) → Init
                                                        │
                            main() runs, classes load lazily,
                            JIT compiles hot paths as you go
</code></pre><p>None of this is knowledge you need daily. But the day your build takes four minutes instead of forty seconds, or two versions of the same library fight on your classpath, or a class mysteriously fails to load in production — this is the map you’ll be reading. Better to have seen it before that day arrives.</p>
]]></content:encoded></item></channel></rss>