Build a CRUD API with the Spring Boot facility
This guide builds the complete path from a CMN entity to an executable Spring Boot REST API. It uses the canonical example-spring-boot module and shows how JoinedWorkz generates the DTO, mapper, persistence, data-access and web layers while the project retains ownership of its application shell and runtime test.
Use this page as the executable CRUD tutorial: it starts from the canonical example, follows one complete generated request flow and shows how to verify it through HTTP. For a CMN-first introduction, begin with Model a SpringBoot REST API step by step. For the example's additional generated contracts and complete test inventory, use the Spring Boot example reference.
The model and generated structure described here are in the example-spring-boot module on release/1.3.81.
Related documentation:
1. Get and verify the example
You need Java 21 and Maven 3.9 or newer.
git clone --branch release/1.3.81 --single-branch \
https://gitlab.com/joinedworkz/joinedworkz-examples.git
cd joinedworkz-examples/example-spring-boot
mvn clean verifyThis single command:
- removes previous generated output;
- generates Java and OpenAPI artifacts from the CMN models;
- compiles generated and manual sources;
- starts the Spring Boot application on a random port for the test;
- runs a complete HTTP CRUD flow against an in-memory H2 database.
The HTTP flow is implemented by the manually maintained CustomerCrudRuntimeTest.
2. Model the domain entity
The domain model in model/customer.cmn defines an enum, an address value and the customer entity:
core package org.joinedworkz.examples.customer
import org.joinedworkz.facilities.common.base
import org.joinedworkz.facilities.profiles.springboot
platform SpringBoot
enum<integer> SetupType {
NONE: 10
NP: 20
JP: 30
}
type Address {
street*: String(200)
zipCode: String(10)
city*: String(100)
country: String(2)
}
type<entity> Customer {
id**: Id
firstName*: Name
lastName*: Name
email: String(255)
setupType: SetupType
mainAddress: Address
}Important details:
type<entity>identifiesCustomeras a persistence entity.id**marksidas its key.Addressis used as a nested value in the customer representation.SetupTypeuses the namesNONE,NP, andJPin JSON and OpenAPI while its explicit codes10,20, and30form the database mapping contract.coreis the CMN layer used for this package and normally becomes the effective layer for outlet routing. Java package overrides are configured separately.
The Customer model deliberately omits an ID generation strategy and therefore uses an assigned ID. Clients must continue to supply a UUID when they create a customer. To model generated IDs without changing this example, use the separate SpringBoot ID generation strategy reference.
3. Model the REST resource
The API model in model/api.cmn imports the domain package, the SpringBoot profile and its API helpers:
api package org.joinedworkz.examples.customer.api vendorSpecificMimeType=true
import org.joinedworkz.examples.customer
import org.joinedworkz.facilities.profiles.springboot
import org.joinedworkz.facilities.springboot.api
platform SpringBoot
resource /customers as Customer[] by id {
queryEntities()
createEntity()
readEntity()
updateEntity()
deleteEntity()
}
abstract resource /address as Address { }resource /customers as Customer[] by id declares a collection resource with id as the item identifier. The imported method types contribute the operation details and connect each operation to the generated data-access service:
createEntity()contributesPOST /customers, callsCustomerDataAccessService.createand returns201 Created.readEntity()contributesGET /customers/{id}, callsCustomerDataAccessService.readand returns200 OK.updateEntity()contributesPUT /customers/{id}, callsCustomerDataAccessService.updateand returns200 OK.queryEntities()contributesGET /customers, enables paging, sorting and filtering, callsCustomerDataAccessService.searchand returns200 OK.deleteEntity()contributesDELETE /customers/{id}, callsCustomerDataAccessService.deleteand returns204 No Content.
These are reusable CMN method types, not five handwritten controller methods. The SpringBoot API model defines their HTTP methods, status codes, request and response representations, handler expressions and CRUD semantics.
The abstract /address resource has no methods and does not create another endpoint in this example.
4. Bind the resource to a component
The resource declaration describes the API but does not by itself establish a concrete component controller. model/customer-backend.cmn makes the backend component provide /customers:
package org.joinedworkz.examples.backend
import org.joinedworkz.examples.customer.api
import org.joinedworkz.facilities.profiles.springboot
platform SpringBoot
component CustomerBackend
componentNamespace='org.joinedworkz.examples.customer.webapp' {
provide /customers
namespaceSuffix='customers.v1'
controller="CustomerV1Controller" {
}
}
application CustomerApp {
consists of {
CustomerBackend
}
}The component and provide declaration supply the component namespace, relative namespace suffix and controller name for generated web sources. The component also causes a component-scoped OpenAPI document to aggregate all endpoints provided by CustomerBackend.
5. Understand the generated request flow
Generation creates the following main artifacts under src/generated/java/org/joinedworkz/examples/customer:
dto/CustomerDto.javaanddto/AddressDto.javatype/SetupType.javaentity/Customer.javaconverter/SetupTypeAttributeConverter.javamapper/CustomerMapper.javarepository/CustomerRepository.javadas/CustomerDataAccessService.javawebapp/customers/v1/api/CustomerV1Api.javawebapp/customers/v1/controller/CustomerV1Controller.java
They form one request and response path:
CustomerV1Controllerreceives the path, query parameters and/orCustomerDto.- The controller delegates to
CustomerDataAccessService. - For create and update,
CustomerMappermaps the DTO into a new or existing JPACustomerentity. CustomerRepositorypersists or reads that entity. Queries are executed from the parsed page, filter and sort specification.CustomerMappermaps returned entities toCustomerDto.- The data-access service returns the DTO or query result to the controller, which creates the HTTP response.
The generated mapper deliberately ignores id while updating an existing entity. Consequently, the UUID in PUT /customers/{id} remains authoritative even when the request body omits it.
CustomerRepository extends Spring Data's JPA repository. The generated CustomerDataAccessService connects that repository with the mapper and the query service; application code does not need to reproduce this plumbing.
The DTO and HTTP boundary use the SetupType names. The generated JPA attribute converter maps them to the explicit numeric codes in the database and rejects an unknown stored code rather than falling back to an ordinal.
6. Select the SpringBoot flavor
The example's joinedworkz.properties contains:
platform.springboot.flavor=modern
cartridge.IntegrationTestCartridge.enabled=falseplatform.springboot.flavor=modern explicitly selects the current naming strategies, including singular table names and safe suffixes for reserved table and column names. Configuration and the distinction from the other flavor are described in SpringBoot profile and modeling.
The generated integration-test cartridge is disabled by default; the example states false explicitly because the complete manual HTTP test described next is its runtime contract. Other projects can opt in with cartridge.IntegrationTestCartridge.enabled=true. That opt-in generates only eligible flat Create-to-Read tests; update, delete and query remain manual test responsibilities.
The example declares org.iworkz:genesis-spring:1.0.77 as a compact reference glue implementation. It brings genesis-core transitively, so no direct core dependency is needed. This dependency is intended for examples, demos, and prototypes. A production project can provide compatible glue implementations and replace both package prefixes; see Facilities and platforms.
7. Follow the executable CRUD test
CustomerCrudRuntimeTest starts the manually maintained SpringBootExampleApplication with a random HTTP port. Before each test it clears CustomerRepository. Its single test method then performs this sequence:
POST /customerscreates Ada Lovelace with a supplied UUID andsetupTypeNP. The test checks201 Created, the UUID, scalar fields, enum value and nested address, then confirms that H2 stores code20.GET /customers/{id}reads Ada and checks200 OKand all fields.- Another
POST /customerscreates Grace Hopper. GET /customers?page=1&page-size=10&sort=ASC:firstNamechecks200 OK, filtered and total counts, and the orderAda,Grace.PUT /customers/{id}updates Ada without an ID in the request body and changessetupTypetoJP. The test checks200 OK, every changed field and the preserved path UUID, then confirms that H2 stores code30.- A subsequent
GET /customers/{id}checks the persisted update. DELETE /customers/{id}checks204 No Content.- A final sorted query checks that the result contains only Grace.
This covers all five modeled CRUD method types through the generated controller and the real persistence path. Assertions cover both HTTP status and observable response content.
8. Run the API manually
Package and start the application:
mvn clean package
java -jar target/example-spring-boot-1.3.81.jarThe example listens on http://localhost:8080. Create a customer with a supplied UUID:
curl --fail-with-body \
-H 'Content-Type: application/json' \
-d '{
"id": "11111111-1111-1111-1111-111111111111",
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada.lovelace@example.test",
"setupType": "NP",
"mainAddress": {
"street": "Analytical Engine Road 1",
"zipCode": "1000",
"city": "London",
"country": "GB"
}
}' \
http://localhost:8080/customersRead it:
curl --fail-with-body \
http://localhost:8080/customers/11111111-1111-1111-1111-111111111111Update it. The path identifies the entity, so the body can omit id:
curl --fail-with-body \
-X PUT \
-H 'Content-Type: application/json' \
-d '{
"firstName": "Augusta Ada",
"lastName": "Byron",
"email": "ada.byron@example.test",
"setupType": "JP",
"mainAddress": {
"street": "St James Square 12",
"zipCode": "SW1Y",
"city": "London",
"country": "GB"
}
}' \
http://localhost:8080/customers/11111111-1111-1111-1111-111111111111Query with paging and sorting:
curl --fail-with-body \
'http://localhost:8080/customers?page=1&page-size=10&sort=ASC:firstName'Delete the customer:
curl --fail-with-body \
-X DELETE \
http://localhost:8080/customers/11111111-1111-1111-1111-1111111111119. Keep manual and generated ownership separate
Maintain these project inputs manually:
model/*.cmnpom.xmljoinedworkz.propertiessrc/main/**src/test/**
src/main/java/org/joinedworkz/examples/SpringBootExampleApplication.java is the application shell. The example's src/main/resources/application.properties configures its H2 data source and runtime JPA behavior. CustomerCrudRuntimeTest is manual test source.
Treat src/generated/** as replaceable output. Do not modify generated DTOs, entities, repositories, mappers, data-access services or controllers by hand; change their model or supported configuration and regenerate. mvn clean removes that output before a clean generation.
The generator also creates:
src/generated/resources/openapi/org.joinedworkz.examples.customer.api.yamlsrc/generated/resources/openapi/org.joinedworkz.examples.backend_customerbackend.yaml
The first document comes from the API model. The second aggregates all endpoints provided by the component. Application composition does not produce an additional OpenAPI aggregate.
For the full ownership rules, see Generated output, ownership and regeneration.
10. Adapt the pattern
For your own application:
- add the Spring Boot facility and generator plugin to the Maven build;
- model entities and keys in a domain package;
- model REST resources in an API package and apply the required CRUD method types;
- provide the resources from a component to establish controller names and packages;
- add a manual Spring Boot application shell and runtime configuration;
- generate from the model, compile and exercise the resulting HTTP contract;
- keep project-specific business services outside
src/generated/**.
The canonical example uses a single Maven module and needs no outlet override. When splitting generated layers across modules, follow Multi-module setup with outlet overrides.
