Java CI/CD: It’s More Than Just YAML Files

I still have nightmares about Friday afternoon deployments from 2015. You know the drill. Build the WAR file locally (because the build server was “acting weird”), SCP it over to the production Tomcat server, restart the service, and pray to the gods of bytecode that the database connection string wasn’t pointing to localhost.

It usually was.

We’ve come a long way since then. But here’s the thing that bugs me: a lot of Java developers still treat CI/CD as “ops stuff.” They think it’s just a bunch of YAML files in a .github folder that the DevOps team manages.

That’s wrong.

If you want a pipeline that actually catches bugs before they wake you up at 3 AM, you have to write your Java code for the pipeline. It starts with OOP fundamentals. If your code is a tangled mess of static methods and tight coupling, no amount of Jenkins plugins or GitHub Actions magic is going to save you. You can’t automate a mess; you just get a faster mess.

The Foundation: Interfaces Make Pipelines Possible

CI/CD relies heavily on testing. If you can’t unit test it easily, you can’t CI it. This is where basic interfaces save your bacon. When I’m building a service now, I’m constantly thinking, “How do I mock this in the pipeline?”

Let’s look at a practical example. Say we have a service that needs to process high-value transactions. If I hardcode the database logic right into the method, I need a live database just to run a unit test. That’s slow. Slow tests mean slow pipelines. Slow pipelines mean developers stop running them.

Here’s how I structure it using a clean Interface and Java Streams for processing:

Java programming code screen - Software developer java programming html web code. abstract ...
Java programming code screen – Software developer java programming html web code. abstract …
package com.production.core;

import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;

// The contract. This makes mocking in CI trivial.
public interface TransactionProcessor {
    List<Transaction> filterHighValue(List<Transaction> transactions);
}

// The domain object
record Transaction(String id, BigDecimal amount, String status) {}

// The implementation we want to test
public class StreamTransactionProcessor implements TransactionProcessor {

    private static final BigDecimal THRESHOLD = new BigDecimal("10000");

    @Override
    public List<Transaction> filterHighValue(List<Transaction> transactions) {
        if (transactions == null || transactions.isEmpty()) {
            return List.of();
        }

        // Java Streams: Clean, parallelizable, and easy to debug
        return transactions.stream()
                .filter(t -> "PENDING".equals(t.status()))
                .filter(t -> t.amount().compareTo(THRESHOLD) > 0)
                .collect(Collectors.toList());
    }
}

See that? No database dependencies. No external API calls. Just pure logic. This runs in milliseconds in a CI environment.

The Gatekeeper: Unit Tests That Don’t Suck

The first step in any decent CI pipeline (after the compile stage, obviously) is the unit test suite.

I’ve seen pipelines that take 45 minutes because someone decided to spin up a full Spring Context for every single assertion. Don’t be that person. Your feedback loop needs to be tight. If I push code, I want to know if I broke the logic within two minutes, tops.

Here is the corresponding test for our processor. It uses JUnit 5. It’s fast, readable, and ensures our stream logic holds up:

package com.production.core;

import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class StreamTransactionProcessorTest {

    private final TransactionProcessor processor = new StreamTransactionProcessor();

    @Test
    void shouldReturnOnlyHighValuePendingTransactions() {
        // Setup - messy real world data
        var t1 = new Transaction("1", new BigDecimal("500"), "PENDING"); // Too low
        var t2 = new Transaction("2", new BigDecimal("15000"), "APPROVED"); // Wrong status
        var t3 = new Transaction("3", new BigDecimal("20000"), "PENDING"); // Target

        var input = List.of(t1, t2, t3);

        // Execute
        List<Transaction> result = processor.filterHighValue(input);

        // Verify
        assertEquals(1, result.size(), "Should filter down to one transaction");
        assertEquals("3", result.get(0).id());
    }
}

This test is the bedrock of CI. It runs instantly. If this fails, the pipeline stops immediately, saving everyone time.

The Heavy Lifting: Integration with Testcontainers

Unit tests are great, but they lie. They tell you your logic is sound, but they won’t tell you that your SQL query syntax is invalid for the specific version of PostgreSQL you’re running in production.

Back in the day, we used H2 (an in-memory database) for tests. It was… okay. But H2 isn’t Postgres. I wasted days debugging issues that only existed because H2 behaves differently than a real DB.

Java programming code screen - Software developer java programming html web code. abstract ...
Java programming code screen – Software developer java programming html web code. abstract …

Enter Testcontainers. If you aren’t using this in your Java CI/CD pipeline yet, stop reading and go set it up. It spins up real Docker containers for your dependencies during the test phase. It ensures that “it works on my machine” actually means something.

Here’s how a repository test looks when you’re serious about CI stability:

@Testcontainers
@SpringBootTest
class TransactionRepositoryTest {

    // Spins up a real Postgres container for the duration of this test
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private TransactionRepository repository;

    @Test
    void shouldPersistAndRetrieve() {
        var transaction = new TransactionEntity("tx_99", new BigDecimal("50000"));
        
        repository.save(transaction);
        
        var found = repository.findById("tx_99");
        assertTrue(found.isPresent());
    }
}

When your CI pipeline runs this, it actually pulls a Docker image, starts the DB, runs the test, and tears it down. It’s heavier than a unit test, sure, but it catches the integration bugs that usually explode on deployment day.

The Pipeline Configuration (The “Ops” Part)

Once you have solid Java code and tests, the actual pipeline configuration becomes boring. And boring is good. Whether you use GitHub Actions, GitLab CI, or Jenkins, the pattern is the same:

Java programming code screen - How Java Works | HowStuffWorks
Java programming code screen – How Java Works | HowStuffWorks
  1. Checkout code.
  2. Setup Java (JDK 21 or whatever LTS you’re on).
  3. Cache dependencies (Gradle/Maven) so you aren’t downloading half the internet every run.
  4. Run Tests (./gradlew check). This runs both the unit tests and the Testcontainers integration tests.
  5. Build Artifact (./gradlew bootJar).
  6. Push to Registry.

Here is a snippet of what the “Build” step looks like in a typical GitHub Actions workflow. Notice how simple it is? That’s because the complexity is handled inside the Java project (Gradle wrapper), not the YAML.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'gradle'

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Build with Gradle
        # This runs our unit tests AND integration tests
        run: ./gradlew build

Why This Matters

I worked on a project last year where the “CI” was just a script that compiled the code. No tests ran. Deployment was manual. The team was terrified to refactor anything because they had no safety net. They were stuck in a loop of “fix one bug, create two more.”

By shifting to a proper CI/CD mindset—starting with the Java code itself—we changed the culture. We wrote interfaces so we could test components in isolation. We used Testcontainers so we could trust our SQL. We automated the deployment so nobody had to log into a server at 5 PM on a Friday.

It’s not about using the flashiest new tools. It’s about sleep insurance. When I see that green checkmark on the pipeline, I know the streams logic works, I know the database queries are valid, and I know the application starts up. That means I can close my laptop and actually enjoy my weekend.