├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── 1_feature_request.md │ ├── 2_enhancement_request.md │ └── 3_bug_report.md ├── close-label.yml ├── dependabot.yml └── workflows │ ├── dependabot-automation.yml │ ├── docs.yml │ └── main.yml ├── .gitignore ├── .mvn └── wrapper │ ├── MavenWrapperDownloader.java │ └── maven-wrapper.properties ├── .run ├── Gift Card Application - Command Side.run.xml ├── Gift Card Application - Command, Query and GUI.run.xml ├── Gift Card Application - GUI.run.xml └── Gift Card Application - Query Side.run.xml ├── LICENSE ├── README.md ├── axon-data-protection-config.json ├── axon-data-protection-plugin-config.yaml ├── docker └── docker-compose.yml ├── docs ├── _playbook │ ├── .gitignore │ ├── .vale.ini │ ├── package-lock.json │ ├── package.json │ └── playbook.yaml └── tutorial │ ├── antora.yml │ └── modules │ └── ROOT │ ├── examples │ └── src │ ├── nav.adoc │ └── pages │ ├── architecture.adoc │ ├── commands.adoc │ ├── deployment.adoc │ ├── design.adoc │ ├── events.adoc │ ├── index.adoc │ ├── next_steps.adoc │ └── projections.adoc ├── kubernetes └── axonserver.yaml ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── main ├── java │ └── io │ │ └── axoniq │ │ └── demo │ │ └── giftcard │ │ ├── AxonConfig.java │ │ ├── GiftCardApp.java │ │ ├── api │ │ ├── Address.java │ │ ├── CancelCardCommand.java │ │ ├── CardCanceledEvent.java │ │ ├── CardIssuedEvent.java │ │ ├── CardRedeemedEvent.java │ │ ├── CardSummary.java │ │ ├── CountCardSummariesQuery.java │ │ ├── CountCardSummariesResponse.java │ │ ├── ExampleEvent.java │ │ ├── FetchCardSummariesQuery.java │ │ ├── IssueCardCommand.java │ │ └── RedeemCardCommand.java │ │ ├── command │ │ └── GiftCard.java │ │ ├── query │ │ └── CardSummaryProjection.java │ │ └── rest │ │ ├── GiftCardController.java │ │ └── Result.java └── resources │ ├── application-command.properties │ ├── application-gui.properties │ ├── application-query.properties │ ├── application.properties │ └── static │ ├── app.js │ └── index.html └── test └── java └── io └── axoniq └── demo └── giftcard ├── command └── GiftCardTest.java └── query └── CardSummaryProjectionTest.java /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @CodeDrivenMitch 2 | * @MateuszNaKodach 3 | * @smcvb -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1_feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Feature request' 3 | about: 'Suggest a feature for the GiftCard Demo' 4 | title: 5 | labels: 'Type: Feature' 6 | --- 7 | 8 | 9 | 10 | ### Feature Description 11 | 12 | 16 | 17 | ### Current Behaviour 18 | 19 | 20 | 21 | ### Wanted Behaviour 22 | 23 | 24 | 25 | ### Possible Workarounds 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2_enhancement_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Enhancement request' 3 | about: 'Suggest an enhancement/change to an existing feature for the GiftCard Demo' 4 | title: 5 | labels: 'Type: Enhancement' 6 | --- 7 | 8 | 9 | 10 | ### Enhancement Description 11 | 12 | 13 | 14 | ### Current Behaviour 15 | 16 | 17 | 18 | ### Wanted Behaviour 19 | 20 | 21 | 22 | ### Possible Workarounds 23 | 24 | 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3_bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Bug report' 3 | about: 'Report a bug for the GiftCard Demo' 4 | title: 5 | labels: 'Type: Bug' 6 | --- 7 | 8 | 9 | 10 | ### Basic information 11 | 12 | * JDK version: 13 | * Complete executable reproducer if available (e.g. GitHub Repo): 14 | 15 | ### Steps to reproduce 16 | 17 | 21 | 22 | ### Expected behaviour 23 | 24 | 25 | 26 | ### Actual behaviour 27 | 28 | 32 | -------------------------------------------------------------------------------- /.github/close-label.yml: -------------------------------------------------------------------------------- 1 | "Type: Bug": "Status: Resolved" 2 | "Type: Enhancement": "Status: Resolved" 3 | "Type: Feature": "Status: Resolved" 4 | "Type: Dependency Upgrade": "Status: Resolved" -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | day: "sunday" 9 | open-pull-requests-limit: 5 10 | labels: 11 | - "Type: Dependency Upgrade" 12 | - "Priority 1: Must" 13 | milestone: 7 14 | groups: 15 | github-dependencies: 16 | update-types: 17 | - "patch" 18 | - "minor" 19 | - "major" 20 | 21 | - package-ecosystem: maven 22 | directory: "/" 23 | schedule: 24 | interval: weekly 25 | day: "sunday" 26 | open-pull-requests-limit: 5 27 | labels: 28 | - "Type: Dependency Upgrade" 29 | - "Priority 1: Must" 30 | milestone: 7 31 | groups: 32 | maven-dependencies: 33 | update-types: 34 | - "patch" 35 | - "minor" 36 | - "major" 37 | 38 | - package-ecosystem: npm 39 | directory: "/" 40 | schedule: 41 | interval: weekly 42 | day: "sunday" 43 | open-pull-requests-limit: 5 44 | labels: 45 | - "Type: Dependency Upgrade" 46 | - "Priority 1: Must" 47 | milestone: 7 48 | groups: 49 | maven-dependencies: 50 | update-types: 51 | - "patch" 52 | - "minor" 53 | - "major" 54 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-automation.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot Automation 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot-approve: 10 | name: Approve PR 11 | 12 | runs-on: ubuntu-latest 13 | if: ${{ github.actor == 'dependabot[bot]' }} 14 | steps: 15 | - name: Retrieve Dependabot metadata 16 | id: metadata 17 | uses: dependabot/fetch-metadata@v2 18 | with: 19 | github-token: "${{ secrets.GITHUB_TOKEN }}" 20 | 21 | - name: Approve Pull Request 22 | run: gh pr review --approve "$PR_URL" 23 | env: 24 | PR_URL: ${{github.event.pull_request.html_url}} 25 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 26 | 27 | dependabot-auto-merge: 28 | name: Auto Merge 29 | 30 | runs-on: ubuntu-latest 31 | if: ${{ github.actor == 'dependabot[bot]' }} 32 | steps: 33 | - name: Retrieve Dependabot metadata 34 | id: metadata 35 | uses: dependabot/fetch-metadata@v2 36 | with: 37 | github-token: "${{ secrets.GITHUB_TOKEN }}" 38 | 39 | - name: Auto-merge Pull Request 40 | if: ${{steps.metadata.outputs.update-type != 'version-update:semver-major'}} 41 | run: gh pr merge --auto --merge "$PR_URL" 42 | env: 43 | PR_URL: ${{github.event.pull_request.html_url}} 44 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Trigger documentation build 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | paths: 8 | - 'docs/**' 9 | pull_request: 10 | branches: 11 | - 'master' 12 | paths: 13 | - 'docs/**' 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Install Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: '20' 26 | 27 | - name: Install vale 28 | run: | 29 | wget https://github.com/errata-ai/vale/releases/download/v2.23.0/vale_2.23.0_Linux_64-bit.tar.gz 30 | sudo tar -xvzf vale_2.23.0_Linux_64-bit.tar.gz -C /usr/local/bin vale 31 | 32 | - name: Generate Site 33 | run: | 34 | cd docs/_playbook/ 35 | npm install 36 | export GIT_CREDENTIALS='https://axoniq-devops:${{ secrets.LIBRARY_DEVBOT_TOKEN }}@github.com' 37 | npx antora playbook.yaml 38 | 39 | - name: Notify AxonIQ Library (if a push to a tracked branch) 40 | if: ${{ github.event_name == 'push'}} 41 | uses: actions/github-script@v7 42 | with: 43 | github-token: ${{ secrets.LIBRARY_DEVBOT_TOKEN }} 44 | script: | 45 | await github.rest.actions.createWorkflowDispatch({ 46 | owner: 'AxonIQ', 47 | repo: 'axoniq-library-site', 48 | workflow_id: 'publish.yml', 49 | ref: 'main' 50 | }) -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: GiftCard Demo 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | paths-ignore: 10 | - 'docs/**' 11 | - '.github/workflows/docs.yml' 12 | 13 | jobs: 14 | build: 15 | name: Test and Build on JDK ${{ matrix.java-version }} 16 | runs-on: ubuntu-latest 17 | continue-on-error: true # do not fail the whole job if one of the steps fails 18 | 19 | strategy: 20 | matrix: 21 | include: 22 | - java-version: 17 23 | fail-fast: false 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | 29 | - name: Set up JDK ${{ matrix.java-version }} 30 | uses: actions/setup-java@v4.7.0 31 | with: 32 | distribution: 'zulu' 33 | java-version: ${{ matrix.java-version }} 34 | cache: "maven" 35 | server-id: sonatype 36 | server-username: MAVEN_USERNAME 37 | server-password: MAVEN_PASSWORD 38 | 39 | - name: Build and Verify 40 | run: ./mvnw -B -U -Dstyle.color=always clean verify 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | 4 | ### STS ### 5 | .apt_generated 6 | .classpath 7 | .factorypath 8 | .project 9 | .settings 10 | .springBeans 11 | 12 | ### IntelliJ IDEA ### 13 | .idea 14 | *.iws 15 | *.iml 16 | *.ipr 17 | 18 | ### NetBeans ### 19 | nbproject/private/ 20 | build/ 21 | nbbuild/ 22 | dist/ 23 | nbdist/ 24 | .nb-gradle/ 25 | *.jar 26 | docs/_playbook/vale 27 | .DS_Store 28 | .java-version 29 | -------------------------------------------------------------------------------- /.mvn/wrapper/MavenWrapperDownloader.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2007-present the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import java.net.*; 17 | import java.io.*; 18 | import java.nio.channels.*; 19 | import java.util.Properties; 20 | 21 | public class MavenWrapperDownloader { 22 | 23 | private static final String WRAPPER_VERSION = "0.5.6"; 24 | /** 25 | * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. 26 | */ 27 | private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" 28 | + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; 29 | 30 | /** 31 | * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to 32 | * use instead of the default one. 33 | */ 34 | private static final String MAVEN_WRAPPER_PROPERTIES_PATH = 35 | ".mvn/wrapper/maven-wrapper.properties"; 36 | 37 | /** 38 | * Path where the maven-wrapper.jar will be saved to. 39 | */ 40 | private static final String MAVEN_WRAPPER_JAR_PATH = 41 | ".mvn/wrapper/maven-wrapper.jar"; 42 | 43 | /** 44 | * Name of the property which should be used to override the default download url for the wrapper. 45 | */ 46 | private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; 47 | 48 | public static void main(String args[]) { 49 | System.out.println("- Downloader started"); 50 | File baseDirectory = new File(args[0]); 51 | System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); 52 | 53 | // If the maven-wrapper.properties exists, read it and check if it contains a custom 54 | // wrapperUrl parameter. 55 | File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); 56 | String url = DEFAULT_DOWNLOAD_URL; 57 | if(mavenWrapperPropertyFile.exists()) { 58 | FileInputStream mavenWrapperPropertyFileInputStream = null; 59 | try { 60 | mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); 61 | Properties mavenWrapperProperties = new Properties(); 62 | mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); 63 | url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); 64 | } catch (IOException e) { 65 | System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); 66 | } finally { 67 | try { 68 | if(mavenWrapperPropertyFileInputStream != null) { 69 | mavenWrapperPropertyFileInputStream.close(); 70 | } 71 | } catch (IOException e) { 72 | // Ignore ... 73 | } 74 | } 75 | } 76 | System.out.println("- Downloading from: " + url); 77 | 78 | File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); 79 | if(!outputFile.getParentFile().exists()) { 80 | if(!outputFile.getParentFile().mkdirs()) { 81 | System.out.println( 82 | "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); 83 | } 84 | } 85 | System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); 86 | try { 87 | downloadFileFromURL(url, outputFile); 88 | System.out.println("Done"); 89 | System.exit(0); 90 | } catch (Throwable e) { 91 | System.out.println("- Error downloading"); 92 | e.printStackTrace(); 93 | System.exit(1); 94 | } 95 | } 96 | 97 | private static void downloadFileFromURL(String urlString, File destination) throws Exception { 98 | if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { 99 | String username = System.getenv("MVNW_USERNAME"); 100 | char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); 101 | Authenticator.setDefault(new Authenticator() { 102 | @Override 103 | protected PasswordAuthentication getPasswordAuthentication() { 104 | return new PasswordAuthentication(username, password); 105 | } 106 | }); 107 | } 108 | URL website = new URL(urlString); 109 | ReadableByteChannel rbc; 110 | rbc = Channels.newChannel(website.openStream()); 111 | FileOutputStream fos = new FileOutputStream(destination); 112 | fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); 113 | fos.close(); 114 | rbc.close(); 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.1/apache-maven-3.8.1-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar 3 | -------------------------------------------------------------------------------- /.run/Gift Card Application - Command Side.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | -------------------------------------------------------------------------------- /.run/Gift Card Application - Command, Query and GUI.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | -------------------------------------------------------------------------------- /.run/Gift Card Application - GUI.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | -------------------------------------------------------------------------------- /.run/Gift Card Application - Query Side.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Getting started with Axon 2 | ========================= 3 | 4 | This Axon Framework demo application focuses around a simple giftcard domain, designed to show various aspects of the framework. 5 | The app can be run in various modes, using [Spring-boot Profiles](https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-profiles.html): by selecting a specific profile, only the corresponding parts of the app will be active. 6 | Select none, and the default behaviour is activated, which activates everything. 7 | This way you can experiment with Axon in a (structured) monolith as well as in micro-services. 8 | 9 | Where to find more information: 10 | ------------------------------- 11 | 12 | * The [Axon Reference Guide](https://docs.axoniq.io/reference-guide/) is definitive guide on the Axon Framework and Axon Server. 13 | * Visit [www.axoniq.io](https://www.axoniq.io) to find out about AxonIQ, the team behind the Axon Framework and Server. 14 | * Subscribe to the [AxonIQ YouTube channel](https://www.youtube.com/AxonIQ) to get the latest Webinars, announcements, and customer stories. 15 | * To start a fresh Axon Application, you can go to [start.axoniq.io](https://start.axoniq.io/). 16 | * Additional information may be gained by following some of AxonIQ's courses on the [AxonIQ Academy](https://academy.axoniq.io/). 17 | * The latest version of the Giftcard App can be found [on GitHub](https://github.com/AxonIQ/giftcard-demo). 18 | * Docker images for Axon Server are pushed to [Docker Hub](https://hub.docker.com/u/axoniq). 19 | * If there are any Axon related questions remaining, you can always go to the [forum](https://discuss.axoniq.io/). 20 | 21 | The Giftcard app 22 | ---------------- 23 | 24 | ### Background story 25 | 26 | See [the wikipedia article](https://en.wikipedia.org/wiki/Gift_card) for a basic definition of gift cards. Essentially, there are just two events in the life cycle of a gift card: 27 | * They get _issued_: a new gift card gets created with some amount of money stored. 28 | * They get _redeemed_: all or part of the monetary value stored on the gift card is used to purchase something. 29 | 30 | ### Structure of the App 31 | 32 | The Giftcard application is split into four parts, using four sub-packages of `io.axoniq.demo.giftcard`: 33 | * The `api` package contains the ([Java Records](https://www.baeldung.com/java-record-keyword)) sourcecode of the messages and entity. They form the API (sic) of the application. 34 | * The `command` package contains the GiftCard Aggregate class, with all command- and associated eventsourcing handlers. 35 | * The `query` package provides the query handlers, with their associated event handlers. 36 | * The `rest` package contains the [Spring Webflux](https://www.baeldung.com/spring-webflux)-based Web API. 37 | 38 | Of these packages, `command`, `query`, and `gui` (enabling the `rest` package) are also configured as profiles. 39 | 40 | ### Building the Giftcard app from the sources 41 | 42 | To build the demo app, simply run the provided [Maven wrapper](https://www.baeldung.com/maven-wrapper): 43 | 44 | ``` 45 | ./mvnw clean package 46 | ``` 47 | 48 | Note that the Giftcard app expects JDK 17 to be used. 49 | 50 | Running the Giftcard app 51 | ------------------------ 52 | 53 | The simplest way to run the app is by using the Spring-boot maven plugin: 54 | 55 | ``` 56 | ./mvnw spring-boot:run 57 | ``` 58 | However, if you have copied the jar file `giftcard-demo-4.8.jar` from the Maven `target` directory to some other location, you can also start it with: 59 | 60 | ``` 61 | java -jar giftcard-demo-4.8.jar 62 | ``` 63 | The Web GUI can be found at [`http://localhost:8080`](http://localhost:8080). 64 | 65 | If you want to activate only the `command` profile, use: 66 | 67 | ``` 68 | java -Dspring.profiles.active=command -jar giftcard-demo-4.8.jar 69 | ``` 70 | Idem for `query` and `gui`. 71 | 72 | ### Running the Giftcard app as microservices 73 | 74 | To run the Giftcard app as if it were three separate microservices, use the Spring-boot `spring.profiles.active` option as follows: 75 | 76 | ``` 77 | $ java -Dspring.profiles.active=command -jar giftcard-demo-4.8.jar 78 | ``` 79 | This will start only the command part. To complete the app, open two other command shells, and start one with profile `query`, and the last one with `gui`. Again you can open the Web GUI at [`http://localhost:8080`](http://localhost:8080). The three parts of the application work together through the running instance of the Axon Server, which distributes the Commands, Queries, and Events. 80 | It's also possible to explore the REST API using [Swagger](http://localhost:8080/webjars/swagger-ui/index.html) or get the [Open Api definition](http://localhost:8080/v3/api-docs) to create a client. 81 | 82 | Running Axon Server 83 | ------------------ 84 | 85 | By default, the Axon Framework is configured to expect a running Axon Server instance, and it will complain if the server is not found. 86 | To run Axon Server, you'll need a Java runtime. 87 | A copy of the server JAR file has been provided in the demo package. 88 | You can run it locally, in a Docker container (including Kubernetes or even Mini-kube), or on a separate server. 89 | 90 | The section below give a fair description on how to run Axon Server for this sample project. 91 | If you are looking for more in depth information on the subject, we recommend this three-part blog series: 92 | 93 | 1. [Running Axon Server - Going from local developer install to full-featured cluster in the cloud](https://axoniq.io/blog-overview/running-axon-server) 94 | 2. [Running Axon Server in Docker - Continuing from local developer install to containerized](https://axoniq.io/blog-overview/running-axon-server-in-docker) 95 | 3. [Running Axon Server in a Virtual Machine](https://axoniq.io/blog-overview/running-axon-server-in-a-virtual-machine) 96 | 97 | ### Running Axon Server locally 98 | 99 | To run Axon Server locally, all you need to do is put the server JAR file in the directory where you want it to live, and start it using: 100 | 101 | ``` 102 | java -jar axonserver.jar 103 | ``` 104 | 105 | You will see that it creates a subdirectory `data` where it will store its information. 106 | 107 | ### Running Axon Server in a Docker container 108 | 109 | To run Axon Server in Docker you can use the image provided on Docker Hub: 110 | 111 | ``` 112 | $ docker run -d --name my-axon-server -p 8024:8024 -p 8124:8124 axoniq/axonserver 113 | ...some container id... 114 | $ 115 | ``` 116 | 117 | *WARNING* This is not a supported image for production purposes. Please use with caution. 118 | 119 | If you want to run the clients in Docker containers as well, and are not using something like Kubernetes, use the "`--hostname`" option of the `docker` command to set a useful name like "axonserver", and pass the `AXONSERVER_HOSTNAME` environment variable to adjust the properties accordingly: 120 | 121 | ``` 122 | $ docker run -d --name my-axon-server -p 8024:8024 -p 8124:8124 --hostname axonserver -e AXONSERVER_HOSTNAME=axonserver axoniq/axonserver 123 | ``` 124 | 125 | When you start the client containers, you can now use "`--link axonserver`" to provide them with the correct DNS entry. 126 | The Axon Server Connector looks at the "`axon.axonserver.servers`" property to determine where Axon Server lives, so don't forget to set it to "`axonserver`". 127 | 128 | ### Running Axon Server in Kubernetes and Mini-Kube 129 | 130 | *WARNING*: Although you can get a pretty functional cluster running locally using Mini-Kube, you can run into trouble when you want to let it serve clients outside the cluster. 131 | Mini-Kube can provide access to HTTP servers running in the cluster, for other protocols you have to run a special protocol-agnostic proxy like you can with "`kubectl port-forward` _<pod-name>_ _<port-number>_". 132 | Thus, for non-development scenarios, we don't recommend using Mini-Kube. 133 | 134 | Deployment requires the use of a YAML descriptor, a working example of which can be found in the "`kubernetes`" directory. 135 | To run it, use the following commands in a separate window: 136 | 137 | ``` 138 | $ kubectl apply -f kubernetes/axonserver.yaml 139 | statefulset.apps "axonserver" created 140 | service "axonserver-gui" created 141 | service "axonserver" created 142 | $ kubectl port-forward axonserver-0 8124 143 | Forwarding from 127.0.0.1:8124 -> 8124 144 | Forwarding from [::1]:8124 -> 8124 145 | ``` 146 | 147 | You can now run the Giftcard app, which will connect through the proxied gRPC port. 148 | To see the Axon Server Web GUI, use "`minikube service --url axonserver-gui`" to obtain the URL for your browser. 149 | Actually, if you leave out the "`--url`", minikube will open the GUI in your default browser for you. 150 | 151 | To clean up the deployment, use: 152 | 153 | ``` 154 | $ kubectl delete sts axonserver 155 | statefulset.apps "axonserver" deleted 156 | $ kubectl delete svc axonserver 157 | service "axonserver" deleted 158 | $ kubectl delete svc axonserver-gui 159 | service "axonserver-gui" deleted 160 | ``` 161 | 162 | If you're using a 'real' Kubernetes cluster, you'll naturally not want to use "`localhost`" as hostname for Axon Server, so you need to add three lines to the container spec to specify the "`AXONSERVER_HOSTNAME`" setting: 163 | 164 | ``` 165 | ... 166 | containers: 167 | - name: axonserver 168 | image: axoniq/axonserver 169 | imagePullPolicy: Always 170 | ports: 171 | - name: grpc 172 | containerPort: 8124 173 | protocol: TCP 174 | - name: gui 175 | containerPort: 8024 176 | protocol: TCP 177 | readinessProbe: 178 | httpGet: 179 | port: 8024 180 | path: /actuator/health 181 | initialDelaySeconds: 5 182 | periodSeconds: 5 183 | timeoutSeconds: 1 184 | env: 185 | - name: AXONSERVER_HOSTNAME 186 | value: axonserver 187 | --- 188 | apiVersion: v1 189 | kind: Service 190 | ... 191 | ``` 192 | 193 | Use "`axonserver`" (as that is the name of the Kubernetes service) if you're going to deploy the client next to the server in the cluster, which is what you'd probably want. 194 | Running the client outside the cluster, with Axon Server *inside*, entails extra work to enable and secure this, and is definitely beyond the scope of this example. 195 | 196 | Configuring Axon Server 197 | ----------------------- 198 | 199 | Axon Server uses sensible defaults for all of its settings, so it will actually run fine without any further configuration. 200 | However, if you want to make some changes, below are the most common options. 201 | For an exhaustive list, we recommend checking out the [Configuration section](https://docs.axoniq.io/reference-guide/axon-server/administration/admin-configuration/configuration) of the Reference Guide. 202 | 203 | ### Environment variables for customizing the Docker image of Axon Server 204 | 205 | The `axoniq/axonserver` image can be customized at start by using one of the following environment variables. 206 | If no default is mentioned, leaving the environment variable unspecified will not add a line to the properties file. 207 | 208 | * `AXONSERVER_NAME` 209 | 210 | This is the name the Axon Server uses for itself. 211 | * `AXONSERVER_HOSTNAME` 212 | 213 | This is the hostname Axon Server communicates to the client as its contact point. Default is "`localhost`", because Docker generates a random name that is not resolvable outside the container. 214 | * `AXONSERVER_DOMAIN` 215 | 216 | This is the domain Axon Server can suffix the hostname with. 217 | * `AXONSERVER_HTTP_PORT` 218 | 219 | This is the port Axon Server uses for its Web GUI and REST API. 220 | * `AXONSERVER_GRPC_PORT` 221 | 222 | This is the gRPC port used by clients to exchange data with the server. 223 | * `AXONSERVER_TOKEN` 224 | 225 | Setting this will enable access control, which means the clients need to pass this token with each request. 226 | * `AXONSERVER_EVENTSTORE` 227 | 228 | This is the directory used for storing the Events. 229 | * `AXONSERVER_CONTROLDB` 230 | 231 | This is where Axon Server stores information of clients and what types of messages they are interested in. 232 | 233 | ### Axon Server configuration 234 | 235 | There are a number of things you can fine-tune in the server configuration. You can do this using an "`axonserver.properties`" file. 236 | All settings have sensible defaults. 237 | 238 | * `axoniq.axonserver.name` 239 | 240 | This is the name Axon Server uses for itself. The default is to use the hostname. 241 | * `axoniq.axonserver.hostname` 242 | 243 | This is the hostname clients will use to connect to the server. Note that an IP address can be used if the name cannot be resolved through DNS. The default value is the actual hostname reported by the OS. 244 | * `server.port` 245 | 246 | This is the port where Axon Server will listen for HTTP requests, by default `8024`. 247 | * `axoniq.axonserver.port` 248 | 249 | This is the port where Axon Server will listen for gRPC requests, by default `8124`. 250 | * `axoniq.axonserver.event.storage` 251 | 252 | This setting determines where event messages are stored, so make sure there is enough diskspace here. Losing this data means losing your Events-sourced Aggregates' state! Conversely, if you want a quick way to start from scratch, here's where to clean. 253 | * `axoniq.axonserver.controldb-path` 254 | 255 | This setting determines where the message hub stores its information. Losing this data will affect Axon Server's ability to determine which applications are connected, and what types of messages they are interested in. 256 | * `axoniq.axonserver.accesscontrol.enabled` 257 | 258 | Setting this to `true` will require clients to pass a token. 259 | * `axoniq.axonserver.accesscontrol.token` 260 | 261 | This is the token used for access control. 262 | 263 | ### The Axon Server HTTP server 264 | 265 | Axon Server provides two servers; one serving HTTP requests, the other gRPC. 266 | By default, these use ports 8024 and 8124 respectively, but you can change these in the settings. 267 | 268 | The HTTP server has in its root context a management Web GUI, a health indicator is available at `/actuator/health`, and the REST API at `/v1`. 269 | The API's Swagger endpoint finally, is available at `/swagger-ui.html`, and gives the documentation on the REST API. 270 | 271 | Data protection plugin 272 | ------------------- 273 | The data protection plugin can serve as an alternative to the Data Protection Module commonly used inside Axon application. 274 | 275 | ### Data protection plugin config generation 276 | The data protection maven plugin has been added to this project. It will automatically create a configuration output called `axon-data-protection-config.json` during the `compile` phase of the Maven Lifecycle. This output should be used for the configuration of the Data Protection Plugin on Axon Server. 277 | 278 | Since at this moment the `dataprotection-config-api` and `dataprotection-maven-plugin` do not have any releases available on public repositories, these two projects will first need to be run locally to install these dependencies. Their repositories can be found here: 279 | - https://github.com/AxonIQ/axon-dataprotection-config-api 280 | - https://github.com/AxonIQ/axon-dataprotection-maven-plugin/ 281 | 282 | Two events have been included in the sample: 283 | - `RedeemedEvent`, an event used by the application 284 | - `ExampleEvent`, an unused event, but with a more complex data structure to show the `config-api` usage in more detail. 285 | 286 | ### Using the data protection plugin in Axon Server 287 | Running the docker-compose.yml file (rather than the kubernetes deployment) found in the docker directory will bring up an instance of axon server and and instance 288 | of vault to be able to run the example. 289 | 290 | Use the axonserver-cli to upload and configure the data protection plugin (taken from https://docs.axoniq.io/reference-guide/axon-server/administration/plugins#plugin-administration) 291 | 292 | Upload the data protection plugin 293 | ``` 294 | java -jar axonserver-cli.jar upload-plugin -t $(cat ./axonserver.token) -f axon-server-plugin-data-protection.jar -S https://localhost:8024 -i 295 | ``` 296 | 297 | Configure the plugin (https://docs.axoniq.io/reference-guide/axon-server/administration/plugins#configuring-a-plugin) 298 | - update the axon-data-protection-plugin-config.yaml file 299 | - set the vault configuration for your environment (if running the docker example, the vault token must match what is defined in the docker-compose file) 300 | - add the contents of the axon-data-protection-config.json file 301 | - run the following command to upload the configuration 302 | ``` 303 | java -jar ./axonserver-cli.jar configure-plugin -t $(cat ./axonserver.token) -p io.axoniq.axon-server-plugin-data-protection -v 1.0.0.SNAPSHOT -S https://localhost:8024 -i -c default -f axon-data-protection-config.yaml 304 | ``` 305 | 306 | Activate the plugin for a context with the -c flag (https://docs.axoniq.io/reference-guide/axon-server/administration/plugins#activating-a-plugin) 307 | ``` 308 | java -jar ./axonserver-cli.jar activate-plugin -t $(cat ./axonserver.token) -p io.axoniq.axon-server-plugin-data-protection -v 1.0.0.SNAPSHOT -S https://localhost:8024 -i -c default 309 | ``` 310 | 311 | -------------------------------------------------------------------------------- /axon-data-protection-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "config" : [ { 3 | "type" : "io.axoniq.demo.giftcard.api.CardRedeemedEvent", 4 | "revision" : "", 5 | "subjectId" : { 6 | "path" : "$.id" 7 | }, 8 | "sensitiveData" : [ { 9 | "path" : "$.amount", 10 | "replacementValue" : "hidden amount" 11 | } ] 12 | }, { 13 | "type" : "io.axoniq.demo.giftcard.api.ExampleEvent", 14 | "revision" : "1", 15 | "subjectId" : { 16 | "path" : "$.id" 17 | }, 18 | "sensitiveData" : [ { 19 | "path" : "$.ssn", 20 | "replacementValue" : "" 21 | }, { 22 | "path" : "$.address.addressLine1", 23 | "replacementValue" : "" 24 | } ] 25 | } ] 26 | } -------------------------------------------------------------------------------- /axon-data-protection-plugin-config.yaml: -------------------------------------------------------------------------------- 1 | Vault configuration: 2 | address: http://somevaultserver:9430 3 | token: token4context 4 | prefix: prefix4context 5 | MetaModel configuration: 6 | metamodel: 'paste raw json contents of axon-data-protection-config.json here' -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | axonserver: 4 | image: axoniq/axonserver:latest 5 | hostname: axonserver 6 | environment: 7 | - SPRING_SERVLET_MULTIPART_MAX-FILE-SIZE=35MB 8 | - SPRING_SERVLET_MULTIPART_MAX-REQUEST-SIZE=35MB 9 | - AXONIQ_AXONSERVER_DEVMODE_ENABLED=TRUE 10 | ports: 11 | - '8024:8024' 12 | - '8124:8124' 13 | networks: 14 | - axon-server-plugin-data-protection 15 | 16 | vault: 17 | image: vault:latest 18 | hostname: vault 19 | ports: 20 | - '8200:8200' 21 | environment: 22 | - VAULT_DEV_ROOT_TOKEN_ID=localVaultToken 23 | networks: 24 | - axon-server-plugin-data-protection 25 | 26 | networks: 27 | axon-server-plugin-data-protection: -------------------------------------------------------------------------------- /docs/_playbook/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | .vscode 4 | vale 5 | -------------------------------------------------------------------------------- /docs/_playbook/.vale.ini: -------------------------------------------------------------------------------- 1 | StylesPath = vale 2 | 3 | MinAlertLevel = suggestion 4 | 5 | Packages = http://github.com/AxonIQ/axoniq-vale-package/releases/latest/download/axoniq-vale-package.zip 6 | 7 | [*.{adoc,html}] 8 | BasedOnStyles = AxonIQ 9 | -------------------------------------------------------------------------------- /docs/_playbook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@antora/atlas-extension": "^1.0.0-alpha.2", 4 | "@antora/cli": "^3.2.0-alpha.2", 5 | "@antora/lunr-extension": "^1.0.0-alpha.8", 6 | "@antora/site-generator": "^3.2.0-alpha.2", 7 | "@asciidoctor/tabs": "^1.0.0-beta.6", 8 | "@axoniq/antora-vale-extension": "^0.1.2", 9 | "asciidoctor-kroki": "^0.17.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /docs/_playbook/playbook.yaml: -------------------------------------------------------------------------------- 1 | site: 2 | title: Giftcard demo docs PREVIEW 3 | start_page: giftcard::index.adoc 4 | 5 | content: 6 | sources: 7 | - url: ../.. 8 | start_paths: ['docs/*', '!docs/_playbook'] 9 | 10 | asciidoc: 11 | attributes: 12 | experimental: true 13 | page-pagination: true 14 | kroki-fetch-diagram: true 15 | primary-site-manifest-url: https://library.axoniq.io/site-manifest.json 16 | extensions: 17 | - asciidoctor-kroki 18 | - '@asciidoctor/tabs' 19 | 20 | antora: 21 | extensions: 22 | - id: prose-linting 23 | require: '@axoniq/antora-vale-extension' 24 | enabled: true 25 | vale_config: .vale.ini 26 | update_styles: true 27 | - id: lunr 28 | require: '@antora/lunr-extension' 29 | enabled: true 30 | index_latest_only: true 31 | - id: atlas 32 | require: '@antora/atlas-extension' 33 | 34 | runtime: 35 | fetch: true # fetch remote repos 36 | log: 37 | level: info 38 | failure_level: error 39 | 40 | ui: 41 | bundle: 42 | url: https://github.com/AxonIQ/axoniq-library-ui/releases/download/v.0.0.9/ui-bundle.zip 43 | -------------------------------------------------------------------------------- /docs/tutorial/antora.yml: -------------------------------------------------------------------------------- 1 | name: giftcard 2 | title: Building a Giftcard Application 3 | version: true 4 | 5 | asciidoc: 6 | attributes: 7 | component_description: Getting started with designing, building, and deploying Axon applications 8 | type: tutorial 9 | group: beginner 10 | 11 | 12 | nav: 13 | - modules/ROOT/nav.adoc 14 | -------------------------------------------------------------------------------- /docs/tutorial/modules/ROOT/examples/src: -------------------------------------------------------------------------------- 1 | ../../../../../src -------------------------------------------------------------------------------- /docs/tutorial/modules/ROOT/nav.adoc: -------------------------------------------------------------------------------- 1 | * xref:index.adoc[] 2 | * xref:design.adoc[] 3 | * xref:commands.adoc[] 4 | * xref:events.adoc[] 5 | * xref:projections.adoc[] 6 | * xref:architecture.adoc[] 7 | * xref:deployment.adoc[] 8 | * xref:next_steps.adoc[] 9 | -------------------------------------------------------------------------------- /docs/tutorial/modules/ROOT/pages/architecture.adoc: -------------------------------------------------------------------------------- 1 | = Architecture 2 | :page-needs-improvement: content 3 | :page-needs-content: This page is a placeholder. Please add content. -------------------------------------------------------------------------------- /docs/tutorial/modules/ROOT/pages/commands.adoc: -------------------------------------------------------------------------------- 1 | = Commands 2 | :page-needs-improvement: content 3 | :page-needs-content: This page is a placeholder. Add meaningful content. 4 | 5 | 6 | == Command definition 7 | 8 | [source,java] 9 | ---- 10 | include::example$src/main/java/io/axoniq/demo/giftcard/api/IssueCardCommand.java[tag=IssueCardCommand] 11 | ---- 12 | 13 | [source,java] 14 | ---- 15 | include::example$src/main/java/io/axoniq/demo/giftcard/api/RedeemCardCommand.java[tag=RedeemCardCommand] 16 | ---- 17 | 18 | [source,java] 19 | ---- 20 | include::example$src/main/java/io/axoniq/demo/giftcard/api/CancelCardCommand.java[tag=CancelCardCommand] 21 | ---- 22 | 23 | == Command handlers 24 | 25 | === Issue card 26 | 27 | 28 | [source,java] 29 | ---- 30 | include::example$src/main/java/io/axoniq/demo/giftcard/command/GiftCard.java[tag=IssueCardCommandHandler] 31 | ---- 32 | 33 | === Redeem card 34 | 35 | 36 | [source,java] 37 | ---- 38 | include::example$src/main/java/io/axoniq/demo/giftcard/command/GiftCard.java[tag=RedeemCardCommandHandler] 39 | ---- -------------------------------------------------------------------------------- /docs/tutorial/modules/ROOT/pages/deployment.adoc: -------------------------------------------------------------------------------- 1 | = Deployment 2 | :page-needs-improvement: content 3 | :page-needs-content: This page is a placeholder. Please add meaningful content. 4 | -------------------------------------------------------------------------------- /docs/tutorial/modules/ROOT/pages/design.adoc: -------------------------------------------------------------------------------- 1 | = Design 2 | :page-needs-improvement: content 3 | :page-needs-content: This page is a placeholder. Add meaningful content. 4 | -------------------------------------------------------------------------------- /docs/tutorial/modules/ROOT/pages/events.adoc: -------------------------------------------------------------------------------- 1 | = Events 2 | :page-needs-improvement: content 3 | :page-needs-content: This page is a placeholder. Add meaningful content. 4 | -------------------------------------------------------------------------------- /docs/tutorial/modules/ROOT/pages/index.adoc: -------------------------------------------------------------------------------- 1 | = Introduction 2 | :navtitle: Introduction 3 | :reftext: Building a Giftcard Application 4 | 5 | Building a new application can be a daunting task. This tutorial walks you trough the various phases of development, explains the capabilities of Axon Framework and provides useful tips and tricks along the way. 6 | 7 | The result is a simple application you deploy in "production". Then the tutorial teaches you how to evolve the application, make changes and deploy those in different scenarios. -------------------------------------------------------------------------------- /docs/tutorial/modules/ROOT/pages/next_steps.adoc: -------------------------------------------------------------------------------- 1 | = Next Steps 2 | :page-needs-improvement: content 3 | :page-needs-content: This page is a placeholder. Add meaningful content. 4 | -------------------------------------------------------------------------------- /docs/tutorial/modules/ROOT/pages/projections.adoc: -------------------------------------------------------------------------------- 1 | = Projections 2 | :page-needs-improvement: content 3 | :page-needs-content: This page is a placeholder. Add meaningful content. 4 | -------------------------------------------------------------------------------- /kubernetes/axonserver.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: StatefulSet 4 | metadata: 5 | name: axonserver 6 | labels: 7 | app: axonserver 8 | spec: 9 | serviceName: axonserver 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: axonserver 14 | template: 15 | metadata: 16 | labels: 17 | app: axonserver 18 | spec: 19 | containers: 20 | - name: axonserver 21 | image: axoniq/axonserver 22 | imagePullPolicy: Always 23 | ports: 24 | - name: grpc 25 | containerPort: 8124 26 | protocol: TCP 27 | - name: gui 28 | containerPort: 8024 29 | protocol: TCP 30 | readinessProbe: 31 | httpGet: 32 | port: 8024 33 | path: /actuator/health 34 | initialDelaySeconds: 5 35 | periodSeconds: 5 36 | timeoutSeconds: 1 37 | --- 38 | apiVersion: v1 39 | kind: Service 40 | metadata: 41 | name: axonserver-gui 42 | labels: 43 | app: axonserver-gui 44 | spec: 45 | ports: 46 | - name: gui 47 | port: 8024 48 | targetPort: 8024 49 | selector: 50 | app: axonserver 51 | type: LoadBalancer 52 | --- 53 | apiVersion: v1 54 | kind: Service 55 | metadata: 56 | name: axonserver 57 | labels: 58 | app: axonserver 59 | spec: 60 | ports: 61 | - name: grpc 62 | port: 8124 63 | targetPort: 8124 64 | clusterIP: None 65 | selector: 66 | app: axonserver 67 | --- -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # https://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Maven Start Up Batch script 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # M2_HOME - location of maven2's installed home dir 31 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 32 | # e.g. to debug Maven itself, use 33 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 34 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 35 | # ---------------------------------------------------------------------------- 36 | 37 | if [ -z "$MAVEN_SKIP_RC" ] ; then 38 | 39 | if [ -f /etc/mavenrc ] ; then 40 | . /etc/mavenrc 41 | fi 42 | 43 | if [ -f "$HOME/.mavenrc" ] ; then 44 | . "$HOME/.mavenrc" 45 | fi 46 | 47 | fi 48 | 49 | # OS specific support. $var _must_ be set to either true or false. 50 | cygwin=false; 51 | darwin=false; 52 | mingw=false 53 | case "`uname`" in 54 | CYGWIN*) cygwin=true ;; 55 | MINGW*) mingw=true;; 56 | Darwin*) darwin=true 57 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 58 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 59 | if [ -z "$JAVA_HOME" ]; then 60 | if [ -x "/usr/libexec/java_home" ]; then 61 | export JAVA_HOME="`/usr/libexec/java_home`" 62 | else 63 | export JAVA_HOME="/Library/Java/Home" 64 | fi 65 | fi 66 | ;; 67 | esac 68 | 69 | if [ -z "$JAVA_HOME" ] ; then 70 | if [ -r /etc/gentoo-release ] ; then 71 | JAVA_HOME=`java-config --jre-home` 72 | fi 73 | fi 74 | 75 | if [ -z "$M2_HOME" ] ; then 76 | ## resolve links - $0 may be a link to maven's home 77 | PRG="$0" 78 | 79 | # need this for relative symlinks 80 | while [ -h "$PRG" ] ; do 81 | ls=`ls -ld "$PRG"` 82 | link=`expr "$ls" : '.*-> \(.*\)$'` 83 | if expr "$link" : '/.*' > /dev/null; then 84 | PRG="$link" 85 | else 86 | PRG="`dirname "$PRG"`/$link" 87 | fi 88 | done 89 | 90 | saveddir=`pwd` 91 | 92 | M2_HOME=`dirname "$PRG"`/.. 93 | 94 | # make it fully qualified 95 | M2_HOME=`cd "$M2_HOME" && pwd` 96 | 97 | cd "$saveddir" 98 | # echo Using m2 at $M2_HOME 99 | fi 100 | 101 | # For Cygwin, ensure paths are in UNIX format before anything is touched 102 | if $cygwin ; then 103 | [ -n "$M2_HOME" ] && 104 | M2_HOME=`cygpath --unix "$M2_HOME"` 105 | [ -n "$JAVA_HOME" ] && 106 | JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 107 | [ -n "$CLASSPATH" ] && 108 | CLASSPATH=`cygpath --path --unix "$CLASSPATH"` 109 | fi 110 | 111 | # For Mingw, ensure paths are in UNIX format before anything is touched 112 | if $mingw ; then 113 | [ -n "$M2_HOME" ] && 114 | M2_HOME="`(cd "$M2_HOME"; pwd)`" 115 | [ -n "$JAVA_HOME" ] && 116 | JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" 117 | fi 118 | 119 | if [ -z "$JAVA_HOME" ]; then 120 | javaExecutable="`which javac`" 121 | if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then 122 | # readlink(1) is not available as standard on Solaris 10. 123 | readLink=`which readlink` 124 | if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then 125 | if $darwin ; then 126 | javaHome="`dirname \"$javaExecutable\"`" 127 | javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" 128 | else 129 | javaExecutable="`readlink -f \"$javaExecutable\"`" 130 | fi 131 | javaHome="`dirname \"$javaExecutable\"`" 132 | javaHome=`expr "$javaHome" : '\(.*\)/bin'` 133 | JAVA_HOME="$javaHome" 134 | export JAVA_HOME 135 | fi 136 | fi 137 | fi 138 | 139 | if [ -z "$JAVACMD" ] ; then 140 | if [ -n "$JAVA_HOME" ] ; then 141 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 142 | # IBM's JDK on AIX uses strange locations for the executables 143 | JAVACMD="$JAVA_HOME/jre/sh/java" 144 | else 145 | JAVACMD="$JAVA_HOME/bin/java" 146 | fi 147 | else 148 | JAVACMD="`which java`" 149 | fi 150 | fi 151 | 152 | if [ ! -x "$JAVACMD" ] ; then 153 | echo "Error: JAVA_HOME is not defined correctly." >&2 154 | echo " We cannot execute $JAVACMD" >&2 155 | exit 1 156 | fi 157 | 158 | if [ -z "$JAVA_HOME" ] ; then 159 | echo "Warning: JAVA_HOME environment variable is not set." 160 | fi 161 | 162 | CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher 163 | 164 | # traverses directory structure from process work directory to filesystem root 165 | # first directory with .mvn subdirectory is considered project base directory 166 | find_maven_basedir() { 167 | 168 | if [ -z "$1" ] 169 | then 170 | echo "Path not specified to find_maven_basedir" 171 | return 1 172 | fi 173 | 174 | basedir="$1" 175 | wdir="$1" 176 | while [ "$wdir" != '/' ] ; do 177 | if [ -d "$wdir"/.mvn ] ; then 178 | basedir=$wdir 179 | break 180 | fi 181 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 182 | if [ -d "${wdir}" ]; then 183 | wdir=`cd "$wdir/.."; pwd` 184 | fi 185 | # end of workaround 186 | done 187 | echo "${basedir}" 188 | } 189 | 190 | # concatenates all lines of a file 191 | concat_lines() { 192 | if [ -f "$1" ]; then 193 | echo "$(tr -s '\n' ' ' < "$1")" 194 | fi 195 | } 196 | 197 | BASE_DIR=`find_maven_basedir "$(pwd)"` 198 | if [ -z "$BASE_DIR" ]; then 199 | exit 1; 200 | fi 201 | 202 | ########################################################################################## 203 | # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 204 | # This allows using the maven wrapper in projects that prohibit checking in binary data. 205 | ########################################################################################## 206 | if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then 207 | if [ "$MVNW_VERBOSE" = true ]; then 208 | echo "Found .mvn/wrapper/maven-wrapper.jar" 209 | fi 210 | else 211 | if [ "$MVNW_VERBOSE" = true ]; then 212 | echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." 213 | fi 214 | if [ -n "$MVNW_REPOURL" ]; then 215 | jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 216 | else 217 | jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 218 | fi 219 | while IFS="=" read key value; do 220 | case "$key" in (wrapperUrl) jarUrl="$value"; break ;; 221 | esac 222 | done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" 223 | if [ "$MVNW_VERBOSE" = true ]; then 224 | echo "Downloading from: $jarUrl" 225 | fi 226 | wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" 227 | if $cygwin; then 228 | wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` 229 | fi 230 | 231 | if command -v wget > /dev/null; then 232 | if [ "$MVNW_VERBOSE" = true ]; then 233 | echo "Found wget ... using wget" 234 | fi 235 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 236 | wget "$jarUrl" -O "$wrapperJarPath" 237 | else 238 | wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" 239 | fi 240 | elif command -v curl > /dev/null; then 241 | if [ "$MVNW_VERBOSE" = true ]; then 242 | echo "Found curl ... using curl" 243 | fi 244 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 245 | curl -o "$wrapperJarPath" "$jarUrl" -f 246 | else 247 | curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f 248 | fi 249 | 250 | else 251 | if [ "$MVNW_VERBOSE" = true ]; then 252 | echo "Falling back to using Java to download" 253 | fi 254 | javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" 255 | # For Cygwin, switch paths to Windows format before running javac 256 | if $cygwin; then 257 | javaClass=`cygpath --path --windows "$javaClass"` 258 | fi 259 | if [ -e "$javaClass" ]; then 260 | if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 261 | if [ "$MVNW_VERBOSE" = true ]; then 262 | echo " - Compiling MavenWrapperDownloader.java ..." 263 | fi 264 | # Compiling the Java class 265 | ("$JAVA_HOME/bin/javac" "$javaClass") 266 | fi 267 | if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 268 | # Running the downloader 269 | if [ "$MVNW_VERBOSE" = true ]; then 270 | echo " - Running MavenWrapperDownloader.java ..." 271 | fi 272 | ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") 273 | fi 274 | fi 275 | fi 276 | fi 277 | ########################################################################################## 278 | # End of extension 279 | ########################################################################################## 280 | 281 | export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} 282 | if [ "$MVNW_VERBOSE" = true ]; then 283 | echo $MAVEN_PROJECTBASEDIR 284 | fi 285 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 286 | 287 | # For Cygwin, switch paths to Windows format before running java 288 | if $cygwin; then 289 | [ -n "$M2_HOME" ] && 290 | M2_HOME=`cygpath --path --windows "$M2_HOME"` 291 | [ -n "$JAVA_HOME" ] && 292 | JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` 293 | [ -n "$CLASSPATH" ] && 294 | CLASSPATH=`cygpath --path --windows "$CLASSPATH"` 295 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 296 | MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` 297 | fi 298 | 299 | # Provide a "standardized" way to retrieve the CLI args that will 300 | # work with both Windows and non-Windows executions. 301 | MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" 302 | export MAVEN_CMD_LINE_ARGS 303 | 304 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 305 | 306 | exec "$JAVACMD" \ 307 | $MAVEN_OPTS \ 308 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 309 | "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 310 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 311 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM https://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Maven Start Up Batch script 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM M2_HOME - location of maven2's installed home dir 28 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 29 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 30 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | @REM e.g. to debug Maven itself, use 32 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | @REM ---------------------------------------------------------------------------- 35 | 36 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 37 | @echo off 38 | @REM set title of command window 39 | title %0 40 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 41 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 42 | 43 | @REM set %HOME% to equivalent of $HOME 44 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 45 | 46 | @REM Execute a user defined script before this one 47 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 48 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 49 | if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" 50 | if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" 51 | :skipRcPre 52 | 53 | @setlocal 54 | 55 | set ERROR_CODE=0 56 | 57 | @REM To isolate internal variables from possible post scripts, we use another setlocal 58 | @setlocal 59 | 60 | @REM ==== START VALIDATION ==== 61 | if not "%JAVA_HOME%" == "" goto OkJHome 62 | 63 | echo. 64 | echo Error: JAVA_HOME not found in your environment. >&2 65 | echo Please set the JAVA_HOME variable in your environment to match the >&2 66 | echo location of your Java installation. >&2 67 | echo. 68 | goto error 69 | 70 | :OkJHome 71 | if exist "%JAVA_HOME%\bin\java.exe" goto init 72 | 73 | echo. 74 | echo Error: JAVA_HOME is set to an invalid directory. >&2 75 | echo JAVA_HOME = "%JAVA_HOME%" >&2 76 | echo Please set the JAVA_HOME variable in your environment to match the >&2 77 | echo location of your Java installation. >&2 78 | echo. 79 | goto error 80 | 81 | @REM ==== END VALIDATION ==== 82 | 83 | :init 84 | 85 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 86 | @REM Fallback to current working directory if not found. 87 | 88 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 89 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 90 | 91 | set EXEC_DIR=%CD% 92 | set WDIR=%EXEC_DIR% 93 | :findBaseDir 94 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 95 | cd .. 96 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 97 | set WDIR=%CD% 98 | goto findBaseDir 99 | 100 | :baseDirFound 101 | set MAVEN_PROJECTBASEDIR=%WDIR% 102 | cd "%EXEC_DIR%" 103 | goto endDetectBaseDir 104 | 105 | :baseDirNotFound 106 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 107 | cd "%EXEC_DIR%" 108 | 109 | :endDetectBaseDir 110 | 111 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 112 | 113 | @setlocal EnableExtensions EnableDelayedExpansion 114 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 115 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 116 | 117 | :endReadAdditionalConfig 118 | 119 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 120 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 121 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 122 | 123 | set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 124 | 125 | FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 126 | IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B 127 | ) 128 | 129 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 130 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 131 | if exist %WRAPPER_JAR% ( 132 | if "%MVNW_VERBOSE%" == "true" ( 133 | echo Found %WRAPPER_JAR% 134 | ) 135 | ) else ( 136 | if not "%MVNW_REPOURL%" == "" ( 137 | SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 138 | ) 139 | if "%MVNW_VERBOSE%" == "true" ( 140 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 141 | echo Downloading from: %DOWNLOAD_URL% 142 | ) 143 | 144 | powershell -Command "&{"^ 145 | "$webclient = new-object System.Net.WebClient;"^ 146 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 147 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 148 | "}"^ 149 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ 150 | "}" 151 | if "%MVNW_VERBOSE%" == "true" ( 152 | echo Finished downloading %WRAPPER_JAR% 153 | ) 154 | ) 155 | @REM End of extension 156 | 157 | @REM Provide a "standardized" way to retrieve the CLI args that will 158 | @REM work with both Windows and non-Windows executions. 159 | set MAVEN_CMD_LINE_ARGS=%* 160 | 161 | %MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 162 | if ERRORLEVEL 1 goto error 163 | goto end 164 | 165 | :error 166 | set ERROR_CODE=1 167 | 168 | :end 169 | @endlocal & set ERROR_CODE=%ERROR_CODE% 170 | 171 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost 172 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 173 | if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" 174 | if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" 175 | :skipRcPost 176 | 177 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 178 | if "%MAVEN_BATCH_PAUSE%" == "on" pause 179 | 180 | if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% 181 | 182 | exit /B %ERROR_CODE% 183 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | io.axoniq.demo 8 | giftcard-demo 9 | 4.11 10 | jar 11 | 12 | giftcard-demo 13 | Axon Framework demo project 14 | 15 | 16 | org.springframework.boot 17 | spring-boot-starter-parent 18 | 3.4.2 19 | 20 | 21 | 22 | 23 | UTF-8 24 | UTF-8 25 | 26 | 17 27 | 28 | 4.11.3 29 | 30 | 1.0 31 | 1.0 32 | 33 | 2.8.5 34 | 2.18.0 35 | 3.14.0 36 | 37 | 5.12.0 38 | 39 | 40 | 41 | 42 | 43 | org.axonframework 44 | axon-bom 45 | ${axon.version} 46 | pom 47 | import 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | org.axonframework 56 | axon-spring-boot-starter 57 | 58 | 59 | 60 | 61 | org.springframework.boot 62 | spring-boot-starter 63 | 64 | 65 | 66 | 67 | org.springframework.boot 68 | spring-boot-starter-webflux 69 | 70 | 71 | 72 | org.springdoc 73 | springdoc-openapi-starter-webflux-ui 74 | ${springdoc-openapi-starter.version} 75 | 76 | 77 | 78 | 79 | 80 | io.projectreactor 81 | reactor-core 82 | 83 | 84 | 85 | 86 | org.slf4j 87 | slf4j-ext 88 | 89 | 90 | 91 | org.axonframework 92 | axon-micrometer 93 | 94 | 95 | 96 | org.springframework.boot 97 | spring-boot-starter-actuator 98 | 99 | 100 | 101 | 102 | org.axonframework 103 | axon-test 104 | 105 | 106 | 107 | org.junit.jupiter 108 | junit-jupiter 109 | ${junit.jupiter.version} 110 | 111 | 112 | 113 | org.springframework.boot 114 | spring-boot-starter-test 115 | test 116 | 117 | 118 | org.junit.vintage 119 | junit-vintage-engine 120 | 121 | 122 | 123 | 124 | io.axoniq 125 | axon-dataprotection-config-api 126 | ${dataprotection-config-api.version} 127 | compile 128 | 129 | 130 | 131 | 132 | 133 | 134 | org.codehaus.mojo 135 | versions-maven-plugin 136 | ${versions-maven-plugin.version} 137 | 138 | false 139 | 140 | 141 | 142 | 143 | org.apache.maven.plugins 144 | maven-compiler-plugin 145 | ${maven.compiler.plugin.version} 146 | 147 | ${java.version} 148 | ${java.version} 149 | 150 | 151 | 152 | 153 | default-compile 154 | none 155 | 156 | 157 | 158 | default-testCompile 159 | none 160 | 161 | 162 | java-compile 163 | compile 164 | 165 | compile 166 | 167 | 168 | 169 | java-test-compile 170 | test-compile 171 | 172 | testCompile 173 | 174 | 175 | 176 | 177 | 178 | 179 | org.springframework.boot 180 | spring-boot-maven-plugin 181 | 182 | 183 | 184 | io.axoniq 185 | axon-dataprotection-maven-plugin 186 | ${dataprotection-maven-plugin.version} 187 | 188 | 189 | 190 | io.axoniq.demo.giftcard.api 191 | 192 | 193 | axon-data-protection-config.json 194 | 195 | 196 | 197 | 198 | generate 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/AxonConfig.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard; 2 | 3 | import com.thoughtworks.xstream.XStream; 4 | import org.axonframework.common.caching.Cache; 5 | import org.axonframework.common.caching.WeakReferenceCache; 6 | import org.axonframework.config.Configurer; 7 | import org.axonframework.config.ConfigurerModule; 8 | import org.axonframework.lifecycle.Phase; 9 | import org.axonframework.messaging.Message; 10 | import org.axonframework.messaging.interceptors.LoggingInterceptor; 11 | import org.jetbrains.annotations.NotNull; 12 | import org.springframework.context.annotation.Bean; 13 | import org.springframework.context.annotation.Configuration; 14 | import org.springframework.context.annotation.Profile; 15 | 16 | @Configuration 17 | public class AxonConfig { 18 | 19 | @Bean 20 | public LoggingInterceptor> loggingInterceptor() { 21 | return new LoggingInterceptor<>(); 22 | } 23 | 24 | @Bean 25 | public ConfigurerModule loggingInterceptorConfigurerModule(LoggingInterceptor> loggingInterceptor) { 26 | return new LoggingInterceptorConfigurerModule(loggingInterceptor); 27 | } 28 | 29 | @Bean 30 | @Profile("command") 31 | public Cache giftCardCache() { 32 | return new WeakReferenceCache(); 33 | } 34 | 35 | /** 36 | * An example {@link ConfigurerModule} implementation to attach configuration to Axon's configuration life cycle. 37 | */ 38 | private static class LoggingInterceptorConfigurerModule implements ConfigurerModule { 39 | 40 | private final LoggingInterceptor> loggingInterceptor; 41 | 42 | private LoggingInterceptorConfigurerModule(LoggingInterceptor> loggingInterceptor) { 43 | this.loggingInterceptor = loggingInterceptor; 44 | } 45 | 46 | @Override 47 | public void configureModule(@NotNull Configurer configurer) { 48 | configurer.eventProcessing( 49 | processingConfigurer -> processingConfigurer.registerDefaultHandlerInterceptor( 50 | (config, processorName) -> loggingInterceptor 51 | ) 52 | ) 53 | .onInitialize(this::registerInterceptorForBusses); 54 | } 55 | 56 | /** 57 | * Registers the {@link LoggingInterceptor} on the {@link org.axonframework.commandhandling.CommandBus}, 58 | * {@link com.google.common.eventbus.EventBus}, {@link org.axonframework.queryhandling.QueryBus}, and 59 | * {@link org.axonframework.queryhandling.QueryUpdateEmitter}. 60 | *

61 | * It does so right after the {@link Phase#INSTRUCTION_COMPONENTS}, to ensure all these infrastructure 62 | * components are constructed. 63 | * 64 | * @param config The {@link org.axonframework.config.Configuration} to retrieve the infrastructure components 65 | * from. 66 | */ 67 | @SuppressWarnings("resource") // We do not require to handle the returned Registration object. 68 | private void registerInterceptorForBusses(org.axonframework.config.Configuration config) { 69 | config.onStart(Phase.INSTRUCTION_COMPONENTS + 1, () -> { 70 | config.commandBus().registerHandlerInterceptor(loggingInterceptor); 71 | config.commandBus().registerDispatchInterceptor(loggingInterceptor); 72 | config.eventBus().registerDispatchInterceptor(loggingInterceptor); 73 | config.queryBus().registerHandlerInterceptor(loggingInterceptor); 74 | config.queryBus().registerDispatchInterceptor(loggingInterceptor); 75 | config.queryUpdateEmitter().registerDispatchInterceptor(loggingInterceptor); 76 | }); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/GiftCardApp.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class GiftCardApp { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(GiftCardApp.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/api/Address.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.api; 2 | 3 | import io.axoniq.plugin.data.protection.annotation.SensitiveData; 4 | 5 | public record Address( 6 | @SensitiveData String addressLine1, 7 | String postalCode 8 | ) { 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/api/CancelCardCommand.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.api; 2 | 3 | import org.axonframework.modelling.command.TargetAggregateIdentifier; 4 | 5 | // Tag this command to use it as code sample in the documentation 6 | // tag::CancelCardCommand[] 7 | public record CancelCardCommand( 8 | @TargetAggregateIdentifier String id 9 | ) { 10 | 11 | } 12 | // end::CancelCardCommand[] 13 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/api/CardCanceledEvent.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.api; 2 | 3 | public record CardCanceledEvent( 4 | String id 5 | ) { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/api/CardIssuedEvent.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.api; 2 | 3 | public record CardIssuedEvent( 4 | String id, 5 | int amount 6 | ) { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/api/CardRedeemedEvent.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.api; 2 | 3 | import io.axoniq.plugin.data.protection.annotation.SensitiveData; 4 | import io.axoniq.plugin.data.protection.annotation.SensitiveDataHolder; 5 | import io.axoniq.plugin.data.protection.annotation.SubjectId; 6 | 7 | @SensitiveDataHolder 8 | public record CardRedeemedEvent( 9 | @SubjectId String id, 10 | @SensitiveData(replacementValue = "hidden amount") int amount 11 | ) { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/api/CardSummary.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.api; 2 | 3 | import java.time.Instant; 4 | 5 | public record CardSummary( 6 | String id, 7 | int initialValue, 8 | int remainingValue, 9 | Instant issued, 10 | Instant lastUpdated 11 | ) { 12 | 13 | public static CardSummary issue(String id, int initialValue, Instant issuedAt) { 14 | return new CardSummary(id, initialValue, initialValue, issuedAt, issuedAt); 15 | } 16 | 17 | public CardSummary redeem(int amount, Instant redeemedAt) { 18 | return new CardSummary( 19 | this.id, 20 | this.initialValue, 21 | this.remainingValue - amount, 22 | this.issued, 23 | redeemedAt); 24 | } 25 | 26 | public CardSummary cancel(Instant cancelledAt) { 27 | return new CardSummary( 28 | this.id, 29 | this.initialValue, 30 | 0, 31 | this.issued, 32 | cancelledAt); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/api/CountCardSummariesQuery.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.api; 2 | 3 | public record CountCardSummariesQuery() { 4 | 5 | @Override 6 | public String toString() { 7 | return "CountCardSummariesQuery"; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/api/CountCardSummariesResponse.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.api; 2 | 3 | import java.time.Instant; 4 | 5 | public record CountCardSummariesResponse( 6 | int count, 7 | Instant lastEvent 8 | ) { 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/api/ExampleEvent.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.api; 2 | 3 | import io.axoniq.plugin.data.protection.annotation.SensitiveData; 4 | import io.axoniq.plugin.data.protection.annotation.SensitiveDataHolder; 5 | import io.axoniq.plugin.data.protection.annotation.SubjectId; 6 | import org.axonframework.serialization.Revision; 7 | 8 | /** 9 | * Example event for data protection plugin config generation 10 | * 11 | * @param id 12 | * @param ssn 13 | * @param address 14 | */ 15 | @SensitiveDataHolder 16 | //Only needs to be placed on the events, not on any contained objects such as the address in this example. 17 | @Revision("1") 18 | public record ExampleEvent( 19 | @SubjectId String id, 20 | @SensitiveData String ssn, 21 | Address address 22 | ) { 23 | 24 | } -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/api/FetchCardSummariesQuery.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.api; 2 | 3 | public record FetchCardSummariesQuery( 4 | int limit 5 | ) { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/api/IssueCardCommand.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.api; 2 | 3 | import org.axonframework.modelling.command.TargetAggregateIdentifier; 4 | 5 | // Tag this command to use it as code sample in the documentation 6 | // tag::IssueCardCommand[] 7 | public record IssueCardCommand( 8 | @TargetAggregateIdentifier String id, 9 | int amount 10 | ) { 11 | 12 | } 13 | // end::IssueCardCommand[] 14 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/api/RedeemCardCommand.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.api; 2 | 3 | import org.axonframework.modelling.command.TargetAggregateIdentifier; 4 | 5 | // Tag this command to use it as code sample in the documentation 6 | // tag::RedeemCardCommand[] 7 | public record RedeemCardCommand( 8 | @TargetAggregateIdentifier String id, 9 | int amount 10 | ) { 11 | 12 | } 13 | // end::RedeemCardCommand[] 14 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/command/GiftCard.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.command; 2 | 3 | import io.axoniq.demo.giftcard.api.CancelCardCommand; 4 | import io.axoniq.demo.giftcard.api.CardCanceledEvent; 5 | import io.axoniq.demo.giftcard.api.CardIssuedEvent; 6 | import io.axoniq.demo.giftcard.api.CardRedeemedEvent; 7 | import io.axoniq.demo.giftcard.api.IssueCardCommand; 8 | import io.axoniq.demo.giftcard.api.RedeemCardCommand; 9 | import org.axonframework.commandhandling.CommandHandler; 10 | import org.axonframework.eventsourcing.EventSourcingHandler; 11 | import org.axonframework.modelling.command.AggregateIdentifier; 12 | import org.axonframework.spring.stereotype.Aggregate; 13 | import org.springframework.context.annotation.Profile; 14 | 15 | import static org.axonframework.modelling.command.AggregateLifecycle.apply; 16 | 17 | @Profile("command") 18 | @Aggregate(cache = "giftCardCache") 19 | public class GiftCard { 20 | 21 | @AggregateIdentifier 22 | private String giftCardId; 23 | private int remainingValue; 24 | 25 | // Tag this handler to use it as code sample in the documentation 26 | // tag::IssueCardCommandHandler[] 27 | @CommandHandler 28 | public GiftCard(IssueCardCommand command) { 29 | if (command.amount() <= 0) { 30 | throw new IllegalArgumentException("amount <= 0"); 31 | } 32 | apply(new CardIssuedEvent(command.id(), command.amount())); 33 | } 34 | // end::IssueCardCommandHandler[] 35 | 36 | // Tag this handler to use it as code sample in the documentation 37 | // tag::RedeemCardCommandHandler[] 38 | @CommandHandler 39 | public void handle(RedeemCardCommand command) { 40 | if (command.amount() <= 0) { 41 | throw new IllegalArgumentException("amount <= 0"); 42 | } 43 | if (command.amount() > remainingValue) { 44 | throw new IllegalStateException("amount > remaining value"); 45 | } 46 | apply(new CardRedeemedEvent(giftCardId, command.amount())); 47 | } 48 | // end::RedeemCardCommandHandler[] 49 | 50 | @SuppressWarnings("unused") 51 | @CommandHandler 52 | public void handle(CancelCardCommand command) { 53 | apply(new CardCanceledEvent(giftCardId)); 54 | } 55 | 56 | @EventSourcingHandler 57 | public void on(CardIssuedEvent event) { 58 | giftCardId = event.id(); 59 | remainingValue = event.amount(); 60 | } 61 | 62 | @EventSourcingHandler 63 | public void on(CardRedeemedEvent event) { 64 | remainingValue -= event.amount(); 65 | } 66 | 67 | @EventSourcingHandler 68 | public void on(CardCanceledEvent event) { 69 | remainingValue = 0; 70 | } 71 | 72 | public GiftCard() { 73 | // Required by Axon to construct an empty instance to initiate Event Sourcing. 74 | } 75 | } 76 | 77 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/query/CardSummaryProjection.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.query; 2 | 3 | import io.axoniq.demo.giftcard.api.CardCanceledEvent; 4 | import io.axoniq.demo.giftcard.api.CardIssuedEvent; 5 | import io.axoniq.demo.giftcard.api.CardRedeemedEvent; 6 | import io.axoniq.demo.giftcard.api.CardSummary; 7 | import io.axoniq.demo.giftcard.api.CountCardSummariesQuery; 8 | import io.axoniq.demo.giftcard.api.CountCardSummariesResponse; 9 | import io.axoniq.demo.giftcard.api.FetchCardSummariesQuery; 10 | import org.axonframework.config.ProcessingGroup; 11 | import org.axonframework.eventhandling.EventHandler; 12 | import org.axonframework.eventhandling.Timestamp; 13 | import org.axonframework.queryhandling.QueryHandler; 14 | import org.axonframework.queryhandling.QueryUpdateEmitter; 15 | import org.springframework.context.annotation.Profile; 16 | import org.springframework.stereotype.Service; 17 | 18 | import java.time.Instant; 19 | import java.util.Comparator; 20 | import java.util.List; 21 | import java.util.Map; 22 | import java.util.concurrent.ConcurrentHashMap; 23 | 24 | @Profile("query") 25 | @Service 26 | @ProcessingGroup("card-summary") 27 | public class CardSummaryProjection { 28 | 29 | private final Map cardSummaryReadModel; 30 | private final QueryUpdateEmitter queryUpdateEmitter; 31 | private Instant lastUpdate; 32 | 33 | public CardSummaryProjection( 34 | QueryUpdateEmitter queryUpdateEmitter 35 | ) { 36 | this.cardSummaryReadModel = new ConcurrentHashMap<>(); 37 | this.queryUpdateEmitter = queryUpdateEmitter; 38 | } 39 | 40 | @EventHandler 41 | public void on(CardIssuedEvent event, @Timestamp Instant timestamp) { 42 | lastUpdate = timestamp; 43 | /* 44 | * Update our read model by inserting the new card. This is done so that upcoming regular 45 | * (non-subscription) queries get correct data. 46 | */ 47 | CardSummary summary = CardSummary.issue(event.id(), event.amount(), timestamp); 48 | cardSummaryReadModel.put(event.id(), summary); 49 | /* 50 | * Serve the subscribed queries by emitting an update. This reads as follows: 51 | * - to all current subscriptions of type CountCardSummariesQuery, 52 | * - for any CountCardSummariesQuery, since true is returned by default, and 53 | * - send a message that the count of queries matching this query has been changed. 54 | */ 55 | queryUpdateEmitter.emit(CountCardSummariesQuery.class, 56 | query -> true, 57 | new CountCardSummariesResponse(cardSummaryReadModel.size(), lastUpdate)); 58 | /* 59 | * Serve the subscribed queries by emitting an update. This reads as follows: 60 | * - to all current subscriptions of type CountCardSummariesQuery, 61 | * - for any CountCardSummariesQuery, since true is returned by default, and 62 | * - send a message that the count of queries matching this query has been changed. 63 | */ 64 | queryUpdateEmitter.emit(FetchCardSummariesQuery.class, query -> true, summary); 65 | } 66 | 67 | @EventHandler 68 | public void on(CardRedeemedEvent event, @Timestamp Instant timestamp) { 69 | /* 70 | * Update our read model by updating the existing card. This is done so that upcoming regular 71 | * (non-subscription) queries get correct data. 72 | */ 73 | CardSummary summary = cardSummaryReadModel.computeIfPresent( 74 | event.id(), (id, card) -> card.redeem(event.amount(), timestamp) 75 | ); 76 | /* 77 | * Serve the subscribed queries by emitting an update. This reads as follows: 78 | * - to all current subscriptions of type FetchCardSummariesQuery 79 | * - for any FetchCardSummariesQuery, since true is returned by default, and 80 | * - send a message containing the new state of this gift card summary 81 | */ 82 | queryUpdateEmitter.emit(FetchCardSummariesQuery.class, query -> true, summary); 83 | } 84 | 85 | @EventHandler 86 | public void on(CardCanceledEvent event, @Timestamp Instant timestamp) { 87 | /* 88 | * Update our read model by updating the existing card. This is done so that upcoming regular 89 | * (non-subscription) queries get correct data. 90 | */ 91 | CardSummary summary = cardSummaryReadModel.computeIfPresent( 92 | event.id(), (id, card) -> card.cancel(timestamp) 93 | ); 94 | /* 95 | * Serve the subscribed queries by emitting an update. This reads as follows: 96 | * - to all current subscriptions of type FetchCardSummariesQuery 97 | * - for any FetchCardSummariesQuery, since true is returned by default, and 98 | * - send a message containing the new state of this gift card summary 99 | */ 100 | queryUpdateEmitter.emit(FetchCardSummariesQuery.class, query -> true, summary); 101 | } 102 | 103 | @QueryHandler 104 | public List handle(FetchCardSummariesQuery query) { 105 | return cardSummaryReadModel.values() 106 | .stream() 107 | .sorted(Comparator.comparing(CardSummary::lastUpdated)) 108 | .limit(query.limit()) 109 | .toList(); 110 | } 111 | 112 | @SuppressWarnings("unused") 113 | @QueryHandler 114 | public CountCardSummariesResponse handle(CountCardSummariesQuery query) { 115 | return new CountCardSummariesResponse(cardSummaryReadModel.size(), lastUpdate); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/rest/GiftCardController.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.rest; 2 | 3 | import io.axoniq.demo.giftcard.api.IssueCardCommand; 4 | import io.axoniq.demo.giftcard.api.RedeemCardCommand; 5 | import io.axoniq.demo.giftcard.api.CardSummary; 6 | import io.axoniq.demo.giftcard.api.CountCardSummariesQuery; 7 | import io.axoniq.demo.giftcard.api.CountCardSummariesResponse; 8 | import io.axoniq.demo.giftcard.api.FetchCardSummariesQuery; 9 | import org.axonframework.commandhandling.gateway.CommandGateway; 10 | import org.axonframework.messaging.responsetypes.ResponseTypes; 11 | import org.axonframework.queryhandling.QueryGateway; 12 | import org.axonframework.queryhandling.SubscriptionQueryResult; 13 | import org.springframework.context.annotation.Profile; 14 | import org.springframework.http.MediaType; 15 | import org.springframework.web.bind.annotation.GetMapping; 16 | import org.springframework.web.bind.annotation.PathVariable; 17 | import org.springframework.web.bind.annotation.PostMapping; 18 | import org.springframework.web.bind.annotation.RequestMapping; 19 | import org.springframework.web.bind.annotation.RestController; 20 | import reactor.core.publisher.Flux; 21 | import reactor.core.publisher.Mono; 22 | 23 | import java.time.Duration; 24 | import java.util.List; 25 | import java.util.UUID; 26 | 27 | @RestController 28 | @Profile("gui") 29 | @RequestMapping("/giftcard") 30 | public class GiftCardController { 31 | 32 | private final CommandGateway commandGateway; 33 | private final QueryGateway queryGateway; 34 | 35 | public GiftCardController(CommandGateway commandGateway, QueryGateway queryGateway) { 36 | this.commandGateway = commandGateway; 37 | this.queryGateway = queryGateway; 38 | } 39 | 40 | @GetMapping(path = "/subscribe/limit/{limit}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) 41 | public Flux subscribe( 42 | @PathVariable int limit 43 | ) { 44 | return summerySubscription(limit); 45 | } 46 | 47 | @GetMapping(path = "/subscribe-count", produces = MediaType.TEXT_EVENT_STREAM_VALUE) 48 | public Flux subscribe() { 49 | return countSubscription(); 50 | } 51 | 52 | @PostMapping("issue/id/{id}/amount/{amount}") 53 | public Mono issue( 54 | @PathVariable String id, 55 | @PathVariable int amount 56 | ) { 57 | var command = new IssueCardCommand(id, amount); 58 | return Mono.fromFuture(commandGateway.send(command)) 59 | .then(Mono.just(Result.ok())) 60 | .onErrorResume(e -> Mono.just(Result.Error(id, e.getMessage()))) 61 | .timeout(Duration.ofSeconds(5L)); 62 | } 63 | 64 | @PostMapping("redeem/id/{id}/amount/{amount}") 65 | public Mono redeem( 66 | @PathVariable String id, 67 | @PathVariable int amount 68 | ) { 69 | var command = new RedeemCardCommand(id, amount); 70 | return Mono.fromFuture(commandGateway.send(command)) 71 | .then(Mono.just(Result.ok())) 72 | .onErrorResume(e -> Mono.just(Result.Error(id, e.getMessage()))) 73 | .timeout(Duration.ofSeconds(5L)); 74 | } 75 | 76 | @GetMapping(path = "bulkissue/number/{number}/amount/{amount}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) 77 | public Flux bulkIssue( 78 | @PathVariable int number, 79 | @PathVariable int amount 80 | ) { 81 | return Flux.range(0, number).flatMap(i -> issue(amount)); 82 | } 83 | 84 | private Mono issue(int amount) { 85 | String id = UUID.randomUUID().toString().substring(0, 11).toUpperCase(); 86 | var command = new IssueCardCommand(id, amount); 87 | return Mono.fromFuture(commandGateway.send(command)) 88 | .then(Mono.just(Result.ok())) 89 | .onErrorResume(e -> Mono.just(Result.Error(id, e.getMessage()))) 90 | .timeout(Duration.ofSeconds(10L)); 91 | } 92 | 93 | 94 | private Flux summerySubscription(int limit) { 95 | var query = new FetchCardSummariesQuery(limit); 96 | SubscriptionQueryResult, CardSummary> result = queryGateway.subscriptionQuery( 97 | query, 98 | ResponseTypes.multipleInstancesOf(CardSummary.class), 99 | ResponseTypes.instanceOf(CardSummary.class)); 100 | return result.initialResult() 101 | .flatMapMany(Flux::fromIterable) 102 | .concatWith(result.updates()) 103 | .doFinally(signal -> result.close()); 104 | } 105 | 106 | private Flux countSubscription() { 107 | var query = new CountCardSummariesQuery(); 108 | SubscriptionQueryResult result = queryGateway.subscriptionQuery( 109 | query, 110 | ResponseTypes.instanceOf(CountCardSummariesResponse.class), 111 | ResponseTypes.instanceOf(CountCardSummariesResponse.class)); 112 | return result.initialResult() 113 | .concatWith(result.updates()) 114 | .doFinally(signal -> result.close()); 115 | } 116 | } -------------------------------------------------------------------------------- /src/main/java/io/axoniq/demo/giftcard/rest/Result.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.rest; 2 | 3 | import org.axonframework.axonserver.connector.ErrorCode; 4 | 5 | public record Result( 6 | boolean isSuccess, 7 | String error 8 | ) { 9 | 10 | public static Result ok() { 11 | return new Result(true, null); 12 | } 13 | 14 | public static Result Error(String id, String error) { 15 | if (error.contains(ErrorCode.INVALID_EVENT_SEQUENCE.errorCode())) { 16 | return new Result(false, 17 | "An event for aggregate [" + id + "] at sequence [" 18 | + error.substring(error.length() - 1) + "] was already inserted. " 19 | + "You are either reusing the aggregate identifier " 20 | + "or concurrently dispatching commands for the same aggregate."); 21 | } 22 | return new Result(false, "Error on aggregate [" + id + "]. " + error); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/resources/application-command.properties: -------------------------------------------------------------------------------- 1 | server.port=8082 -------------------------------------------------------------------------------- /src/main/resources/application-gui.properties: -------------------------------------------------------------------------------- 1 | server.port=8080 -------------------------------------------------------------------------------- /src/main/resources/application-query.properties: -------------------------------------------------------------------------------- 1 | server.port=8083 2 | 3 | # Configuring the "card-summary" processing group to use two threads and two segments 4 | axon.eventhandling.processors.card-summary.thread-count=2 5 | axon.eventhandling.processors.card-summary.initial-segment-count=2 -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | # The name of this app: 2 | spring.application.name=GiftCard-App-${spring.profiles.active} 3 | server.port=8080 4 | 5 | # Debugging on 6 | logging.level.io.axoniq.demo=debug 7 | 8 | # We look for Axon Server locally, unless we find a PCF Binding for AxonServer 9 | axon.axonserver.servers=${vcap.services.AxonServer.credentials.uri:localhost} 10 | 11 | # The default profiles are "all of them" 12 | spring.profiles.active=command,query,gui 13 | 14 | # Management endpoints inclusion 15 | management.endpoint.health.show-details=always 16 | management.endpoints.web.exposure.include=* 17 | 18 | axon.serializer.general=jackson 19 | 20 | -------------------------------------------------------------------------------- /src/main/resources/static/app.js: -------------------------------------------------------------------------------- 1 | let actionInProgress = false 2 | let idInputIssue 3 | let amountInputIssue 4 | let issueButton 5 | let numberInputBulk 6 | let amountInputBulk 7 | let bulkButton 8 | let idInputRedeem 9 | let amountInputRedeem 10 | let redeemButton 11 | let notification 12 | let notificationText 13 | let notificationButton 14 | let tableSource 15 | 16 | function setDomElements() { 17 | idInputIssue = document.getElementById("id-input-issue") 18 | amountInputIssue = document.getElementById("amount-input-issue") 19 | issueButton = document.getElementById("issue-button") 20 | numberInputBulk = document.getElementById("number-input-bulk") 21 | amountInputBulk = document.getElementById("amount-input-bulk") 22 | bulkButton = document.getElementById("bulk-button") 23 | idInputRedeem = document.getElementById("id-input-redeem") 24 | amountInputRedeem = document.getElementById("amount-input-redeem") 25 | redeemButton = document.getElementById("redeem-button") 26 | notification = document.getElementById("notification") 27 | notificationText = document.getElementById("notification-text") 28 | notificationButton = document.getElementById("notification-button") 29 | } 30 | 31 | function maybeSwitchIssueState() { 32 | if (issueButton.disabled && !actionInProgress && idInputIssue.value !== "" && amountInputIssue.value !== "") { 33 | issueButton.disabled = false 34 | } else if (idInputIssue.value === "" || amountInputIssue.value === "") { 35 | issueButton.disabled = true 36 | } 37 | } 38 | 39 | function maybeSwitchBulkState() { 40 | if (bulkButton.disabled && !actionInProgress && numberInputBulk.value !== "" && amountInputBulk.value !== "") { 41 | bulkButton.disabled = false 42 | } else if (numberInputBulk.value === "" && amountInputBulk.value === "") { 43 | bulkButton.disabled = true 44 | } 45 | } 46 | 47 | function maybeSwitchRedeemState() { 48 | if (redeemButton.disabled && !actionInProgress && idInputRedeem.value !== "" && amountInputRedeem.value !== "") { 49 | redeemButton.disabled = false 50 | } else if (idInputRedeem.value === "" || amountInputRedeem.value === "") { 51 | redeemButton.disabled = true 52 | } 53 | } 54 | 55 | function maybeSwitchAll(){ 56 | maybeSwitchIssueState() 57 | maybeSwitchBulkState() 58 | maybeSwitchRedeemState() 59 | } 60 | 61 | function disableAllButtons() { 62 | issueButton.disabled = true 63 | bulkButton.disabled = true 64 | redeemButton.disabled = true 65 | } 66 | 67 | function hideNotification() { 68 | notification.style.visibility = "hidden" 69 | } 70 | 71 | function removeColorClassesFromNotification() { 72 | notification.classList.remove("is-success", "is-info", "is-danger") 73 | } 74 | 75 | async function handleResult(result) { 76 | actionInProgress = false 77 | hideNotification() 78 | removeColorClassesFromNotification() 79 | if (result["isSuccess"] === true) { 80 | notification.classList.add("is-success") 81 | notificationText.innerHTML = "Success" 82 | } else { 83 | notification.classList.add("is-danger") 84 | notificationText.innerHTML = result["error"] 85 | } 86 | notification.style.visibility = "" 87 | maybeSwitchAll() 88 | } 89 | 90 | 91 | async function issueCard() { 92 | actionInProgress = true 93 | disableAllButtons() 94 | const value = idInputIssue.value 95 | const amount = amountInputIssue.value 96 | idInputIssue.value = "" 97 | amountInputIssue.value = "" 98 | const response = await fetch("/giftcard/issue/id/" + value + "/amount/" + amount, { 99 | method: "POST" 100 | }); 101 | response.json().then(result => handleResult(result)) 102 | } 103 | 104 | async function bulkCard() { 105 | actionInProgress = true 106 | disableAllButtons() 107 | const number = numberInputBulk.value 108 | const amount = amountInputBulk.value 109 | numberInputBulk.value = "" 110 | amountInputBulk.value = "" 111 | removeColorClassesFromNotification() 112 | notification.classList.add("is-info") 113 | const progressBar = document.createElement('progress') 114 | notificationText.innerHTML = "" 115 | notificationText.appendChild(progressBar) 116 | progressBar["value"] = 0 117 | progressBar["max"] = number 118 | progressBar.classList.add("progress") 119 | notificationButton.style.display = "none" 120 | notification.style.visibility = "" 121 | let successCount = 0 122 | let failedCount = 0 123 | const source = new EventSource("/giftcard/bulkissue/number/" + number + "/amount/" + amount) 124 | source.onmessage = function (event) { 125 | const result = JSON.parse(event.data) 126 | if (result["isSuccess"] === true) { 127 | successCount++ 128 | } else { 129 | failedCount++ 130 | console.log(result["error"]) 131 | } 132 | const total = successCount + failedCount 133 | progressBar["value"] = total 134 | if (total >= number) { 135 | source.close() 136 | notificationButton.style.display = "" 137 | if (failedCount === 0) { 138 | handleResult({"isSuccess": true}) 139 | } else { 140 | handleResult({"isSuccess": false, "error": failedCount + " of the " + number + " ended in failure."}) 141 | } 142 | } 143 | }; 144 | source.onerror = function (error) { 145 | source.close() 146 | console.log(error) 147 | notificationButton.style.display = "" 148 | handleResult({"isSuccess": false, "error": "Failed to connect to the server"}) 149 | } 150 | } 151 | 152 | async function redeemCard() { 153 | actionInProgress = true 154 | disableAllButtons() 155 | const id = idInputRedeem.value 156 | const amount = amountInputRedeem.value 157 | idInputRedeem.value = "" 158 | amountInputRedeem.value = "" 159 | const response = await fetch("/giftcard/redeem/id/" + id + "/amount/" + amount, { 160 | method: "POST" 161 | }); 162 | response.json().then(result => { 163 | handleResult(result) 164 | }) 165 | } 166 | 167 | async function updateCount() { 168 | const count = document.getElementById("count") 169 | const lastEvent = document.getElementById("last-event") 170 | const source = new EventSource("/giftcard/subscribe-count") 171 | source.onmessage = function (event) { 172 | const cardData = JSON.parse(event.data) 173 | count.innerHTML = cardData["count"] 174 | lastEvent.innerHTML = new Date(cardData["lastEvent"]).toLocaleString() 175 | }; 176 | } 177 | 178 | async function updateTable(maxRows) { 179 | if (tableSource !== undefined){ 180 | tableSource.close() 181 | } 182 | let tableIds = [] 183 | const tableBody = document.getElementById("table-body") 184 | tableBody.innerHTML = "" 185 | tableSource = new EventSource("/giftcard/subscribe/limit/" + maxRows) 186 | tableSource.onmessage = function (event) { 187 | const cardData = JSON.parse(event.data) 188 | const cardId = "card_" + cardData["id"] 189 | if (tableIds.includes(cardId)) { 190 | document.getElementById(cardId).remove() 191 | tableIds = tableIds.filter(item => item !== cardId) 192 | } else if (tableIds.length >= maxRows) { 193 | const toRemove = tableIds.pop() 194 | document.getElementById(toRemove).remove() 195 | } 196 | tableIds.unshift(cardId) 197 | const row = tableBody.insertRow(0) 198 | row.id = cardId 199 | const cell1 = row.insertCell(0) 200 | const cell2 = row.insertCell(1) 201 | const cell3 = row.insertCell(2) 202 | const cell4 = row.insertCell(3) 203 | const cell5 = row.insertCell(4) 204 | cell1.innerHTML = cardData["id"] 205 | cell2.innerHTML = cardData["initialValue"] 206 | cell3.innerHTML = cardData["remainingValue"] 207 | cell4.innerHTML = new Date(cardData["issued"]).toLocaleString() 208 | cell5.innerHTML = new Date(cardData["lastUpdated"]).toLocaleString() 209 | }; 210 | } 211 | 212 | function setListeners() { 213 | notificationButton.addEventListener("click", () => { 214 | void hideNotification() 215 | }); 216 | issueButton.addEventListener("click", () => { 217 | void issueCard(); 218 | }); 219 | bulkButton.addEventListener("click", () => { 220 | void bulkCard(); 221 | }); 222 | redeemButton.addEventListener("click", () => { 223 | void redeemCard(); 224 | }); 225 | idInputIssue.addEventListener("keyup", () => { 226 | maybeSwitchIssueState() 227 | }) 228 | amountInputIssue.addEventListener("keyup", () => { 229 | maybeSwitchIssueState() 230 | }) 231 | numberInputBulk.addEventListener("keyup", () => { 232 | maybeSwitchBulkState() 233 | }) 234 | amountInputBulk.addEventListener("keyup", () => { 235 | maybeSwitchBulkState() 236 | }) 237 | idInputRedeem.addEventListener("keyup", () => { 238 | maybeSwitchRedeemState() 239 | }) 240 | amountInputRedeem.addEventListener("keyup", () => { 241 | maybeSwitchRedeemState() 242 | }) 243 | addEventListener("paste", () => { 244 | setTimeout(maybeSwitchAll, 10) 245 | }) 246 | document.getElementById("table-size-select").addEventListener("change", event => { 247 | void updateTable(event.target.value) 248 | }) 249 | } 250 | 251 | window.addEventListener("load", () => { 252 | setDomElements() 253 | setListeners() 254 | void updateCount() 255 | void updateTable(20) 256 | }) -------------------------------------------------------------------------------- /src/main/resources/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Giftcard Demo 7 | 8 | 9 | 10 | 11 |

12 |
13 |

14 | Giftcard Demo 15 |

16 |
17 |
18 |
19 |
20 |

Issue single card

21 | 23 | 24 | 25 |
26 |
27 |

Bulk issue cards

28 | 30 | 32 | 33 |
34 |
35 |

Redeem card

36 | 38 | 39 | 40 |
41 |
42 | 46 |
47 |
48 |

49 | Latest updated cards 50 |

51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |
Card IDInitial valueRemaining valueIssued atLast updated
64 |
65 |
66 |
67 |
68 |
In total 69 |
0
70 | cards issued, last one at: 71 |
not yet
. 72 |
73 |
74 |
75 |
76 | 77 |
78 |
79 | 84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | 92 | -------------------------------------------------------------------------------- /src/test/java/io/axoniq/demo/giftcard/command/GiftCardTest.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.command; 2 | 3 | import io.axoniq.demo.giftcard.api.CardIssuedEvent; 4 | import io.axoniq.demo.giftcard.api.CardRedeemedEvent; 5 | import io.axoniq.demo.giftcard.api.IssueCardCommand; 6 | import io.axoniq.demo.giftcard.api.RedeemCardCommand; 7 | import org.axonframework.test.aggregate.AggregateTestFixture; 8 | import org.axonframework.test.aggregate.FixtureConfiguration; 9 | import org.junit.jupiter.api.Test; 10 | 11 | import java.util.UUID; 12 | 13 | class GiftCardTest { 14 | 15 | private static final String CARD_ID = UUID.randomUUID().toString(); 16 | private static final int AMOUNT = 1377; 17 | private static final int NEGATIVE_AMOUNT = -1337; 18 | 19 | private final FixtureConfiguration testFixture = new AggregateTestFixture<>(GiftCard.class); 20 | 21 | @Test 22 | void testIssueCardCommandPublishesCardIssuedEvent() { 23 | testFixture.givenNoPriorActivity() 24 | .when(new IssueCardCommand(CARD_ID, AMOUNT)) 25 | .expectSuccessfulHandlerExecution() 26 | .expectEvents(new CardIssuedEvent(CARD_ID, AMOUNT)); 27 | } 28 | 29 | @Test 30 | void testIssueCardCommandThrowsIllegalArgumentExceptionForNegativeAmount() { 31 | testFixture.givenNoPriorActivity() 32 | .when(new IssueCardCommand(CARD_ID, NEGATIVE_AMOUNT)) 33 | .expectException(IllegalArgumentException.class); 34 | } 35 | 36 | @Test 37 | void testRedeemCardCommandPublishesCardRedeemedEvent() { 38 | testFixture.given(new CardIssuedEvent(CARD_ID, AMOUNT)) 39 | .when(new RedeemCardCommand(CARD_ID, AMOUNT)) 40 | .expectSuccessfulHandlerExecution() 41 | .expectEvents(new CardRedeemedEvent(CARD_ID, AMOUNT)); 42 | } 43 | 44 | @Test 45 | void testRedeemCardCommandThrowsIllegalArgumentExceptionForNegativeAmount() { 46 | testFixture.given(new CardIssuedEvent(CARD_ID, AMOUNT)) 47 | .when(new RedeemCardCommand(CARD_ID, NEGATIVE_AMOUNT)) 48 | .expectException(IllegalArgumentException.class); 49 | } 50 | 51 | @Test 52 | void testRedeemCardCommandThrowsIllegalStateExceptionForInsufficientFunds() { 53 | testFixture.given(new CardIssuedEvent(CARD_ID, AMOUNT), new CardRedeemedEvent(CARD_ID, AMOUNT)) 54 | .when(new RedeemCardCommand(CARD_ID, AMOUNT)) 55 | .expectException(IllegalStateException.class); 56 | } 57 | } -------------------------------------------------------------------------------- /src/test/java/io/axoniq/demo/giftcard/query/CardSummaryProjectionTest.java: -------------------------------------------------------------------------------- 1 | package io.axoniq.demo.giftcard.query; 2 | 3 | import io.axoniq.demo.giftcard.api.CardIssuedEvent; 4 | import io.axoniq.demo.giftcard.api.CardRedeemedEvent; 5 | import io.axoniq.demo.giftcard.api.CardSummary; 6 | import io.axoniq.demo.giftcard.api.CountCardSummariesQuery; 7 | import io.axoniq.demo.giftcard.api.CountCardSummariesResponse; 8 | import io.axoniq.demo.giftcard.api.FetchCardSummariesQuery; 9 | import org.axonframework.queryhandling.QueryUpdateEmitter; 10 | import org.junit.jupiter.api.*; 11 | 12 | import java.time.Instant; 13 | import java.util.List; 14 | import java.util.UUID; 15 | import java.util.stream.IntStream; 16 | 17 | import static org.junit.jupiter.api.Assertions.*; 18 | import static org.mockito.Mockito.*; 19 | 20 | class CardSummaryProjectionTest { 21 | 22 | private final QueryUpdateEmitter updateEmitter = mock(QueryUpdateEmitter.class); 23 | 24 | private CardSummaryProjection testSubject; 25 | 26 | @BeforeEach 27 | void setUp() { 28 | testSubject = new CardSummaryProjection(updateEmitter); 29 | } 30 | 31 | @Test 32 | void testCardIssuedEventInsertsCardSummaryAndUpdatesCountCardSummariesQuery() { 33 | String testId = "001"; 34 | int testAmount = 1377; 35 | 36 | testSubject.on(new CardIssuedEvent(testId, testAmount), Instant.now()); 37 | 38 | List results = testSubject.handle(new FetchCardSummariesQuery(100)); 39 | assertEquals(1, results.size()); 40 | CardSummary result = results.get(0); 41 | assertEquals(testId, result.id()); 42 | assertEquals(testAmount, result.initialValue()); 43 | assertEquals(testAmount, result.remainingValue()); 44 | 45 | verify(updateEmitter).emit(eq(CountCardSummariesQuery.class), any(), isA(CountCardSummariesResponse.class)); 46 | } 47 | 48 | @Test 49 | void testCardRedeemedEventUpdatesCardSummaryAndUpdatesFetchCardSummariesQuery() { 50 | String testId = "001"; 51 | int testAmount = 1377; 52 | int testRedeemAmount = 42; 53 | testSubject.on(new CardIssuedEvent(testId, testAmount), Instant.now()); 54 | 55 | testSubject.on(new CardRedeemedEvent(testId, testRedeemAmount), Instant.now()); 56 | 57 | List results = testSubject.handle(new FetchCardSummariesQuery(100)); 58 | assertEquals(1, results.size()); 59 | CardSummary result = results.get(0); 60 | assertEquals(testId, result.id()); 61 | assertEquals(testAmount, result.initialValue()); 62 | assertEquals(testAmount - testRedeemAmount, result.remainingValue()); 63 | 64 | verify(updateEmitter).emit(eq(FetchCardSummariesQuery.class), any(), eq(result)); 65 | } 66 | 67 | @Test 68 | void testFetchCardSummariesQueryReturnsAllCardSummaries() { 69 | String testId = "001"; 70 | int testAmount = 1377; 71 | testSubject.on(new CardIssuedEvent(testId, testAmount), Instant.now()); 72 | 73 | String otherTestId = "002"; 74 | int otherTestAmount = 42; 75 | testSubject.on(new CardIssuedEvent(otherTestId, otherTestAmount), Instant.now()); 76 | 77 | List results = testSubject.handle(new FetchCardSummariesQuery(100)); 78 | assertEquals(2, results.size()); 79 | 80 | CardSummary firstResult = results.get(0); 81 | assertEquals(testId, firstResult.id()); 82 | assertEquals(testAmount, firstResult.initialValue()); 83 | assertEquals(testAmount, firstResult.remainingValue()); 84 | 85 | CardSummary secondResult = results.get(1); 86 | assertEquals(otherTestId, secondResult.id()); 87 | assertEquals(otherTestAmount, secondResult.initialValue()); 88 | assertEquals(otherTestAmount, secondResult.remainingValue()); 89 | } 90 | 91 | @Test 92 | void testFetchCardSummariesQueryReturnsFirstEntryOnLimitedSetOfCardSummaries() { 93 | String testId = "001"; 94 | int testAmount = 1377; 95 | // first entry 96 | testSubject.on(new CardIssuedEvent(testId, testAmount), Instant.now()); 97 | // second entry 98 | testSubject.on(new CardIssuedEvent("002", 42), Instant.now()); 99 | 100 | List results = testSubject.handle(new FetchCardSummariesQuery(1)); 101 | assertEquals(1, results.size()); 102 | CardSummary result = results.get(0); 103 | assertEquals(testId, result.id()); 104 | assertEquals(testAmount, result.initialValue()); 105 | assertEquals(testAmount, result.remainingValue()); 106 | } 107 | 108 | @Test 109 | void testCountCardSummariesQueryReturnsNumberOfCardSummaryEntries() { 110 | int expectedCount = 10; 111 | IntStream.range(0, expectedCount) 112 | .forEach(i -> testSubject.on(new CardIssuedEvent(UUID.randomUUID().toString(), i), Instant.now())); 113 | 114 | CountCardSummariesResponse result = testSubject.handle(new CountCardSummariesQuery()); 115 | assertEquals(expectedCount, result.count()); 116 | } 117 | } --------------------------------------------------------------------------------