Skip to content

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.

bash
git clone --branch release/1.3.81 --single-branch \
  https://gitlab.com/joinedworkz/joinedworkz-examples.git
cd joinedworkz-examples/example-spring-boot
mvn clean verify

This single command:

  1. removes previous generated output;
  2. generates Java and OpenAPI artifacts from the CMN models;
  3. compiles generated and manual sources;
  4. starts the Spring Boot application on a random port for the test;
  5. 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:

cmn
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> identifies Customer as a persistence entity.
  • id** marks id as its key.
  • Address is used as a nested value in the customer representation.
  • SetupType uses the names NONE, NP, and JP in JSON and OpenAPI while its explicit codes 10, 20, and 30 form the database mapping contract.
  • core is 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:

cmn
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() contributes POST /customers, calls CustomerDataAccessService.create and returns 201 Created.
  • readEntity() contributes GET /customers/{id}, calls CustomerDataAccessService.read and returns 200 OK.
  • updateEntity() contributes PUT /customers/{id}, calls CustomerDataAccessService.update and returns 200 OK.
  • queryEntities() contributes GET /customers, enables paging, sorting and filtering, calls CustomerDataAccessService.search and returns 200 OK.
  • deleteEntity() contributes DELETE /customers/{id}, calls CustomerDataAccessService.delete and returns 204 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:

cmn
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.java and dto/AddressDto.java
  • type/SetupType.java
  • entity/Customer.java
  • converter/SetupTypeAttributeConverter.java
  • mapper/CustomerMapper.java
  • repository/CustomerRepository.java
  • das/CustomerDataAccessService.java
  • webapp/customers/v1/api/CustomerV1Api.java
  • webapp/customers/v1/controller/CustomerV1Controller.java

They form one request and response path:

  1. CustomerV1Controller receives the path, query parameters and/or CustomerDto.
  2. The controller delegates to CustomerDataAccessService.
  3. For create and update, CustomerMapper maps the DTO into a new or existing JPA Customer entity.
  4. CustomerRepository persists or reads that entity. Queries are executed from the parsed page, filter and sort specification.
  5. CustomerMapper maps returned entities to CustomerDto.
  6. 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:

properties
platform.springboot.flavor=modern
cartridge.IntegrationTestCartridge.enabled=false

platform.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:

  1. POST /customers creates Ada Lovelace with a supplied UUID and setupType NP. The test checks 201 Created, the UUID, scalar fields, enum value and nested address, then confirms that H2 stores code 20.
  2. GET /customers/{id} reads Ada and checks 200 OK and all fields.
  3. Another POST /customers creates Grace Hopper.
  4. GET /customers?page=1&page-size=10&sort=ASC:firstName checks 200 OK, filtered and total counts, and the order Ada, Grace.
  5. PUT /customers/{id} updates Ada without an ID in the request body and changes setupType to JP. The test checks 200 OK, every changed field and the preserved path UUID, then confirms that H2 stores code 30.
  6. A subsequent GET /customers/{id} checks the persisted update.
  7. DELETE /customers/{id} checks 204 No Content.
  8. 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:

bash
mvn clean package
java -jar target/example-spring-boot-1.3.81.jar

The example listens on http://localhost:8080. Create a customer with a supplied UUID:

bash
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/customers

Read it:

bash
curl --fail-with-body \
  http://localhost:8080/customers/11111111-1111-1111-1111-111111111111

Update it. The path identifies the entity, so the body can omit id:

bash
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-111111111111

Query with paging and sorting:

bash
curl --fail-with-body \
  'http://localhost:8080/customers?page=1&page-size=10&sort=ASC:firstName'

Delete the customer:

bash
curl --fail-with-body \
  -X DELETE \
  http://localhost:8080/customers/11111111-1111-1111-1111-111111111111

9. Keep manual and generated ownership separate

Maintain these project inputs manually:

  • model/*.cmn
  • pom.xml
  • joinedworkz.properties
  • src/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.yaml
  • src/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:

  1. add the Spring Boot facility and generator plugin to the Maven build;
  2. model entities and keys in a domain package;
  3. model REST resources in an API package and apply the required CRUD method types;
  4. provide the resources from a component to establish controller names and packages;
  5. add a manual Spring Boot application shell and runtime configuration;
  6. generate from the model, compile and exercise the resulting HTTP contract;
  7. 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.