Skip to main content

Spring Boot with bleep

This tutorial walks through a working Spring Boot 3.x service that compiles, tests, runs in dev or prod mode, and packages to an executable fat JAR — all through bleep. The example uses Spring Boot Web, Spring Boot JDBC, Spring Boot Actuator, and DuckDB as an in-memory database. It also wires per-environment configuration through scripts, which is the recommended pattern.

If you want the argument for why this pattern is the right one, see Spring Boot proves the model. This page is the how-to.

What you'll end up with

  • One main project (myapp) with a controller, service, repository, and a couple of REST endpoints.
  • One test project (myapp-test) with a Spring @SpringBootTest slice and a MockMvc test.
  • Three named scripts in bleep.yaml:
    • run-myapp-dev — dev profile, 512 MB heap, port 9090, live resource edits.
    • run-myapp-prod — prod profile, 2 GB heap, G1GC tuning, port 9091.
    • package-myapp — produces a layered executable fat JAR.
  • META-INF/build-info.properties auto-generated as sourcegen so Spring Boot Actuator's /actuator/info endpoint returns build metadata.

The full source lives at docs-snippets-from-tests/spring-boot-myapp and is included throughout this page via <Snippet> blocks.

Prerequisites

Bleep installed. That's it — bleep downloads the JVM specified in bleep.yaml on first use. The example uses DuckDB embedded; no Docker, no external services.

The build file

$schema: https://raw.githubusercontent.com/oyvindberg/bleep/master/schema.json
$version: 1.0.0-M9
jvm:
name: graalvm-community:25.0.1
projects:
myapp:
dependencies:
- org.springframework.boot:spring-boot-starter-web:3.4.0
- org.springframework.boot:spring-boot-starter-jdbc:3.4.0
- org.springframework.boot:spring-boot-starter-actuator:3.4.0
- org.duckdb:duckdb_jdbc:1.1.3
extends:
- template-common
platform:
mainClass: com.example.myapp.MyApp
sourcegen:
- main: bleep.plugin.springboot.SpringBootBuildInfo
project: scripts
myapp-test:
dependencies:
- org.springframework.boot:spring-boot-starter-test:3.4.0
dependsOn: myapp
extends:
- template-common
isTestProject: true
scripts:
dependencies:
- build.bleep:bleep-plugin-spring-boot:${BLEEP_VERSION}
java:
options: -proc:none --release 17
platform:
name: jvm
scripts:
run-myapp-dev:
main: scripts.RunMyappDev
project: scripts
run-myapp-prod:
main: scripts.RunMyappProd
project: scripts
package-myapp:
main: scripts.PackageMyapp
project: scripts
templates:
template-common:
java:
options: -proc:none --release 17
platform:
name: jvm

Three projects (myapp, myapp-test, scripts), three named scripts, one template that just sets the JVM target. That is the entire build configuration. There is no <plugin> block; the Spring Boot machinery lives in the scripts project as a regular dependency.

Worth pointing out:

  • myapp dependencies. Plain Maven coordinates pinned to the same Spring Boot version. Bleep does not do BOM-style version inheritance yet (status); when every starter you pull in is on the same Spring Boot version, the transitive graph stays consistent.
  • myapp.sourcegen references bleep.plugin.springboot.SpringBootBuildInfo directly. The plugin ships a BleepCodegenScript you can use as-is — no wrapper class needed for the default case.
  • myapp-test has isTestProject: true and depends on myapp. There is no Test/test/itTest/Compile scope dance; the test project is just another project that picks up myapp's classes and adds the Spring Boot test starter.
  • scripts.dependencies pulls in build.bleep:bleep-plugin-spring-boot which provides the three SpringBoot* library classes used by the script files.

The application code

Plain Spring Boot beans wired by constructor injection. Nothing bleep-specific. Click to expand any of them.

The main class — MyApp.java
package com.example.myapp;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MyApp {
public static void main(String[] args) {
SpringApplication.run(MyApp.class, args);
}
}
The Item record
package com.example.myapp;

public record Item(Long id, String name, int quantity) {}
The repository (JdbcTemplate-backed, talking to DuckDB)
package com.example.myapp;

import java.util.List;
import java.util.Optional;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;

@Repository
public class ItemRepository {
private static final RowMapper<Item> ROW =
(rs, n) -> new Item(rs.getLong("id"), rs.getString("name"), rs.getInt("quantity"));

private final JdbcTemplate jdbc;

public ItemRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}

public List<Item> findAll() {
return jdbc.query("SELECT id, name, quantity FROM items ORDER BY id", ROW);
}

public Optional<Item> findById(long id) {
return jdbc.query("SELECT id, name, quantity FROM items WHERE id = ?", ROW, id).stream()
.findFirst();
}

public Item insert(String name, int quantity) {
Long id =
jdbc.queryForObject(
"INSERT INTO items (name, quantity) VALUES (?, ?) RETURNING id",
Long.class,
name,
quantity);
return new Item(id, name, quantity);
}

public Item update(Item item) {
jdbc.update(
"UPDATE items SET name = ?, quantity = ? WHERE id = ?",
item.name(),
item.quantity(),
item.id());
return item;
}
}
The service (validation, simple state changes)
package com.example.myapp;

import java.util.List;
import org.springframework.stereotype.Service;

@Service
public class ItemService {
private final ItemRepository repo;

public ItemService(ItemRepository repo) {
this.repo = repo;
}

public List<Item> all() {
return repo.findAll();
}

public Item create(String name, int quantity) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name is required");
}
if (quantity < 0) {
throw new IllegalArgumentException("quantity must be non-negative");
}
return repo.insert(name, quantity);
}

public Item adjust(long id, int delta) {
Item current =
repo.findById(id).orElseThrow(() -> new IllegalArgumentException("no item " + id));
int next = current.quantity() + delta;
if (next < 0) {
throw new IllegalArgumentException("adjust would push quantity below zero");
}
return repo.update(new Item(current.id(), current.name(), next));
}
}
The controller (REST + error handler)
package com.example.myapp;

import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/items")
public class ItemController {
private final ItemService service;

public ItemController(ItemService service) {
this.service = service;
}

@GetMapping
public List<Item> all() {
return service.all();
}

@PostMapping
public ResponseEntity<Item> create(@RequestBody CreateRequest req) {
return ResponseEntity.ok(service.create(req.name(), req.quantity()));
}

@PostMapping("/{id}/adjust")
public ResponseEntity<Item> adjust(@PathVariable long id, @RequestBody AdjustRequest req) {
return ResponseEntity.ok(service.adjust(id, req.delta()));
}

@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<String> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}

public record CreateRequest(String name, int quantity) {}

public record AdjustRequest(int delta) {}
}

The properties files

Base application.properties exposes the server port as a ${APP_PORT} lever (default 9090); the scripts below set it explicitly. The schema.sql initializes the items table on startup. Profile-specific files override logging levels.

spring.application.name=myapp
# Port: default 9090, override via APP_PORT env var or -Dserver.port=...
# Per-profile overrides go in application-<profile>.properties.
server.port=${APP_PORT:9090}
spring.datasource.url=jdbc:duckdb:
spring.datasource.driver-class-name=org.duckdb.DuckDBDriver
spring.sql.init.mode=always
spring.sql.init.schema-locations=classpath:schema.sql
management.endpoints.web.exposure.include=info,health
schema.sql
CREATE SEQUENCE IF NOT EXISTS items_id_seq;
CREATE TABLE IF NOT EXISTS items (
id BIGINT PRIMARY KEY DEFAULT nextval('items_id_seq'),
name VARCHAR NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity >= 0)
);
application-dev.properties
logging.level.com.example=DEBUG
logging.level.org.springframework.jdbc.core.JdbcTemplate=DEBUG
application-prod.properties
logging.level.root=WARN
logging.level.com.example=INFO
application-test.properties (test classpath)
logging.level.com.example=DEBUG
spring.main.banner-mode=off

The scripts

Each script hardcodes the parameters you actually care about for one combination. Read it and you know what runs. These three stay open because they are the manifesto point: per-environment configuration lives in plain Java, not in YAML config blocks.

scripts/RunMyappDev.java:

package scripts;

import bleep.plugin.springboot.SpringBootRun;
import bleepscript.BleepScript;
import bleepscript.Commands;
import bleepscript.Started;
import java.util.List;

/** Run myapp in dev mode: dev profile, smaller heap, live resource edits. */
public class RunMyappDev extends BleepScript {
public RunMyappDev() {
super("run-myapp-dev");
}

@Override
public void run(Started started, Commands commands, List<String> args) {
new SpringBootRun()
.withJvmArgs("-Xmx512m")
.withProfiles("dev")
.withAddResources(true)
.withEnvironment("APP_PORT", "9090")
.runOn(started, commands, "myapp");
}
}

scripts/RunMyappProd.java:

package scripts;

import bleep.plugin.springboot.SpringBootRun;
import bleepscript.BleepScript;
import bleepscript.Commands;
import bleepscript.Started;
import java.util.List;

/** Run myapp in prod mode: prod profile, larger heap, G1GC tuning. */
public class RunMyappProd extends BleepScript {
public RunMyappProd() {
super("run-myapp-prod");
}

@Override
public void run(Started started, Commands commands, List<String> args) {
new SpringBootRun()
.withJvmArgs("-Xmx2g", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=200")
.withProfiles("prod")
.withEnvironment("APP_PORT", "9091")
.runOn(started, commands, "myapp");
}
}

scripts/PackageMyapp.java:

package scripts;

import bleep.plugin.springboot.SpringBootRepackage;
import bleepscript.BleepScript;
import bleepscript.Commands;
import bleepscript.Started;
import java.util.List;

/** Build an executable fat JAR with layered Docker support. */
public class PackageMyapp extends BleepScript {
public PackageMyapp() {
super("package-myapp");
}

@Override
public void run(Started started, Commands commands, List<String> args) {
new SpringBootRepackage().withLayered(true).repackageOn(started, commands, "myapp");
}
}

The tests

Standard JUnit 5 + Spring Boot test slices. ItemServiceTest hits the real (in-memory) DuckDB through the wired beans; ItemControllerTest uses MockMvc to exercise the controller without binding a port.

ItemServiceTest.java
package com.example.myapp;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

@SpringBootTest
@ActiveProfiles("test")
class ItemServiceTest {
@Autowired ItemService service;

@Test
void createsAndListsItems() {
Item bread = service.create("bread", 5);
assertNotNull(bread.id());
assertEquals("bread", bread.name());
assertEquals(5, bread.quantity());

Item milk = service.create("milk", 2);

List<Item> all = service.all();
assertTrue(all.contains(bread));
assertTrue(all.contains(milk));
}

@Test
void adjustsQuantity() {
Item it = service.create("water", 10);
Item after = service.adjust(it.id(), -3);
assertEquals(7, after.quantity());
}

@Test
void rejectsNegativeQuantityOnAdjust() {
Item it = service.create("egg", 1);
assertThrows(IllegalArgumentException.class, () -> service.adjust(it.id(), -2));
}

@Test
void rejectsBlankName() {
assertThrows(IllegalArgumentException.class, () -> service.create(" ", 1));
}
}
ItemControllerTest.java
package com.example.myapp;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ItemControllerTest {
@Autowired MockMvc mvc;

@Test
void postCreatesAndGetListsItem() throws Exception {
mvc.perform(
post("/items")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"apple\",\"quantity\":12}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("apple"))
.andExpect(jsonPath("$.quantity").value(12));

mvc.perform(get("/items"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[?(@.name=='apple')].quantity").value(12));
}

@Test
void invalidPostReturnsBadRequest() throws Exception {
mvc.perform(
post("/items")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"\",\"quantity\":1}"))
.andExpect(status().isBadRequest());
}
}

Running it

Compile the main project (this also runs the build-info sourcegen):

bleep compile myapp

Run the tests:

bleep test myapp-test

You should see two suites, six tests, all green.

Run the app in dev mode:

bleep run-myapp-dev

The script forks a JVM with the dev profile, 512 MB heap, addResources=true so changes to myapp/src/resources/ are live without rebuild, and APP_PORT=9090. The app exposes:

curl http://localhost:9090/items
curl -X POST -H 'Content-Type: application/json' \
-d '{"name":"bread","quantity":5}' http://localhost:9090/items
curl http://localhost:9090/actuator/info

The /actuator/info endpoint returns the contents of META-INF/build-info.properties, which the sourcegen wrote at compile time.

Run in prod mode on a different port:

bleep run-myapp-prod

That picks up the prod profile, the heap and GC tuning from RunMyappProd, and binds to port 9091. Different script, different config, both visible in plain Java.

Build the executable fat JAR:

bleep package-myapp
java -jar build/myapp/myapp-boot.jar

build/myapp/myapp-boot.jar is a regular Spring Boot fat JAR with the standard BOOT-INF/ layout, the loader classes at root, and Spring-Boot-Version: 3.x.x in the manifest. You can deploy it anywhere you would deploy a Maven-built Spring Boot artifact. It also includes a layers.idx because PackageMyapp.java sets withLayered(true), so it works directly with Docker layered builds.

Adding a new run combination

This is where the pattern pays off. Say you want a staging mode: prod profile, dev heap size, a specific port, a Java agent for tracing.

Add a RunMyappStaging.java next to the others:

package scripts;

import bleep.plugin.springboot.SpringBootRun;
import bleepscript.BleepScript;
import bleepscript.Commands;
import bleepscript.Started;
import java.nio.file.Paths;
import java.util.List;

public class RunMyappStaging extends BleepScript {
public RunMyappStaging() { super("run-myapp-staging"); }

@Override
public void run(Started started, Commands commands, List<String> args) {
new SpringBootRun()
.withJvmArgs("-Xmx512m")
.withProfiles("prod")
.withEnvironment("APP_PORT", "9100")
.withAgents(Paths.get("/opt/tracing/agent.jar"))
.runOn(started, commands, "myapp");
}
}

Register it in bleep.yaml:

scripts:
run-myapp-staging:
main: scripts.RunMyappStaging
project: scripts

bleep run-myapp-staging now works. The class is twelve lines of plain Java. Anyone reading it knows exactly what it runs.

Adding a knob the library doesn't expose

bleep-plugin-spring-boot's SpringBootRun exposes the parameters most teams need (JVM args, profiles, agents, system properties, environment, working directory). If you want a parameter that isn't there, you have two options that don't require touching the published plugin:

  1. Set it directly via withEnvironment or withSystemProperty. Spring Boot reads almost everything through one of those.
  2. Copy SpringBootRun.java into your own scripts project, rename it, add the fluent setter you want, and register it as a new script main. The class is about 200 lines of straight Java; reading and adapting it takes minutes.

This is one of the reasons scripts live in your repo: when a published library doesn't expose something you need, you copy the relevant part and adjust it. No fork, no upstream PR, no new published artifact.

What's not in this tutorial

A few things deliberately left out, in roughly increasing order of how much they would expand the example:

  • build-image for Buildpacks-based OCI image production. The plugin can be extended to wrap spring-boot-buildpack-platform the same way it wraps spring-boot-loader-tools; not shipped yet.
  • Spring AOT for GraalVM native-image. Niche enough that we'll ship it once a user asks. The integration would be one more script.
  • DevTools live restart. Already works at runtime if spring-boot-devtools is on the classpath — bleep does not need to do anything special, Spring Boot wires it itself.

See also