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.
Set up
Section titled “Set up”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.
With Maven
Section titled “With Maven”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>The runtime must match the plugin
Section titled “The runtime must match the plugin”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.
Write a handler
Section titled “Write a handler”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.
@RestControllerclass 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.
Who is calling
Section titled “Who is calling”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 noprincipal().input.scopes()has one member per scope the operation runs in, each theResolvedScopeyour resolver returned. An operation without scopes has noscopes().
UUID userId = input.principal().userId();Object channelId = input.scopes().channel().id();Neither comes from the request body: the client cannot set them.
Wire it in
Section titled “Wire it in”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).
Write the hooks
Section titled “Write the hooks”Hooks are beans. Write one per identity, one decider for your rights and rules, and one resolver per scope.
@Componentclass 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 innames(), orDecider.EVERY_NAMEfor 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
IdempotencyStorebean. - 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.
Use Spring Security
Section titled “Use Spring Security”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:
@BeanSecurityFilterChain 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:
@BeanBearerTokenResolver bearerTokens(NotesSecurityMatchers routes) { DefaultBearerTokenResolver standard = new DefaultBearerTokenResolver(); return request -> routes.anonymousOnRefusalRoutes().matches(request) ? null : standard.resolve(request);}Options
Section titled “Options”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.
Paging
Section titled “Paging”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
Pageableas the item’s Java member name:sort=created_at,descsorts bycreatedAt. 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.
Docs and OpenAPI
Section titled “Docs and OpenAPI”artefacts: { nativeDocs: true }puts springdoc’s@Operationon the generated interfaces. Each success shows the body the contract declares, not the handler’s result type. Addio.swagger.core.v3:swagger-annotations-jakarta(springdoc brings it).frameworkDocs: offwritesseampact-notes.properties, which turns springdoc’s document off. Load it inapplication.properties:
spring.config.import=classpath:seampact-notes.propertiesThe boot check fails when springdoc still serves its document. More in OpenAPI and docs.
Format envelopes and null
Section titled “Format envelopes and null”- The success body goes out in its format’s envelope. With
pack:laravelyourNoteleaves as{ "data": { ... } }; with a bare format it leaves as it is. You still return the plain value. - A
nullbody answers the format’s null (null, or the envelope with a null payload). The boundary refuses it unless the schema is nullable. Anulllist, table or page is always refused. - An opaque value takes
nullonly when it isnullable. - A number past the range of a double inside an opaque value is a
typeissue (400).
Test your routes
Section titled “Test your routes”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)));Debug locally
Section titled “Debug locally”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.
Deploy
Section titled “Deploy”Behind a reverse proxy
Section titled “Behind a reverse proxy”Set the forwarded-headers strategy, so Spring reads the proxy’s headers:
server.forward-headers-strategy=frameworkWith 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.comIn Docker
Section titled “In Docker”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-jreCOPY build/libs/app.jar /app.jarENTRYPOINT ["java", "-jar", "/app.jar"]Pass secrets and the public origin as environment variables (SPRING_ properties or ${...} placeholders), never baked into the image.
The boot check
Section titled “The boot check”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." }Versions
Section titled “Versions”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 }.
Limits
Section titled “Limits”generaterefuses 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
repeatform (tag=a&tag=b) or ascomma. - A Spring Boot app must run Spring MVC on Tomcat. WebFlux fails the start.