Clinical Tool - Modulith

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

This documentation is a work in progress.

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.

ZenWave Domain Models

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 (πŸ‘‡ view source)
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"
// these should match the values of openapi-generator-maven-plugin
openApiModelNameSuffix DTO
// used by ZDLToOpenAPIPlugin
idType integer
idTypeFormat int64
// BackendApplicationDefaultPlugin
useLombok true
useSpringModulith true
haltOnFailFormatting false
plugins {
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"
}
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"
}
ZDLToOpenAPIPlugin {
title "Documents API"
zdlFile "models/documents.zdl"
targetFile "src/main/resources/public/apis/documents-openapi.yml"
}
ZDLToOpenAPIPlugin {
title "Master Data API"
zdlFiles "models/masterdata.zdl"
targetFile "src/main/resources/public/apis/masterdata-openapi.yml"
}
ZDLToOpenAPIPlugin {
title "Terms And Conditions API"
zdlFiles "models/terms-and-conditions.zdl"
targetFile "src/main/resources/public/apis/terms-and-conditions-openapi.yml"
}
ZDLToOpenAPIPlugin {
title "User Managament API"
zdlFiles "models/users.zdl"
openapiOverlayFiles "src/main/resources/public/apis/user-additionalproperties-overlay.yml"
targetFile "src/main/resources/public/apis/users-openapi.yml"
}
//-----------------------------
BackendApplicationDefaultPlugin {
zdlFile "models/clinical.zdl"
// --force // overwite all files
}
BackendApplicationDefaultPlugin {
zdlFile "models/surveys.zdl"
// --force // overwite all files
}
BackendApplicationDefaultPlugin {
zdlFile "models/documents.zdl"
// --force // overwite all files
}
BackendApplicationDefaultPlugin {
zdlFile "models/masterdata.zdl"
// --force // overwite all files
}
BackendApplicationDefaultPlugin {
zdlFile "models/terms-and-conditions.zdl"
// --force // overwite all files
}
BackendApplicationDefaultPlugin {
zdlFile "models/users.zdl"
// --force // overwite all files
}
//-----------------------------
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"
}
OpenAPIControllersPlugin {
zdlFiles "models/clinical.zdl"
openapiFile "src/main/resources/public/apis/mobile-openapi.yml"
layout.customWebModule "{{basePackage}}.modules.web.mobile"
layout.adaptersWebPackage "{{layout.customWebModule}}"
layout.openApiApiPackage "{{layout.customWebModule}}"
layout.openApiModelPackage "{{layout.customWebModule}}.dtos"
}
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"
}
OpenAPIControllersPlugin {
zdlFiles "models/documents.zdl"
openapiFile "src/main/resources/public/apis/documents-openapi.yml"
}
OpenAPIControllersPlugin {
zdlFiles "models/masterdata.zdl"
openapiFile "src/main/resources/public/apis/masterdata-openapi.yml"
}
OpenAPIControllersPlugin {
zdlFiles "models/terms-and-conditions.zdl"
openapiFile "src/main/resources/public/apis/terms-and-conditions-openapi.yml"
}
OpenAPIControllersPlugin {
zdlFiles "models/users.zdl"
openapiFile "src/main/resources/public/apis/users-openapi.yml"
}
//-----------------------------
SpringWebTestClientPlugin {
zdlFiles "models/clinical.zdl,models/metrics.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"
}
SpringWebTestClientPlugin {
zdlFiles "models/clinical.zdl,models/metrics.zdl"
openapiFile "src/main/resources/public/apis/mobile-openapi.yml"
layout.customWebModule "{{basePackage}}.modules.web.mobile"
layout.adaptersWebPackage "{{layout.customWebModule}}"
layout.openApiApiPackage "{{layout.customWebModule}}"
layout.openApiModelPackage "{{layout.customWebModule}}.dtos"
}
SpringWebTestClientPlugin {
zdlFiles "models/surveys.zdl"
openapiFile "src/main/resources/public/apis/surveys-backoffice-openapi.yml"
}
SpringWebTestClientPlugin {
zdlFiles "models/surveys.zdl"
openapiFile "src/main/resources/public/apis/surveys-public-openapi.yml"
}
SpringWebTestClientPlugin {
zdlFiles "models/documents.zdl"
openapiFile "src/main/resources/public/apis/documents-openapi.yml"
}
SpringWebTestClientPlugin {
zdlFiles "models/masterdata.zdl"
openapiFile "src/main/resources/public/apis/masterdata-openapi.yml"
}
SpringWebTestClientPlugin {
zdlFiles "models/terms-and-conditions.zdl"
openapiFile "src/main/resources/public/apis/terms-and-conditions-openapi.yml"
}
//-----------------------------
OpenAPIKaratePlugin {
basePackage "{{basePackage}}.adapters.web"
testsPackage "karate.webapp"
apiFile "src/main/resources/public/apis/webapp-openapi.yml"
}
OpenAPIKaratePlugin {
basePackage "{{basePackage}}.adapters.web"
testsPackage "karate.mobile"
apiFile "src/main/resources/public/apis/mobile-openapi.yml"
}
OpenAPIKaratePlugin {
basePackage "{{basePackage}}.adapters.web"
testsPackage "karate.surveys.backoffice"
apiFile "src/main/resources/public/apis/surveys-backoffice-openapi.yml"
}
OpenAPIKaratePlugin {
basePackage "{{basePackage}}.adapters.web"
testsPackage "karate.surveys.api"
apiFile "src/main/resources/public/apis/surveys-public-openapi.yml"
}
}
}
πŸ”—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 (πŸ‘‡ view source)
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
}
@aggregate
entity Hospital {
name String required unique maxlength(254)
lang String required maxlength(3) /** Primary language of the hospital */
timezone String required maxlength(3) /** ECT (Europe/Madrid) */
}
@aggregate
@auditing
entity Doctor {
userId Long
profilePictureId Long
hospitalId Long required
name String required maxlength(100)
surname String required maxlength(100)
surname2 String maxlength(100)
email String maxlength(100) pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/)
phoneNumber String maxlength(20)
lang String maxlength(3) /** Primary language of the doctor */
}
@aggregate
@auditing
entity Patient {
userId Long
spikeUUID String /** se modifica directamente via Spike webhook en WearableDataRepository */
hospitalId Long required
profilePictureId Long
@naturalId
phoneNumber String required maxlength(20)
@naturalId
hisNumber String required maxlength(100)
email String required maxlength(100) pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/)
generalInfo GeneralInfo {
name String required maxlength(100)
surname String required maxlength(100)
surname2 String maxlength(100)
identityDocumentType IdentityDocumentType required
identityDocumentNumber String required maxlength(20)
birthDate LocalDate required
gender GenderType required
lang String maxlength(3) /** Primary language of the patient */
}
healthInsuranceInfo HealthInsuranceInfo {
insuranceCompanyId String required maxlength(100)
insuranceCardNumber String required maxlength(20)
}
@json documentIds Long[]
}
relationship OneToMany {
Patient{medicalContacts required } to MedicalContact{patient required}
Patient{personalContacts required } to PersonalContact{patient required}
Patient{patientAddresses required } to PatientAddress{patient required}
Patient{hospitalAddresses required } to HospitalAddress{patient required}
Patient{patientWearables required } to PatientWearable{patient required}
}
@aggregate
entity ProvisionalPatient {
@naturalId
phoneNumber String required maxlength(20)
@naturalId
hisNumber String required maxlength(100)
@json patient Patient
}
@skip
entity Address {
street String required maxlength(100)
city String required maxlength(100)
postalCode String required maxlength(10)
countryCode String required maxlength(3)
additionalInfo String maxlength(254)
}
@copy(Address)
entity PatientAddress {
current Boolean required
}
@copy(Address)
entity HospitalAddress {
}
enum IdentityDocumentType {
DNI(1), NIE(2), PASSPORT(3)
}
enum GenderType {
MALE(1), FEMALE(2), OTHER(3)
}
entity PatientWearable {
wearableType String required
serialNumber String maxlength(20)
}
entity MedicalContact {
name String required maxlength(100)
surname String required maxlength(100)
surname2 String maxlength(100)
hospital String maxlength(254)
area String maxlength(100)
jobPosition String maxlength(100)
phoneNumber String required maxlength(20)
email String required maxlength(100) pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/)
}
entity PersonalContact {
name String required maxlength(100)
surname String required maxlength(100)
surname2 String maxlength(100)
phoneNumber String required maxlength(20)
email String required maxlength(100) pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/)
patientRelationshipType PatientRelationshipType
emergencyContact Boolean required
}
enum PatientRelationshipType {
SON_DAUGTHER(1), CARE_GIVER(2), OTHER(3)
}
@inline
input HospitalId {
hospitalId Long required
}
@inline
input CriterioPatient {
hisNumber String required maxlength(100)
phoneNumber String required maxlength(20)
}
output PatientHospital {
patientId Long
hospitalId Long
hisNumber String
fullName String
gender GenderType
dni String
birthDate LocalDate
phone String
email String
insuranceCardNumber String
address String
}
@rest
service HospitalService for (Hospital, Doctor) {
@get("/hospitals/{id}")
getHospital(id) Hospital?
@post("/hospitals")
createHospital(Hospital) Hospital
@put("/hospitals/{id}")
updateHospital(id, Hospital) Hospital?
@get("/hospitals")
listHospitals() Hospital[]
@post("/doctors")
createDoctor(Doctor) Doctor withEvents DoctorCreated
@put("/doctors/{id}")
updateDoctor(id, Doctor) Doctor?
@get("/doctors/{id}")
getDoctor(id) Doctor?
@get("/doctors")
listDoctors() Doctor[]
@get("/hospital/{hospitalId}/doctors")
@entityForId(Hospital)
listHospitalDoctors(HospitalId) Doctor[]
@get("/hospital/{hospitalId}/patients")
@entityForId(Hospital)
listHospitalPatients(HospitalId) PatientHospital[]
}
input DocumentSignatureRequestedInput {
documentInfoId Long required
patientId Long required
}
@rest
service PatientsService for (Patient, ProvisionalPatient) {
/**
* Loads a saved temporal patient or new one if not found.
*/
@get("/patients/{hisNumber}/{phoneNumber}")
loadPatient(@natural id) Patient
/**
* Persist a temporal patient in the database.
*/
@patch("/patients/{hisNumber}/{phoneNumber}")
partialPatientUpdate(@natural id, Patient) Patient
/**
* Persite a patient in the database and deletes any temporal data.
*/
@post("/patients")
createPatient(Patient) Patient withEvents PatientCreated
/**
* Updates a patient in the database.
*/
@put("/patients/{id}")
updatePatient(id, Patient) Patient?
/**
* Load a patient by id
*/
@get("/patients/{id}")
getPatient(id) Patient?
/**
* Load a patient profile by id to mobile app.
*/
@get("/patients/{id}/profile")
@policies(isMobile)
@entityForId(Patient)
getPatientProfileById(id) PatientProfile
@post({ path: "/patients/{id}/opt-out", status: 200 })
@entityForId(Patient)
requestOptOut(id)
@listener({model: "models/documents.zdl", event: DocumentSignatureRequested})
associateDocumentWithPatient(DocumentSignatureRequestedInput)
}
output PatientProfile {
name String required maxlength(100)
surname String required maxlength(100)
surname2 String maxlength(100)
birthDate LocalDate required
gender GenderType required
phoneNumber String required maxlength(20)
}
event DoctorCreated {
doctor Doctor
}
event PatientCreated {
patient Patient
}
πŸ”—Navigate to source
Users Domain Model.zdl (πŸ‘‡ view source)
config {
title "User Management"
layout.moduleBasePackage "io.zenwave360.example.clinicaltool.modules.users"
// you can choose: DefaultProjectLayout, CleanHexagonalProjectLayout, LayeredProjectLayout, SimpleDomainProjectLayout, HexagonalProjectLayout, CleanArchitectureProjectLayout
layout SimpleDomainProjectLayout
}
@aggregate
@auditing
entity User (application_user) {
name String
@naturalId
username String required unique
email String required unique
password String
@set
roles String[] required
enabled Boolean
credentialsNonExpired Boolean
accountNonExpired Boolean
accountNonLocked Boolean
@jsonb additionalProperties Map
}
input SearchCriteria {
username String
email String
role String
enabled Boolean
searchTerms Map
}
@rest("/users")
service UserService for (User) {
@get("/{username}")
findByUsername(@natural id) User?
@post
createUser(User) User
@put("/{username}")
updateUser(@natural id, User) User?
@put(path: "/{username}/lock", status: 200)
lockAccount(@natural id) User?
@put(path: "/{username}/unlock", status: 200)
unLockAccount(@natural id) User?
@delete("/{username}")
deleteUser(@natural id)
@post(path: "/search", status: 200) @paginated
searchUsers(SearchCriteria) User[]
@get("/list") @paginated
listUsers() User[]
}
πŸ”—Navigate to source
Documents Domain Model.zdl (πŸ‘‡ view source)
config {
title "Documents Model"
layout.moduleBasePackage "io.zenwave360.example.clinicaltool.modules.documents"
// you can choose: DefaultProjectLayout, CleanHexagonalProjectLayout, LayeredProjectLayout, SimpleDomainProjectLayout, HexagonalProjectLayout, CleanArchitectureProjectLayout
layout SimpleDomainProjectLayout
}
@aggregate
entity DocumentInfo {
uuid String
fileName String required
documentType String required
contentType String required
tags String[]
}
entity DocumentData {
data Blob required
}
relationship OneToOne {
DocumentInfo{documentData required } to @Id DocumentData{document required}
}
@inline
input DocumentIds {
documentIds Long[]
}
@rest("/documents")
service DocumentService for (DocumentInfo) {
@get({path: "/"})
listDocumentInfo(DocumentIds) DocumentInfo[]
@get({path: "/{id}", params: {preview: boolean} })
@filedownload("documentData.data")
downloadDocument(id) DocumentInfo
@post({path: "/upload", status: 201})
@fileupload("file")
uploadDocument(DocumentInfo) DocumentInfo
@delete({path: "/{id}", status: 204})
deleteDocumentInfo(id)
}
πŸ”—Navigate to source
Master Data Domain Model.zdl (πŸ‘‡ view source)
config {
title "Master Data"
layout.moduleBasePackage "io.zenwave360.example.clinicaltool.modules.masterdata"
// you can choose: DefaultProjectLayout, CleanHexagonalProjectLayout, LayeredProjectLayout, SimpleDomainProjectLayout, HexagonalProjectLayout, CleanArchitectureProjectLayout
layout SimpleDomainProjectLayout
}
@aggregate
entity MasterData {
@naturalId
type MasterDataType required
@naturalId
key String required maxlength(254)
value String required maxlength(254)
@jsonb translations MasterDataTranslation[] {
lang String required
text String required
}
}
enum MasterDataType {
GENDER(1)
ID_DOCUMENT_TYPE(2)
COUNTRY(3)
INSURANCE_COMPANY(4)
MEDICAL_AREA(5)
}
output MasterDataKeyValue {
key String
value String
}
@inline
input MasterDataFilter {
type MasterDataType
lang String
}
@rest("/masterdata")
service MasterDataService for (MasterData) {
@post
createMasterData(MasterData) MasterData
@get("/{id}")
getMasterData(id) MasterData?
@put("/{id}")
updateMasterData(id, MasterData) MasterData?
@get @paginated
listMasterData() MasterData[]
@delete("/{id}")
deleteMasterData(id)
@get("/type/{type}/{lang}")
listMasterDataOfType(MasterDataFilter) MasterDataKeyValue[]
}
πŸ”—Navigate to source
Surveys Domain Model.zdl (πŸ‘‡ view source)
config {
title "Clinical Surveys Backend"
layout.moduleBasePackage "io.zenwave360.example.clinicaltool.modules.surveys"
layout LayeredProjectLayout
}
apis {
openapi(provider) BackOfficeAPI {
url "src/main/resources/public/apis/surveys-backoffice-openapi.yml"
}
openapi(provider) PublicAPI {
url "src/main/resources/public/apis/surveys-public-openapi.yml"
}
}
@aggregate
@auditing
entity Survey {
@naturalId
name String required maxlength(254) /** Unique identifier for the survey */
@naturalId
hospitalId Long required
title String required maxlength(254) /** Public title for the survey */
lang String required maxlength(3) /** Default language for this survey */
@jsonb sections SurveySection[] {
name String required maxlength(254) /** Unique identifier for the section */
translations SectionTranslation[] {
lang String required
title String required
summary String
}
questionIds Long[] /** Sorted List of Questions for this section */
}
}
@aggregate
@auditing
entity Question {
name String required unique maxlength(254) /** Unique identifier for the question */
questionType QuestionType required
required boolean = true
rangeStart Integer
rangeEnd Integer
@jsonb translations QuestionTranslation[] {
lang String required
text String required
}
@jsonb options Option[] { // mas facil mantener el orden que con OneToMany
name String required maxlength(254)
translations OptionTranslation[] {
lang String required
text String required
}
}
includeOther Boolean = false
}
enum QuestionType {
YES_NO(1), MULTIPLE_SELECTION(2), SINGLE_SELECTION(3), TEXT(4), NUMBER(5), DECIMAL(6), RANGE(7),
}
@aggregate
@auditing
entity SurveyAnswers {
@naturalId
surveyId Long required
@naturalId
patientId Long required
@naturalId
date LocalDate required
lang String required
@jsonb answers Answer[] {
questionId Long required
value String
values String[]
otherValue String
}
}
input SurveysByHospital {
hospitalId Long required
lang String maxlength(3)
}
/**
* Service for managing surveys and questions in the backoffice.
*/
@apis(BackOfficeAPI)
@rest("/backoffice/surveys")
service SurveysBackofficeService for (Survey, Question) {
@get
listSurveys() Survey[]
@get("/{id}")
getSurvey(id) Survey?
@post
createSurvey(Survey) Survey
@put("/{id}")
updateSurvey(id, Survey) Survey?
@delete("/{id}")
@entityForId(Survey)
deleteSurvey(id)
@get("/questions")
listQuestions() Question[]
@get("/questions/{id}")
getQuestion(id) Question?
@post("/questions")
createQuestion(Question) Question
@put("/questions/{id}")
updateQuestion(id, Question) Question?
@entityForId(Question)
@delete("/questions/{id}")
deleteQuestion(id)
}
/**
* This object represent a full survey with all the questions and options translated to the patient language.
*/
output SurveyAndQuestions {
surveyId Long required /** Persistence identifier for the survey */
name String required maxlength(254) /** Unique identifier for the survey across all environments */
title String required maxlength(254) /** Public title for the survey */
hospitalId Long required
lang String required maxlength(3) /** Default language for this survey */
sections SurveySectionOutput[] {
name String required maxlength(254) /** Unique identifier for the section */
title String required maxlength(254) /** Public title for the section */
summary String
questions QuestionTranslationOutput[] {
questionId Long required
required boolean = true
rangeStart Integer
rangeEnd Integer
text String required maxlength(254) /** Translated text for the question */
questionType QuestionType required
options OptionTranslationOutput[] {
name String required maxlength(254) /** Internal name for the option */
text String required /** Translated text for the option */
}
includeOther boolean = false
}
}
}
@inline
input SurveyByNameAndPatient {
name String required maxlength(254)
patientId Long required
lang String maxlength(3)
}
/**
* Public service to get Surveys and Questions for a patient and to answer the survey.
*/
@apis(PublicAPI)
@rest("/public/surveys")
service SurveysService for (SurveyAnswers) {
@get({ path: "/{name}/patient/{patientId}/questions", params: { lang: String } })
getSurveyAndQuestionsForPatient(SurveyByNameAndPatient) SurveyAndQuestions
@post("/{surveyId}/patient/{patientId}/answers/{date}")
answerSurvey(@natural id, SurveyAnswers) SurveyAnswers
@patch({ path: "/{surveyId}/patient/{patientId}/answers/{date}", params: { surveryId: Long } })
updateSurveyAnswers(@natural id, SurveyAnswers) SurveyAnswers?
@get("/{surveyId}/patient/{patientId}/answers/{date}")
getSurveyAnswers(@natural id) SurveyAnswers?
}
πŸ”—Navigate to source
Terms and Conditions Domain Model.zdl (πŸ‘‡ view source)
config {
title "Clinical Terms And Conditions Backend"
layout.moduleBasePackage "io.zenwave360.example.clinicaltool.modules.termsandconditions"
// you can choose: DefaultProjectLayout, CleanHexagonalProjectLayout, LayeredProjectLayout, SimpleDomainProjectLayout, HexagonalProjectLayout, CleanArchitectureProjectLayout
layout SimpleDomainProjectLayout
}
@aggregate
entity AcceptedTermsAndConditions {
@naturalId
userId Long required
termsAndConditionsId Long required
acceptedDate Instant required
}
@aggregate
entity TermsAndConditions {
content TextBlob required
lang String required maxlength(3) /** language code */
contentVersion String unique required /** Arbitrary version string */
startDate LocalDate unique required /** Date when the terms and conditions are valid (inclusive) */
}
@inline
input Lang {
lang String required maxlength(3) /** language code */
}
input AcceptedTermsAndConditionsInput {
termsAndConditionsId Long required
}
@rest("/terms-and-conditions")
service TermsAndConditionsService for (AcceptedTermsAndConditions, TermsAndConditions) {
@get
listTermsAndConditions() TermsAndConditions[]
@get("/{id}")
getTermsAndConditions(id) TermsAndConditions?
@post
createTermsAndConditions(TermsAndConditions) TermsAndConditions
@put("/{id}")
updateTermsAndConditions(id, TermsAndConditions) TermsAndConditions?
@get("/latest/{lang}")
getCurrentTermsAndConditions(Lang) TermsAndConditions?
@post({ path: "/accept", status: 200 })
acceptTermsAndConditions(AcceptedTermsAndConditionsInput)
}
πŸ”—Navigate to source

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 (πŸ‘‡ view source)
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 (πŸ‘‡ view source)
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:

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 (πŸ‘‡ view source)
@rest
service PatientsService for (Patient, ProvisionalPatient) {
/**
* Loads a saved temporal patient or new one if not found.
*/
@get("/patients/{hisNumber}/{phoneNumber}")
loadPatient(@natural id) Patient
/**
* Persist a temporal patient in the database.
*/
@patch("/patients/{hisNumber}/{phoneNumber}")
partialPatientUpdate(@natural id, Patient) Patient
/**
* Persite a patient in the database and deletes any temporal data.
*/
@post("/patients")
createPatient(Patient) Patient withEvents PatientCreated
/**
* Updates a patient in the database.
*/
@put("/patients/{id}")
updatePatient(id, Patient) Patient?
/**
* Load a patient by id
*/
@get("/patients/{id}")
getPatient(id) Patient?
/**
* Load a patient profile by id to mobile app.
*/
@get("/patients/{id}/profile")
@policies(isMobile)
@entityForId(Patient)
getPatientProfileById(id) PatientProfile
@post({ path: "/patients/{id}/opt-out", status: 200 })
@entityForId(Patient)
requestOptOut(id)
@listener({model: "models/documents.zdl", event: DocumentSignatureRequested})
associateDocumentWithPatient(DocumentSignatureRequestedInput)
}
πŸ”—Navigate to source

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

ZenWave Scripts for Clinical Tool - Modulith (πŸ‘‡ view source)
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 (πŸ‘‡ view source)
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 (πŸ‘‡ view source)
OpenAPIControllersPlugin {
zdlFiles "models/clinical.zdl"
openapiFile "src/main/resources/public/apis/mobile-openapi.yml"
layout.customWebModule "{{basePackage}}.modules.web.mobile"
layout.adaptersWebPackage "{{layout.customWebModule}}"
layout.openApiApiPackage "{{layout.customWebModule}}"
layout.openApiModelPackage "{{layout.customWebModule}}.dtos"
}
πŸ”—Navigate to source

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 (πŸ‘‡ view source)
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:

Clinical Tool Domain Model.zdl (πŸ‘‡ view source)
}
@inline
input HospitalId {
hospitalId Long required
}
@inline
input CriterioPatient {
πŸ”—Navigate to source

Other interesting features

File Uploads

βš™οΈ

When decorating a service method with @fileupload like this:

models/documents.zdl (πŸ‘‡ view source)
@post({path: "/upload", status: 201})
@fileupload("file")
uploadDocument(DocumentInfo) DocumentInfo
πŸ”—Navigate to source

then ZDLToOpenAPIPlugin will generate this OpenAPI definition:

OpenAPI Definition (πŸ‘‡ view source)
/documents/upload:
post:
operationId: uploadDocument
description: "uploadDocument"
tags: [Document]
requestBody:
required: true
content:
multipart/form-data:
schema:
allOf:
- type: object
properties:
file:
type: string
format: binary
- $ref: "#/components/schemas/DocumentInfo"
πŸ”—Navigate to source

File Downloads

βš™οΈ

When decorating a service method with @filedownload like this:

models/documents.zdl (πŸ‘‡ view source)
@get({path: "/{id}", params: {preview: boolean} })
@filedownload("documentData.data")
downloadDocument(id) DocumentInfo
πŸ”—Navigate to source

then ZDLToOpenAPIPlugin will generate this OpenAPI definition:

OpenAPI Definition (πŸ‘‡ view source)
/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 (πŸ‘‡ view source)
var documentInfo = documentService.downloadDocument(id);
byte[] bytes = null; // TODO get bytes from documentData.data
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);
}
@Override
public ResponseEntity<Void> deleteDocumentInfo(Long id) {
πŸ”—Navigate to source

Domain Events between Modules

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 @filedownload like this:

models/documents.zdl (πŸ‘‡ view source)
/**
* Persite a patient in the database and deletes any temporal data.
*/
@post("/patients")
createPatient(Patient) Patient withEvents PatientCreated
πŸ”—Navigate to source

then ZDLToOpenAPIPlugin will generate this OpenAPI definition:

OpenAPI Definition (πŸ‘‡ view source)
return patient;
}
@Transactional
public Patient createPatient(Patient input) {
log.debug("[CRUD] Request to save Patient: {}", input);
var patient = patientsServiceMapper.update(new Patient(), input);
patient = patientRepository.save(patient);
// emit events
var patientCreated = eventsMapper.asPatientCreated(input);
πŸ”—Navigate to source

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

DefaultEventPublisher.java (πŸ‘‡ view source)
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