A customer management microservice built with Java 25 and Spring Boot 4, using a contract-first approach driven by an OpenAPI specification.
The primary goal of this project is to study and understand how to build a RESTful microservice using a contract-first approach with the OpenAPI Generator Maven Plugin. The API contract is defined in src/main/resources/static/openapi.yaml, and both the model classes and controller interfaces are generated automatically at build time β the implementation only needs to fulfil the generated interface.
Key Technical Features:
- Contract-First API: Models and controller interfaces are auto-generated from
openapi.yamlusing the OpenAPI Generator Maven Plugin. - Database: Customer data is persisted in MySQL 8 using Spring Data JDBC. Schema and seed scripts are applied automatically on startup from
src/main/resources. - API Documentation: Interactive documentation is provided via springdoc-openapi (Swagger UI), pointing directly at the OpenAPI spec file.
- API Testing: End-to-end API tests are written with Karate 2.0, running against a live Spring Boot context spun up by JUnit 5.
A Customer is the aggregate root. Each customer can have multiple Address, Email, and Document records. All child entities are linked via customer_id and cascade-deleted when the customer is removed.
βΉοΈ Database scripts are in
src/main/resources:
| File | Description |
|---|---|
| schema.sql | Creates all tables |
| data.sql | Seeds initial data |
- Language: Java 25
- Framework: Spring Boot 4.0.6
- Data: Spring Data JDBC, MySQL 8
- API Contract: OpenAPI Generator Maven Plugin 7.22.0
- API Documentation: springdoc-openapi 3.0.3 (Swagger UI)
- API Testing: Karate 2.0.0 (JUnit 5)
- Validation: Hibernate Validator 9, Jakarta Bean Validation 4
- Infrastructure: Docker, Docker Compose (Spring Boot Docker Compose integration)
- Endpoint:
GET /v1/customers/{id}
Request example:
curl --location 'http://localhost:9081/v1/customers/1'Response example 200 OK:
{
"id": 1,
"firstName": "Aoife",
"lastName": "Murphy",
"birthday": "1980-12-13",
"addresses": [
{ "id": 1, "street": "6 Bridge St.", "eircode": "N36RP84", "city": "Cork", "county": "County Cork", "country": "Ireland" }
],
"emails": [
{ "id": 1, "type": "WORK", "email": "aoife.murphy@aib.ie" },
{ "id": 2, "type": "PERSONAL", "email": "aoife.murphy@gmail.com" }
],
"documents": [
{ "id": 1, "type": "PPS", "documentNumber": "48378IA" },
{ "id": 2, "type": "PASSPORT", "documentNumber": "FA3891IU" }
]
}| Status | Description |
|---|---|
200 OK |
Customer found |
404 Not Found |
No customer for the given id |
- Endpoint:
GET /v1/customers
| Parameter | Description | Example |
|---|---|---|
pageNumber |
Page number (zero-based) | 0 |
pageSize |
Records per page | 10 |
firstName |
Filter by first name | Aoife |
lastName |
Filter by last name | Murphy |
Request example:
curl --location 'http://localhost:9081/v1/customers?pageSize=10&pageNumber=0&firstName=Aoife&lastName=Murphy'Response example 200 OK:
{
"totalElements": 1,
"totalPages": 1,
"size": 10,
"content": [
{ "id": 1, "firstName": "Aoife", "lastName": "Murphy", "birthday": "1980-12-13" }
],
"number": 0,
"first": true,
"last": true,
"empty": false
}- Endpoint:
POST /v1/customers
Request example:
curl --location 'http://localhost:9081/v1/customers' \
--header 'Content-Type: application/json' \
--data '{
"firstName": "Irwin",
"lastName": "Streich",
"birthday": "1977-09-23"
}'Response example 201 Created:
{ "id": 5, "firstName": "Irwin", "lastName": "Streich", "birthday": "1977-09-23" }- Endpoint:
PUT /v1/customers/{id}
Request example:
curl --location --request PUT 'http://localhost:9081/v1/customers/1' \
--header 'Content-Type: application/json' \
--data '{
"firstName": "Irwin",
"lastName": "Streich",
"birthday": "1980-05-12"
}'| Status | Description |
|---|---|
200 OK |
Customer updated |
404 Not Found |
No customer for the given id |
- Endpoint:
DELETE /v1/customers/{id}
Request example:
curl --location --request DELETE 'http://localhost:9081/v1/customers/1'| Status | Description |
|---|---|
204 No Content |
Customer deleted |
404 Not Found |
No customer for the given id |
- Endpoint:
GET /v1/customers/{customerId}/addresses/{id}
Request example:
curl --location 'http://localhost:9081/v1/customers/1/addresses/1'Response example 200 OK:
{ "id": 1, "street": "6 Bridge St.", "eircode": "N36RP84", "city": "Cork", "county": "County Cork", "country": "Ireland" }- Endpoint:
GET /v1/customers/{customerId}/addresses
| Parameter | Description | Example |
|---|---|---|
pageNumber |
Page number | 0 |
pageSize |
Records per page | 10 |
street |
Filter by street | Bridge |
city |
Filter by city | Cork |
county |
Filter by county | County Cork |
country |
Filter by country | Ireland |
eircode |
Filter by eircode | N36RP84 |
- Endpoint:
POST /v1/customers/{customerId}/addresses
Request example:
curl --location 'http://localhost:9081/v1/customers/1/addresses' \
--header 'Content-Type: application/json' \
--data '{
"street": "Hammes Highway",
"complement": "103",
"eircode": "N21RP11",
"city": "Mosciskifort",
"county": "Roscommon",
"country": "Ireland"
}'Response example 201 Created:
{ "id": 5, "street": "Hammes Highway", "complement": "103", "eircode": "N21RP11", "city": "Mosciskifort", "county": "Roscommon", "country": "Ireland" }- Endpoint:
PUT /v1/customers/{customerId}/addresses/{id}
Request example:
curl --location --request PUT 'http://localhost:9081/v1/customers/1/addresses/1' \
--header 'Content-Type: application/json' \
--data '{
"street": "Hammes Highway",
"complement": "105",
"eircode": "N21RP11",
"city": "Dublin",
"county": "Co. Dublin",
"country": "Ireland"
}'- Endpoint:
DELETE /v1/customers/{customerId}/addresses/{id}
Request example:
curl --location --request DELETE 'http://localhost:9081/v1/customers/1/addresses/1'- Endpoint:
GET /v1/customers/{customerId}/emails/{id}
Request example:
curl --location 'http://localhost:9081/v1/customers/1/emails/1'Response example 200 OK:
{ "id": 1, "type": "WORK", "email": "aoife.murphy@aib.ie" }- Endpoint:
POST /v1/customers/{customerId}/emails
Request example:
curl --location 'http://localhost:9081/v1/customers/1/emails' \
--header 'Content-Type: application/json' \
--data '{
"type": "WORK",
"email": "aoife.murphy@guiness.ie"
}'Response example 201 Created:
{ "id": 10, "type": "WORK", "email": "aoife.murphy@guiness.ie" }- Endpoint:
PUT /v1/customers/{customerId}/emails/{id}
Request example:
curl --location --request PUT 'http://localhost:9081/v1/customers/1/emails/1' \
--header 'Content-Type: application/json' \
--data '{
"type": "PERSONAL",
"email": "aoife.murphy@hotmail.com"
}'- Endpoint:
DELETE /v1/customers/{customerId}/emails/{id}
Request example:
curl --location --request DELETE 'http://localhost:9081/v1/customers/1/emails/1'- Endpoint:
GET /v1/customers/{customerId}/documents/{id}
Request example:
curl --location 'http://localhost:9081/v1/customers/1/documents/1'Response example 200 OK:
{ "id": 1, "type": "PASSPORT", "documentNumber": "FU129837" }- Endpoint:
POST /v1/customers/{customerId}/documents
Request example:
curl --location 'http://localhost:9081/v1/customers/1/documents' \
--header 'Content-Type: application/json' \
--data '{
"type": "PPS",
"documentNumber": "46751M"
}'Response example 201 Created:
{ "id": 10, "type": "PPS", "documentNumber": "46751M" }- Endpoint:
PUT /v1/customers/{customerId}/documents/{id}
Request example:
curl --location --request PUT 'http://localhost:9081/v1/customers/1/documents/1' \
--header 'Content-Type: application/json' \
--data '{
"type": "PASSPORT",
"documentNumber": "FU4673761"
}'- Endpoint:
DELETE /v1/customers/{customerId}/documents/{id}
Request example:
curl --location --request DELETE 'http://localhost:9081/v1/customers/1/documents/1'Once the service is running, access the interactive API documentation at:
- Swagger UI: http://localhost:9081/swagger-ui/index.html
A Postman collection is provided to test all APIs:
βΉοΈ Location:
_assets/postman/customer-service.postman_collection.json
Execute unit tests (service and controller layers, no Spring context loaded):
mvn testKarate tests spin up a live Spring Boot context on a random port and run end-to-end API scenarios. They are organised as follows:
| File | Purpose |
|---|---|
CustomerSteps.feature |
Reusable step definitions for Customer CRUD |
AddressSteps.feature |
Reusable step definitions for Address CRUD |
EmailSteps.feature |
Reusable step definitions for Email CRUD |
DocumentSteps.feature |
Reusable step definitions for Document CRUD |
SavingCustomerTest.feature |
Customer create / update / get / delete scenarios |
SavingAddressTest.feature |
Address create / update / get / delete scenarios |
SavingEmailTest.feature |
Email create / update / get / delete scenarios |
SavingDocumentTest.feature |
Document create / update / get / delete scenarios |
Actuator.feature |
Spring Boot Actuator health check |
Execute the Karate tests:
mvn test -Dkarate.env=local -Dtest=br.com.customer.karate.KarateTestRunnerResults are generated in the target/karate-reports/ folder.
mvn clean packageEnsure MySQL is running and accessible at localhost:3306 (database: customer_cascade, user: root, password: root), then:
mvn spring-boot:run -Dspring-boot.run.profiles=localThe service starts on port 9081.
Build the image:
docker build -f Dockerfile -t customer-openapi-service:1.0.0 .The service includes a Dockerfile to build a lightweight, secure container image using Distroless. The Distroless image contains only the JRE and the application itself β no shell, no package manager, and a significantly smaller attack surface.
FROM gcr.io/distroless/java25-debian13
COPY target/customer-openapi-service.jar app.jar
EXPOSE 8081
ENV SPRING_PROFILES_ACTIVE=local
ENTRYPOINT ["java", "-jar", "/app.jar"]Build and run the container image:
# Build the image
docker build -t customer-openapi-service .
# Run the container (requires Redis to be running and reachable)
docker run -e SPRING_PROFILES_ACTIVE=local -p 9081:9081 customer-openapi-serviceSpring Boot's Docker Compose integration (spring-boot-docker-compose) will automatically start the required services defined in docker-compose.yml when the application boots.
This project is licensed under the MIT License β see the LICENSE file for details.



