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
config {title "Clinical Tool Backend"basePackage "io.zenwave360.example.clinicaltool"persistence jpadatabaseType postgresqllayout.commonPackage "{{basePackage}}.common"layout.infrastructureRepositoryCommonPackage "{{commonPackage}}"layout.adaptersWebMappersCommonPackage "{{commonPackage}}.mappers"layout.coreImplementationMappersCommonPackage "{{commonPackage}}.mappers"// these should match the values of openapi-generator-maven-pluginopenApiModelNameSuffix DTO// used by ZDLToOpenAPIPluginidType integeridTypeFormat int64// BackendApplicationDefaultPluginuseLombok trueuseSpringModulith truehaltOnFailFormatting falseplugins {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"}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"openapiOverlayFiles "src/main/resources/public/apis/documents-openapi-overlay.yml"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"}}}
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
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}@aggregateentity 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@auditingentity Doctor {userId LongprofilePictureId LonghospitalId Long requiredname 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@auditingentity Patient {userId LonghospitalId Long requiredprofilePictureId Long@naturalIdphoneNumber String required maxlength(20)@naturalIdhisNumber 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 requiredidentityDocumentNumber String required maxlength(20)birthDate LocalDate requiredgender GenderType requiredlang 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}}@aggregateentity ProvisionalPatient {@naturalIdphoneNumber String required maxlength(20)@naturalIdhisNumber String required maxlength(100)@json patient Patient}@skipentity 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 requiredserialNumber 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 PatientRelationshipTypeemergencyContact Boolean required}enum PatientRelationshipType {SON_DAUGTHER(1), CARE_GIVER(2), OTHER(3)}@inlineinput HospitalId {hospitalId Long required}@inlineinput CriterioPatient {hisNumber String required maxlength(100)phoneNumber String required maxlength(20)}output PatientHospital {patientId LonghospitalId LonghisNumber StringfullName Stringgender GenderTypedni StringbirthDate LocalDatephone Stringemail StringinsuranceCardNumber Stringaddress String}@restservice 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 requiredpatientId Long required}@restservice PatientsService for (Patient, ProvisionalPatient) {@get("/patients/{hisNumber}/{phoneNumber}")loadPatient(@natural id) Patient@post("/patients")createPatient(Patient) Patient withEvents PatientCreated@patch("/patients/{hisNumber}/{phoneNumber}")partialPatientUpdate(@natural id, Patient) Patient@put("/patients/{id}")updatePatient(id, Patient) Patient?@get("/patients/{id}")getPatient(id) Patient?@get("/patients/{id}/profile")@policies(isMobile)@entityForId(Patient)getPatientProfileById(id) PatientProfile@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 requiredgender GenderType requiredphoneNumber String required maxlength(20)}event DoctorCreated {doctor Doctor}event PatientCreated {patient Patient}
Users Domain Model.zdl
config {title "User Management"layout.moduleBasePackage "io.zenwave360.example.clinicaltool.modules.users"// you can choose: DefaultProjectLayout, CleanHexagonalProjectLayout, LayeredProjectLayout, SimpleDomainProjectLayout, HexagonalProjectLayout, CleanArchitectureProjectLayoutlayout SimpleDomainProjectLayout}@aggregate@auditingentity User (application_user) {name String@naturalIdusername String required uniqueemail String required uniquepassword String@setroles String[] requiredenabled BooleancredentialsNonExpired BooleanaccountNonExpired BooleanaccountNonLocked Boolean@jsonb additionalProperties Map}input SearchCriteria {username Stringemail Stringrole Stringenabled BooleansearchTerms Map}@rest("/users")service UserService for (User) {@get("/{username}")findByUsername(@natural id) User?@postcreateUser(User) User@put("/{username}")updateUser(@natural id, User) User?@put(path: "/{username}/enable", status: 200)enableAccount(@natural id) User?@put(path: "/{username}/disable", status: 200)disableAccount(@natural id) User?@delete("/{username}")deleteUser(@natural id)@post(path: "/search", status: 200) @paginatedsearchUsers(SearchCriteria) User[]@get @paginatedlistUsers() User[]}
Documents Domain Model.zdl
config {title "Documents Model"layout.moduleBasePackage "io.zenwave360.example.clinicaltool.modules.documents"// you can choose: DefaultProjectLayout, CleanHexagonalProjectLayout, LayeredProjectLayout, SimpleDomainProjectLayout, HexagonalProjectLayout, CleanArchitectureProjectLayoutlayout SimpleDomainProjectLayout}@aggregateentity DocumentInfo {uuid StringfileName String requireddocumentType String requiredcontentType String requiredtags String[]}entity DocumentData {data Blob required}relationship OneToOne {DocumentInfo{documentData required } to @Id DocumentData{document required}}@inlineinput 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)}
Master Data Domain Model.zdl
config {title "Master Data"layout.moduleBasePackage "io.zenwave360.example.clinicaltool.modules.masterdata"// you can choose: DefaultProjectLayout, CleanHexagonalProjectLayout, LayeredProjectLayout, SimpleDomainProjectLayout, HexagonalProjectLayout, CleanArchitectureProjectLayoutlayout SimpleDomainProjectLayout}@aggregateentity MasterData {@naturalIdtype MasterDataType required@naturalIdkey String required maxlength(254)value String required maxlength(254)@jsonb translations MasterDataTranslation[] {lang String requiredtext String required}}enum MasterDataType {GENDER(1)ID_DOCUMENT_TYPE(2)COUNTRY(3)INSURANCE_COMPANY(4)MEDICAL_AREA(5)}output MasterDataKeyValue {key Stringvalue String}@inlineinput MasterDataFilter {type MasterDataTypelang String}@rest("/masterdata")service MasterDataService for (MasterData) {@postcreateMasterData(MasterData) MasterData@get("/{id}")getMasterData(id) MasterData?@put("/{id}")updateMasterData(id, MasterData) MasterData?@get @paginatedlistMasterData() MasterData[]@delete("/{id}")deleteMasterData(id)@get("/type/{type}/{lang}")listMasterDataOfType(MasterDataFilter) MasterDataKeyValue[]}
Surveys Domain Model.zdl
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@auditingentity Survey {@naturalIdname String required maxlength(254) /** Unique identifier for the survey */@naturalIdhospitalId Long requiredtitle 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 requiredtitle String requiredsummary String}questionIds Long[] /** Sorted List of Questions for this section */}}@aggregate@auditingentity Question {name String required unique maxlength(254) /** Unique identifier for the question */questionType QuestionType requiredrequired boolean = truerangeStart IntegerrangeEnd Integer@jsonb translations QuestionTranslation[] {lang String requiredtext String required}@jsonb options Option[] { // mas facil mantener el orden que con OneToManyname String required maxlength(254)translations OptionTranslation[] {lang String requiredtext 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@auditingentity SurveyAnswers {@naturalIdsurveyId Long required@naturalIdpatientId Long required@naturalIddate LocalDate requiredlang String required@jsonb answers Answer[] {questionId Long requiredvalue Stringvalues String[]otherValue String}}input SurveysByHospital {hospitalId Long requiredlang String maxlength(3)}/*** Service for managing surveys and questions in the backoffice.*/@apis(BackOfficeAPI)@rest("/backoffice/surveys")service SurveysBackofficeService for (Survey, Question) {@getlistSurveys() Survey[]@get("/{id}")getSurvey(id) Survey?@postcreateSurvey(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 requiredlang 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 Stringquestions QuestionTranslationOutput[] {questionId Long requiredrequired boolean = truerangeStart IntegerrangeEnd Integertext String required maxlength(254) /** Translated text for the question */questionType QuestionType requiredoptions OptionTranslationOutput[] {name String required maxlength(254) /** Internal name for the option */text String required /** Translated text for the option */}includeOther boolean = false}}}@inlineinput SurveyByNameAndPatient {name String required maxlength(254)patientId Long requiredlang 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?}
Terms and Conditions Domain Model.zdl
config {title "Clinical Terms And Conditions Backend"layout.moduleBasePackage "io.zenwave360.example.clinicaltool.modules.termsandconditions"// you can choose: DefaultProjectLayout, CleanHexagonalProjectLayout, LayeredProjectLayout, SimpleDomainProjectLayout, HexagonalProjectLayout, CleanArchitectureProjectLayoutlayout SimpleDomainProjectLayout}@aggregateentity AcceptedTermsAndConditions {@naturalIduserId Long requiredtermsAndConditionsId Long requiredacceptedDate Instant required}@aggregateentity TermsAndConditions {content TextBlob requiredlang 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) */}@inlineinput Lang {lang String required maxlength(3) /** language code */}input AcceptedTermsAndConditionsInput {userId Long requiredtermsAndConditionsId Long required}@rest("/terms-and-conditions")service TermsAndConditionsService for (AcceptedTermsAndConditions, TermsAndConditions) {@getlistTermsAndConditions() TermsAndConditions[]@get("/{id}")getTermsAndConditions(id) TermsAndConditions?@postcreateTermsAndConditions(TermsAndConditions) TermsAndConditions@put("/{id}")updateTermsAndConditions(id, TermsAndConditions) TermsAndConditions?@get("/latest/{lang}")getCurrentTermsAndConditions(Lang) TermsAndConditions?@post({ path: "/accept", status: 200 })acceptTermsAndConditions(AcceptedTermsAndConditionsInput)}
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 jpadatabaseType postgresqllayout.commonPackage "{{basePackage}}.common"layout.infrastructureRepositoryCommonPackage "{{commonPackage}}"layout.adaptersWebMappersCommonPackage "{{commonPackage}}.mappers"layout.coreImplementationMappersCommonPackage "{{commonPackage}}.mappers"
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}
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
service PatientsService for (Patient, ProvisionalPatient) {@get("/patients/{hisNumber}/{phoneNumber}")loadPatient(@natural id) Patient@post("/patients")createPatient(Patient) Patient withEvents PatientCreated@patch("/patients/{hisNumber}/{phoneNumber}")partialPatientUpdate(@natural id, Patient) Patient@put("/patients/{id}")updatePatient(id, Patient) Patient?@get("/patients/{id}")getPatient(id) Patient?@get("/patients/{id}/profile")@policies(isMobile)@entityForId(Patient)getPatientProfileById(id) PatientProfile@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 requiredgender GenderType requiredphoneNumber String required maxlength(20)}event DoctorCreated {doctor Doctor}event PatientCreated {patient Patient}
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"}
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"
ZenWave Scripts for Clinical Tool - Modulith
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"
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"}
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
@inlineinput HospitalId {hospitalId Long required}@inlineinput CriterioPatient {hisNumber String required maxlength(100)
Other interesting features
File Uploads
When decorating a service method with @fileupload like this:
models/documents.zdl
@post({path: "/upload", status: 201})@fileupload("file")uploadDocument(DocumentInfo) DocumentInfo
then ZDLToOpenAPIPlugin will generate this OpenAPI definition:
OpenAPI Definition
required: trueschema:type: "integer"format: "int64"responses:"204":description: "OK"/documents/upload:post:operationId: "uploadDocument"description: "uploadDocument"tags:- "Document"parameters:- name: "uuid"in: "query"required: false
File Downloads
When decorating a service method with @filedownload like this:
models/documents.zdl
@get({path: "/{id}", params: {preview: boolean} })@filedownload("documentData.data")downloadDocument(id) DocumentInfo
then ZDLToOpenAPIPlugin will generate this OpenAPI definition:
OpenAPI Definition
content:application/json:schema:$ref: "#/components/schemas/DocumentInfoList"/documents/{id}:get:operationId: "downloadDocument"description: "downloadDocument"tags:- "Document"parameters:- name: "id"in: "path"required: trueschema: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:
And the OpenAPIControllersPlugin will generate the corresponding controller code to handle file uploads and downloads.
DocumentApiController.java
@Overridepublic 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);}
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
@get("/patients/{id}")getPatient(id) Patient?@get("/patients/{id}/profile")@policies(isMobile)
then ZDLToOpenAPIPlugin will generate this OpenAPI definition:
OpenAPI Definition
.orElseThrow();return patient;}@Transactionalpublic 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
With an EventPublisher that use Spring's ApplicationEventPublisher under the hood (and also an InMemoryEventPublisher for testing purposes):
DefaultEventPublisher.java
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);}}