Skip to content

Modular Monolith for a “Clinical Tool” using JPA and Domain Events.

SpringBoot + Java modular monolith for a “Clinical Tool” using JPA for persistence for this Domain Model:

The project is composed by five vertical modules: clinical, users, documents, masterdata and surveys.

They are vertical modules in the sense that they do not call each other or database tables from other modules.

Each module exposes a REST API as part of its internal implementation, except clinical which exposes its functionality through two different API modules: web-api and mobile-api.

surveys module also exposes two different APIs: surveys-backoffice and surveys-public but its implementation is an internal implementation detail.

We’ll see in the following sections detailed instructions on how to configure ZenWave SDK to generate a modular monolith like this one.

You can find the Complete Source Code at github.

This project was generated using the following ZenWave scripts, as always you can run each of these plugins using the ZenWave Model Editor for IntelliJ or from the command line using JBang:

ZenWave Scripts for Clinical Tool - Modulith

Navigate to source

And ZenWave Domain Models located on folder: https://github.com/ZenWave360/zenwave-playground/tree/main/examples/modulith-clinical-tool-jpa/models

Clinical Tool Domain Model.zdl

Navigate to source

Users Domain Model.zdl

Navigate to source

Documents Domain Model.zdl

Navigate to source

Master Data Domain Model.zdl

Navigate to source

Surveys Domain Model.zdl

Navigate to source

Terms and Conditions Domain Model.zdl

Navigate to source

Building a Modular Monolith with ZenWave SDK

Section titled “Building a Modular Monolith with ZenWave SDK”

When building modular monoliths is necessary to configure basePackage, moduleBasePackage and the different commonPackages in order to share base classes between different modules to avoid code duplication.

In zenwave-scripts.zw we configure the basePackage and the commonPackages:

ZenWave Scripts for Clinical Tool - Modulith
config {
title "Clinical Tool Backend"
basePackage "io.zenwave360.example.clinicaltool"
persistence jpa
databaseType postgresql
layout.commonPackage "{{basePackage}}.common"
layout.infrastructureRepositoryCommonPackage "{{commonPackage}}"
layout.adaptersWebMappersCommonPackage "{{commonPackage}}.mappers"
layout.coreImplementationMappersCommonPackage "{{commonPackage}}.mappers"

Navigate to source

And each module has its own moduleBasePackage:

Clinical Tool Domain Model.zdl
config {
title "Clinical Tool Backend"
layout.moduleBasePackage "io.zenwave360.example.clinicaltool.modules.clinical"
// you can choose different layouts, see docs at https://www.zenwave360.io/docs/
layout CleanHexagonalProjectLayout
}

Navigate to source

This results in the following source folder structure, with shared base classes in common packages:

Modules Source Folders

Exposing a Module API as different Modules

Section titled “Exposing a Module API as different Modules”

In this example we have a module clinical that exposes its functionality through two different API modules: web-api and mobile-api.

You can achieve that by generating two different OpenAPI definitions from the same ZenWave service.

Given this service definition:

Clinical Tool Domain Model.zdl

Navigate to source

You can configure code ZenWave Scripts to include or exclude certain operations from each API:

ZenWave Scripts for Clinical Tool - Modulith
ZDLToOpenAPIPlugin {
title "Clinical Tool - WebApp API"
zdlFiles "models/clinical.zdl"
targetFile "src/main/resources/public/apis/webapp-openapi.yml"
operationIdsToExclude "getPatientProfileById"
}
ZDLToOpenAPIPlugin {
title "Clinical Tool - Mobile API"
zdlFiles "models/clinical.zdl"
targetFile "src/main/resources/public/apis/mobile-openapi.yml"
operationIdsToInclude "getPatientProfileById,requestOptOut"
}

Navigate to source

And then with the functionality split in two different OpenAPI definitions, you can generate their corresponding controllers and tests on different modules:

ZenWave Scripts for Clinical Tool - Modulith
OpenAPIControllersPlugin {
zdlFiles "models/clinical.zdl"
openapiFile "src/main/resources/public/apis/webapp-openapi.yml"
layout.customWebModule "{{basePackage}}.modules.web.webapp"
layout.adaptersWebPackage "{{layout.customWebModule}}"
layout.openApiApiPackage "{{layout.customWebModule}}"
layout.openApiModelPackage "{{layout.customWebModule}}.dtos"
}

Navigate to source

ZenWave Scripts for Clinical Tool - Modulith

Navigate to source

Splitting one Service into multiple APIs on the same module

Section titled “Splitting one Service into multiple APIs on the same module”

On the other hand surveys module exposes two different APIs: surveys-backoffice and surveys-public but they are implemented inside the same module.

Again you can achieve that by generating two different OpenAPI definitions from the same ZenWave service.

ZenWave Scripts for Clinical Tool - Modulith
ZDLToOpenAPIPlugin {
title "Surveys - Backoffice API"
zdlFile "models/surveys.zdl"
targetFile "src/main/resources/public/apis/surveys-backoffice-openapi.yml"
operationIdsToExclude "getSurveyAndQuestionsForPatient,answerSurvey,updateSurveyAnswers,getSurveyAnswers"
}
ZDLToOpenAPIPlugin {
title "Surveys - Public API"
zdlFile "models/surveys.zdl"
targetFile "src/main/resources/public/apis/surveys-public-openapi.yml"
operationIdsToInclude "getSurveyAndQuestionsForPatient,answerSurvey,updateSurveyAnswers,getSurveyAnswers"
}

Navigate to source

But in this case, because we don’t do any special configuration when generating the controllers, they will be generated in the same module:

ZenWave Scripts
OpenAPIControllersPlugin {
zdlFiles "models/surveys.zdl"
openapiFile "src/main/resources/public/apis/surveys-backoffice-openapi.yml"
}
OpenAPIControllersPlugin {
zdlFiles "models/surveys.zdl"
openapiFile "src/main/resources/public/apis/surveys-public-openapi.yml"
}

Navigate to source

When decorating a service method with @fileupload like this:

models/documents.zdl
@post({path: "/upload", status: 201})
@fileupload("file")
uploadDocument(DocumentInfo) DocumentInfo

Navigate to source

then `ZDLToOpenAPIPlugin` will generate this OpenAPI definition:

OpenAPI Definition
/documents/upload:
post:
operationId: "uploadDocument"
description: "uploadDocument"
tags:
- "Document"
parameters:
- name: "uuid"
in: "query"
required: false
schema:
type: "string"
- name: "tags"
in: "query"
required: false
style: "form"
explode: true
schema:
type: "array"
items:
type: "string"
requestBody:
required: true
content:
multipart/form-data:
schema:
type: "object"
required:
- "file"
properties:
file:
type: "string"
format: "binary"
responses:
"201":
description: "OK"
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentInfo"

Navigate to source

When decorating a service method with @filedownload like this:

models/documents.zdl
@get({path: "/{id}", params: {preview: boolean} })
@filedownload("documentData.data")
downloadDocument(id) DocumentInfo

Navigate to source

then `ZDLToOpenAPIPlugin` will generate this OpenAPI definition:

OpenAPI Definition
/documents/{id}:
get:
operationId: "downloadDocument"
description: "downloadDocument"
tags:
- "Document"
parameters:
- name: "id"
in: "path"
required: true
schema:
type: "integer"
format: "int64"
- name: "preview"
in: "query"
schema:
type: "boolean"
responses:
"200":
description: "OK"
headers:
Content-Disposition:
description: "Controls file download behavior. Values: 'inline' (display\
\ in browser) or 'attachment; filename=example.pdf' (download file)"
schema:
type: "string"
content:
'*/*':
schema:
type: "string"
format: "binary"

Navigate to source

And the OpenAPIControllersPlugin will generate the corresponding controller code to handle file uploads and downloads.

DocumentApiController.java
@Override
public ResponseEntity<Resource> downloadDocument(Long id, Boolean preview) {
log.debug("REST request to downloadDocument: {}, {}", id, preview);
var documentInfo = documentService.downloadDocument(id);
byte[] bytes = documentInfo.getDocumentData().getData();
ByteArrayResource resource = new ByteArrayResource(bytes);
return ResponseEntity.status(200)
.header("Content-Disposition", "inline") // or attachment; filename=example.pdf
.contentType(MediaType.APPLICATION_OCTET_STREAM) // TODO: set content type
.body(resource);
}

Navigate to source

Modules can comunicate with each other using Event-Driven patterns with Domain Events.

When a service method emits a domain event that is not annotated with @asyncapi, BackendApplicationPlugin will generate an EventProducer which uses Spring event bus to publish the event.

NOTE: currently ZenWave SDK is not able to generate listeners for domain events between modules, as with @asyncapi events, and you need to implement them manually.

When decorating a service method with @listener like this:
@listener({model: "models/documents.zdl", event: DocumentSignatureRequested})
associateDocumentWithPatient(DocumentSignatureRequestedInput)

Navigate to source

With an EventPublisher that use Spring’s ApplicationEventPublisher under the hood (and also an InMemoryEventPublisher for testing purposes):

DefaultEventPublisher.java
@Component
@lombok.RequiredArgsConstructor
public class DefaultEventPublisher implements EventPublisher {
private final ApplicationEventPublisher applicationEventPublisher;
public void onDoctorCreated(DoctorCreated event) {
applicationEventPublisher.publishEvent(event);
}
public void onPatientCreated(PatientCreated event) {
applicationEventPublisher.publishEvent(event);
}
}

Navigate to source