Skip to content

Spring Boot

The Gradle or Maven plugin generates the contract’s Java code when you build. You write the controllers and the hooks; the generated boundary checks each request and each reply.

Spring Boot 4 on Java 21 or later. See Versions and Supported versions.

The runtime, the hooks and the Maven plugin are on Maven Central under io.github.jubasse. The Gradle plugin is on the Gradle Plugin Portal. While the Portal reviews a new release, add Maven Central to the plugin repositories in settings.gradle.kts:

pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}

Then, in build.gradle.kts:

plugins {
java
id("org.springframework.boot") version "4.1.1"
id("io.github.jubasse.seampact")
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("io.github.jubasse:seampact-hooks")
}

In seampact.config.yaml, add one entry per api:

targets:
- id: notes
use: io.github.jubasse:seampact-spring-boot
api: notes
options: { package: com.example.notes.api }

The records and the checks on them are described in Java records; see Types and validation.

The plugin adds io.github.jubasse:seampact-spring-runtime at its own version: the generated code imports its boundary runtime from there. ./gradlew build generates the code, then compiles it with yours. The build runs check too (./gradlew seampactCheck alone). Whether to commit the generated code: Generated files; the other commands: Commands per stack.

The Node CLI does not generate a Spring Boot entry: seampact generate refuses it with config.target.native.

Add the runtime and the plugin to pom.xml, next to seampact-hooks, at the same version. Set it once in <properties>: <seampact.version>1.0.0</seampact.version>.

<dependency>
<groupId>io.github.jubasse</groupId>
<artifactId>seampact-spring-runtime</artifactId>
<version>${seampact.version}</version>
</dependency>
<plugin>
<groupId>io.github.jubasse</groupId>
<artifactId>seampact-maven-plugin</artifactId>
<version>${seampact.version}</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>

./mvnw verify generates the code before it compiles, then checks it. When nothing changed since the last run, generate skips.

You can write the config in <configuration> instead of seampact.config.yaml, with the same keys. A list holds one child per item. Keep one of the two: a project with both fails.

<configuration>
<outDir>target/generated-sources/seampact</outDir>
<targets>
<target>
<id>notes</id>
<use>io.github.jubasse:seampact-spring-boot</use>
<api>notes</api>
<options><package>com.example.notes.api</package></options>
</target>
</targets>
</configuration>

Before it writes, generate reads the runtime on the compile classpath, with Gradle as with Maven. It fails with runtime.missing when there is none. It fails with runtime.version-mismatch when the runtime is another major.minor, or an older patch, than the plugin. A later patch passes.

Each resource has a generated <Resource>Endpoints interface, and each operation a mapping annotation in <package>.spring. Return one arm of the operation’s result: Status200 and the like, or an error of <Operation>Errors.

@RestController
class NotesController implements NotesEndpoints {
@Override
@GetNote
public GetNoteResult getNote(GetNoteInput input) {
Note note = notes.get(input.path().noteId());
return note == null
? new GetNoteErrors.ApiNotFound()
: new GetNoteResult.Status200(note);
}
}

The boundary has already checked the path, query, headers and body. You never write a status or a content type yourself.

A version in the input is checked for its format only. Compare it with the stored version, and return the operation’s conflict error when they differ:

if (input.body().expectedVersion() != task.version()) return new UpdateTaskErrors.StateConflict();

With If-Match, a missing or empty header (or *) answers 428 before the handler runs. A weak tag (W/"7") is accepted. The handler gets the version unquoted (7) and returns the preconditionFailed error (412) on a mismatch. See Catch lost updates.

The input also tells you who calls and in which scopes:

  • input.principal() is what your authenticate hook returned, typed as the identity’s principal. It is null for an anonymous caller on an optional-auth route. A public route has no principal().
  • input.scopes() has one member per scope the operation runs in, each the ResolvedScope your resolver returned. An operation without scopes has no scopes().
UUID userId = input.principal().userId();
Object channelId = input.scopes().channel().id();

Neither comes from the request body: the client cannot set them.

The plugin generates a configuration class, NotesSeampactConfiguration, in <package>.spring. Spring finds it by component scan when package sits under your application’s package. Otherwise import it:

@SpringBootApplication
@Import(NotesSeampactConfiguration.class)
class NotesApplication {
}

Your controller and hook beans are found as usual. Your own routes stay beside the contract’s. To serve the api under a prefix inside the app, set mount: /api on the target entry (see Deploy).

Hooks are beans. Write one per identity, one decider for your rights and rules, and one resolver per scope.

@Component
class UserHook implements UserAuthenticateHook {
@Override
public AuthenticateResult<UserPrincipal, Denial> authenticate(UserAuthenticateRequest request) {
return request.credentials().bearer()
.flatMap(tokens::userOf)
.<AuthenticateResult<UserPrincipal, Denial>>map(AuthenticateResult.Authenticated::new)
.orElseGet(() -> new AuthenticateResult.Denied<>(Denial.of("api.unauthenticated")));
}
}
  • A decider implements the api’s NotesDecider. It lists the names it answers in names(), or Decider.EVERY_NAME for all of them.
  • A scope resolver implements NotesScopeResolver.
  • The optional unexpected-error listener implements NotesUnexpectedErrorListener. It hears each 500 the api answers.
  • An idempotent operation needs exactly one IdempotencyStore bean.
  • With two apis, each one calls only the beans of its own interfaces.

The params of a right or rule in a decider request are an instance of the generated record, not a map.

With Spring Security on the classpath (spring-boot-starter-security), the plugin generates, for the notes api:

  • NotesSecurityMatchers: the contract’s routes by access kind (publicRoutes(), optionalRoutes(), identityRoutes(), signedRoutes(), anonymousOnRefusalRoutes());
  • SecurityRefusals: an entry point and an access-denied handler that answer 401, 403 and a CSRF refusal in the api’s error format. With two apis on Spring Security, take it by its bean name: @Qualifier("notesSecurityRefusals").

Set springSecurity: false in the entry’s options to turn them off. Setting it on without Spring Security on the classpath fails the build with options.springSecurity.unavailable.

Wire them in your chain:

@Bean
SecurityFilterChain chain(HttpSecurity http, NotesSecurityMatchers routes, SecurityRefusals refusals) {
return http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(routes.publicRoutes(), routes.optionalRoutes(), routes.signedRoutes()).permitAll()
.requestMatchers(routes.identityRoutes()).hasRole("MEMBER")
.anyRequest().authenticated())
.exceptionHandling(handling -> handling.authenticationEntryPoint(refusals).accessDeniedHandler(refusals))
.build();
}

Each matcher follows the route the api serves, so a public /notes/{id} never covers a protected /notes/mine. Signed routes are permitted: the api checks their signature.

With springSecurity: { matchers: true }, a generated Customizer<HttpSecurity> permits the public, optional-auth and signed routes for you, in every chain. It runs before your own rules and your own customizers. A chain with no authorization rules of its own then denies every request it does not permit, so give each chain its rules. The customizer needs spring-security-config, which spring-boot-starter-security brings; without it, the build fails with options.springSecurity.matchers.unavailable.

Your authenticate hook reads the user from SecurityContextHolder. On an optional-auth route that calls anonymously when the token is refused, keep the route out of your bearer filter:

@Bean
BearerTokenResolver bearerTokens(NotesSecurityMatchers routes) {
DefaultBearerTokenResolver standard = new DefaultBearerTokenResolver();
return request -> routes.anonymousOnRefusalRoutes().matches(request) ? null : standard.resolve(request);
}

Set under options: on the target entry.

Option Default What it does
package required The Java package of the generated code, such as com.example.notes.api.
indent 4 Spaces per indent in the generated code, from 1 to 8.
responses strict strict answers 500 when your reply breaks the contract. warn logs it and sends the reply.
bodyValidation false Checks success bodies against the contract.
frameworkEtag strip Drops an ETag or a 304 that the boundary did not write. keep leaves them.
catchAll true Unknown paths under the api’s base path answer the contract’s 404.
springSecurity read from the classpath true, false or { matchers: true }: the classes of Use Spring Security.

Also on the entry: mount (a path prefix), frameworkRoutes (see The boot check), frameworkDocs, allowUntested, and under artefacts: nativeDocs and bootCheck. Settings set on a group flow down to the routes below it: see Settings flow down the tree. The full list is in the configuration reference.

responses, bodyValidation, frameworkEtag and catchAll set the defaults. You can change responses at run time with the property seampact.<api>.responses. With warn, the reply goes out as written and the violation is logged, a body that fails bodyValidation included. A result that breaks its own type (a null, a page without items or with a negative total) is still a 500.

With @seampact/pack-spring, a list in the springPage style gets a Pageable in its input and returns Spring Data’s Page. Add org.springframework.data:spring-data-commons; the Spring Boot BOM gives its version.

  • A sort key reaches the Pageable as the item’s Java member name: sort=created_at,desc sorts by createdAt. Name your entity’s properties like the generated record’s members.
  • A page number past Spring Data’s range (a page index above 2147483647) gets a 400 at the page parameter.
  • artefacts: { nativeDocs: true } puts springdoc’s @Operation on the generated interfaces. Each success shows the body the contract declares, not the handler’s result type. Add io.swagger.core.v3:swagger-annotations-jakarta (springdoc brings it).
  • frameworkDocs: off writes seampact-notes.properties, which turns springdoc’s document off. Load it in application.properties:
spring.config.import=classpath:seampact-notes.properties

The boot check fails when springdoc still serves its document. More in OpenAPI and docs.

  • The success body goes out in its format’s envelope. With pack:laravel your Note leaves as { "data": { ... } }; with a bare format it leaves as it is. You still return the plain value.
  • A null body answers the format’s null (null, or the envelope with a null payload). The boundary refuses it unless the schema is nullable. A null list, table or page is always refused.
  • An opaque value takes null only when it is nullable.
  • A number past the range of a double inside an opaque value is a type issue (400).

Start the app on a random port and send requests with RestTestClient or any HTTP client. Send the credential header your authenticate hook reads. @MockitoBean replaces a hook bean with a mock you program in the test.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class NotesRoutesTest {
@MockitoBean
UserHook userHook;

A test slice that loads only some controllers (@WebMvcTest) fails the boot check by default: every operation needs its handler. Set unbound-operations=warn in the slice and import the api’s configuration and its hook beans:

@WebMvcTest(controllers = NotesController.class)
@Import({NotesSeampactConfiguration.class, UserHook.class, AppDecider.class})
@TestPropertySource(properties = "seampact.notes.unbound-operations=warn")
class NotesControllerTest {

The slice starts and logs the operations it leaves without a handler, once. A request to one of their routes answers the api’s 404. Set the property only in the test (@TestPropertySource or @WebMvcTest(properties = ...)), never in application.properties: there it lets the app start with a missing handler. Every hook the contract names is still needed. Any value other than error (the default) or warn stops the start.

To test a controller without HTTP, build the input record yourself, with the caller and the scopes. Nothing checks them on a direct call.

GetNoteResult result = controller.getNote(new GetNoteInput(new GetNotePath(noteId), new UserPrincipal(userId)));

While Spring Boot devtools is active, an exception your controller throws goes to Spring’s own error handling (Boot’s error page or JSON). seampact logs its message and the file and line it came from. Without devtools, under the default responses: strict the client gets the api’s 500. Under responses: warn the framework’s reply goes out as written and the violation is logged.

Set the forwarded-headers strategy, so Spring reads the proxy’s headers:

server.forward-headers-strategy=framework

With framework, a prefix sent in X-Forwarded-Prefix is applied, and the contract’s routes still match. The same holds for server.servlet.context-path and spring.mvc.servlet.path. Strip any forwarded header a client sends at your edge proxy: only the proxy may set them.

To serve the api under a prefix inside the app, set mount: /api on the target entry. The context path stays outside it.

The public URL of the api stays the one in the contract. Page links are query-only (?page=2), so they stay right behind a proxy that strips a prefix. If the contract writes absolute URLs, set the public origin. The request’s Host is never read:

seampact.notes.public-origin=https://api.example.com

Build the jar with ./gradlew bootJar (or ./mvnw package). The generated code is compiled into it, so the image needs no seampact tool.

FROM eclipse-temurin:21-jre
COPY build/libs/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

Pass secrets and the public origin as environment variables (SPRING_ properties or ${...} placeholders), never baked into the image.

The app refuses to start, and lists every problem. Each message starts with its code: find it in Boot errors.

Fault Fix
The code was generated by another version than the runtime’s (runtime.generated-for-other-version). Run the generate task again, or move the runtime to the plugin’s version.
An identity has no authenticate hook, a right or rule no decider, a scope no resolver. Add the bean.
A bean implements Decider, ScopeResolver or UnexpectedErrorListener itself, not the api’s own interface. Implement the api’s generated interface.
An idempotent operation has no IdempotencyStore bean. Add exactly one.
An operation has no handler. Implement it. A test slice can set seampact.<api>.unbound-operations=warn, see Test your routes.
A route that is not in the contract is served and not allow-listed. Add it to frameworkRoutes on the entry, or remove it.
Another handler mapping, ordered before Spring MVC’s, takes a contract route. Reorder it, or move the route.
The app does not run Spring MVC on Tomcat. Run Spring MVC on Tomcat.
The contract writes absolute URLs and seampact.<api>.public-origin is missing, or is not a scheme, host and port. Set it, such as https://api.example.com.

By default the check reads only routes that reach a path of the api; artefacts.bootCheck.frameworkRoutes: strict checks them all. Allow-list your own routes in the entry, with a reason:

frameworkRoutes:
- { path: "/admin/**", reason: "Back office, not part of the api." }

The plugins read the Spring Boot version of your build. Spring Boot 4 is tested. Spring Boot 3 fails with config.matrix.incompatible. A newer major prints config.matrix.untested. Accept it on the entry with allowUntested: [spring-boot@5], or make it fail with checks: { matrix: error }.

  • generate refuses some forms by name until an app needs them. The list is in Generate errors; forms refused on every server are under every server.
  • A query list is read in the default repeat form (tag=a&tag=b) or as comma.
  • A Spring Boot app must run Spring MVC on Tomcat. WebFlux fails the start.