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@SpringBootTestslice and aMockMvctest. - 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.propertiesauto-generated as sourcegen so Spring Boot Actuator's/actuator/infoendpoint 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:
myappdependencies. 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.sourcegenreferencesbleep.plugin.springboot.SpringBootBuildInfodirectly. The plugin ships aBleepCodegenScriptyou can use as-is — no wrapper class needed for the default case.myapp-testhasisTestProject: trueand depends onmyapp. There is noTest/test/itTest/Compilescope dance; the test project is just another project that picks upmyapp's classes and adds the Spring Boot test starter.scripts.dependenciespulls inbuild.bleep:bleep-plugin-spring-bootwhich provides the threeSpringBoot*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:
- Set it directly via
withEnvironmentorwithSystemProperty. Spring Boot reads almost everything through one of those. - Copy
SpringBootRun.javainto your own scripts project, rename it, add the fluent setter you want, and register it as a new scriptmain. 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-imagefor Buildpacks-based OCI image production. The plugin can be extended to wrapspring-boot-buildpack-platformthe same way it wrapsspring-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-devtoolsis on the classpath — bleep does not need to do anything special, Spring Boot wires it itself.
See also
- Spring Boot proves the model — the design argument
- Scripts concept
- Sourcegen concept
- Maven plugin coverage — Spring Boot in the broader picture
- Project status — what's missing, including BOM support
- The example workspace:
docs-snippets-from-tests/spring-boot-myapp