├── .github └── workflows │ └── release.yml ├── .gitignore ├── .idea └── .gitignore ├── LICENSE ├── README.md ├── images ├── AggregareStateTransitions.png ├── AggregationSagaExample.PNG ├── CommandResultsInEvent.png ├── CommandResultsInEventNew.png ├── ConfigClassDiagramm.png ├── EventInsertion.png ├── Example1.png ├── GeneralAggregateUpdate.png ├── NestedSagaExample.PNG ├── SagaExample.PNG └── SagaProcessing.PNG ├── pom.xml ├── tiny-event-sourcing-app-base ├── pom.xml └── src │ └── main │ └── kotlin │ └── ru.quipy │ └── TinyEsLibConfig.kt ├── tiny-event-sourcing-app ├── pom.xml └── src │ ├── main │ ├── kotlin │ │ └── ru │ │ │ └── quipy │ │ │ ├── Application.kt │ │ │ ├── application │ │ │ ├── App.kt │ │ │ ├── component │ │ │ │ ├── BaseComponent.kt │ │ │ │ └── Component.kt │ │ │ ├── config │ │ │ │ └── TinyEsDependencyConfig.kt │ │ │ └── context │ │ │ │ └── Context.kt │ │ │ ├── bankDemo │ │ │ ├── BankContext.kt │ │ │ ├── accounts │ │ │ │ ├── api │ │ │ │ │ ├── AccountAggregate.kt │ │ │ │ │ └── AccountEvents.kt │ │ │ │ ├── config │ │ │ │ │ └── AccountBoundedContextConfig.kt │ │ │ │ ├── logic │ │ │ │ │ └── Account.kt │ │ │ │ └── subscribers │ │ │ │ │ └── TransactionsSubscriber.kt │ │ │ └── transfers │ │ │ │ ├── api │ │ │ │ ├── TransferTransactionAggregate.kt │ │ │ │ └── TransferTransactionEvents.kt │ │ │ │ ├── config │ │ │ │ └── TransactionCoundedContextConfig.kt │ │ │ │ ├── db │ │ │ │ ├── BankAccountCacheRepository.kt │ │ │ │ ├── BankAccountCacheRepositoryImpl.kt │ │ │ │ └── entity │ │ │ │ │ └── BankAccount.kt │ │ │ │ ├── logic │ │ │ │ └── TransaferTransaction.kt │ │ │ │ ├── projections │ │ │ │ └── BankAccountsExistenceCache.kt │ │ │ │ ├── service │ │ │ │ └── TransactionService.kt │ │ │ │ └── subscribers │ │ │ │ └── BankAccountsSubscriber.kt │ │ │ └── projectDemo │ │ │ ├── ProjectContext.kt │ │ │ ├── api │ │ │ ├── ProjectAggregate.kt │ │ │ └── ProjectAggregateDomainEvents.kt │ │ │ ├── config │ │ │ └── ProjectDemoConfig.kt │ │ │ ├── logic │ │ │ ├── ProjectAggregateCommands.kt │ │ │ └── ProjectAggregateState.kt │ │ │ └── projections │ │ │ ├── AnnotationBasedProjectEventsSubscriber.kt │ │ │ └── ProjectEventsSubscriber.kt │ └── resources │ │ ├── application.properties │ │ └── logback.xml │ └── test │ └── kotlin │ ├── ActiveStreamReaderTest.kt │ ├── BankAggregateStateTest.kt │ ├── BaseTest.kt │ ├── EventStreamsTest.kt │ ├── StreamEventOrderingTest.kt │ ├── TransferTransactionAggregateStateTest.kt │ └── config │ └── DockerPostgresDataSourceInitialize.kt ├── tiny-event-sourcing-lib ├── pom.xml └── src │ └── main │ └── kotlin │ └── ru │ └── quipy │ ├── core │ ├── AggregateRegistry.kt │ ├── BasicAggregateRegistry.kt │ ├── EventSourcingProperties.kt │ ├── EventSourcingService.kt │ ├── EventSourcingServiceFactory.kt │ ├── SeekingForSuitableClassesAggregateRegistry.kt │ ├── annotations │ │ ├── AggregateType.kt │ │ ├── DomainEvent.kt │ │ └── StateTransitionFunc.kt │ └── exceptions │ │ ├── DuplicateEventIdException.kt │ │ └── EventRecordOptimisticLockException.kt │ ├── database │ └── EventStore.kt │ ├── domain │ └── EventSourcingDomain.kt │ ├── mapper │ └── EventMapper.kt │ ├── saga │ ├── SagaInfo.kt │ ├── SagaManager.kt │ └── aggregate │ │ ├── api │ │ ├── SagaStepAggregate.kt │ │ └── SagaStepEvents.kt │ │ ├── logic │ │ └── SagaStepAggregateState.kt │ │ └── stream │ │ └── SagaEventStream.kt │ ├── streams │ ├── AggregateEventStream.kt │ ├── AggregateEventStreamManager.kt │ ├── AggregateSubscriptionsManager.kt │ ├── BufferedAggregateEventStream.kt │ ├── EventStoreReader.kt │ ├── EventStreamReaderManager.kt │ ├── EventStreamSubscriber.kt │ ├── EventsChannel.kt │ └── annotation │ │ ├── AggregateSubscriber.kt │ │ └── SubscribeEvent.kt │ └── utils │ ├── Batcher.kt │ ├── NamedThreadFactory.kt │ └── PeriodicalJob.kt ├── tiny-event-sourcing-sagas-projections ├── pom.xml └── src │ └── main │ ├── kotlin │ └── ru │ │ └── quipy │ │ ├── SagasProjectionsApplication.kt │ │ └── projections │ │ ├── SagaDefaultProjections.kt │ │ └── SagaProjections.kt │ └── resources │ └── application.properties ├── tiny-event-sourcing-spring-app ├── .env ├── docker-compose.yml ├── mongo │ └── Dockerfile ├── pom.xml └── src │ ├── main │ ├── kotlin │ │ └── ru.quipy │ │ │ ├── Application.kt │ │ │ ├── bankDemo │ │ │ ├── accounts │ │ │ │ ├── api │ │ │ │ │ ├── AccountAggregate.kt │ │ │ │ │ └── AccountEvents.kt │ │ │ │ ├── config │ │ │ │ │ └── AccountBoundedContextConfig.kt │ │ │ │ ├── controller │ │ │ │ │ └── AccountController.kt │ │ │ │ ├── logic │ │ │ │ │ └── Account.kt │ │ │ │ └── subscribers │ │ │ │ │ └── TransactionsSubscriber.kt │ │ │ └── transfers │ │ │ │ ├── api │ │ │ │ ├── TransferTransactionAggregate.kt │ │ │ │ └── TransferTransactionEvents.kt │ │ │ │ ├── config │ │ │ │ └── TransactionCoundedContextConfig.kt │ │ │ │ ├── logic │ │ │ │ └── TransaferTransaction.kt │ │ │ │ ├── projections │ │ │ │ └── BankAccountsExistenceCache.kt │ │ │ │ ├── service │ │ │ │ └── TransactionService.kt │ │ │ │ └── subscribers │ │ │ │ └── BankAccountsSubscriber.kt │ │ │ ├── projectDemo │ │ │ ├── api │ │ │ │ ├── ProjectAggregate.kt │ │ │ │ └── ProjectAggregateDomainEvents.kt │ │ │ ├── config │ │ │ │ └── ProjectDemoConfig.kt │ │ │ ├── logic │ │ │ │ ├── ProjectAggregateCommands.kt │ │ │ │ └── ProjectAggregateState.kt │ │ │ └── projections │ │ │ │ ├── AnnotationBasedProjectEventsSubscriber.kt │ │ │ │ └── ProjectEventsSubscriber.kt │ │ │ └── sagaDemo │ │ │ ├── flights │ │ │ ├── api │ │ │ │ ├── FlightAggregate.kt │ │ │ │ └── FlightsEvents.kt │ │ │ ├── config │ │ │ │ └── FlightBoundedContextConfig.kt │ │ │ ├── logic │ │ │ │ └── Flight.kt │ │ │ └── subscribers │ │ │ │ └── PaymentSubscriber.kt │ │ │ ├── payment │ │ │ ├── api │ │ │ │ ├── PaymentAggregate.kt │ │ │ │ └── PaymentEvents.kt │ │ │ ├── config │ │ │ │ └── PaymentBoundedContextConfig.kt │ │ │ ├── logic │ │ │ │ └── Payment.kt │ │ │ └── subscribers │ │ │ │ └── TripSubscriber.kt │ │ │ └── trips │ │ │ ├── api │ │ │ ├── TripAggregate.kt │ │ │ └── TripEvents.kt │ │ │ ├── config │ │ │ └── TripBoundedContextConfig.kt │ │ │ ├── controller │ │ │ └── TripController.kt │ │ │ ├── logic │ │ │ └── Trip.kt │ │ │ └── subscribers │ │ │ ├── FlightSubscriber.kt │ │ │ └── PaymentTripSubscriber.kt │ └── resources │ │ └── application.properties │ └── test │ ├── kotlin │ └── ru.quipy │ │ ├── ActiveStreamReaderTest.kt │ │ ├── BankAggregateStateTest.kt │ │ ├── BaseTest.kt │ │ ├── EventStreamsTest.kt │ │ ├── StreamEventOrderingTest.kt │ │ ├── TransferTransactionAggregateStateTest.kt │ │ └── config │ │ └── DockerPostgresDataSourceInitialize.kt │ └── resources │ └── application-test.yml ├── tiny-event-sourcing-spring-boot-starter ├── pom.xml └── src │ └── main │ ├── kotlin │ └── ru │ │ └── quipy │ │ └── config │ │ └── EventSourcingLibConfig.kt │ └── resources │ └── META-INF │ ├── spring-configuration-metadata.json │ └── spring.factories ├── tiny-mongo-event-store-spring-boot-starter ├── pom.xml └── src │ ├── main │ ├── kotlin │ │ └── ru │ │ │ └── quipy │ │ │ ├── MongoTemplateEventStore.kt │ │ │ └── autoconfigure │ │ │ └── MongoEventStoreAutoConfiguration.kt │ └── resources │ │ └── META-INF │ │ └── spring.factories │ └── test │ ├── kotlin │ └── ru │ │ └── quipy │ │ └── updateSerial │ │ ├── TestAggregate.kt │ │ ├── UpdateSerialTest.kt │ │ └── UpdateSerialTestConfiguration.kt │ └── resources │ └── application-test.yml ├── tiny-mongo-event-store ├── pom.xml └── src │ └── main │ └── kotlin │ └── ru │ └── quipy │ └── eventstore │ ├── MongoClientEventStore.kt │ ├── converter │ ├── BsonConverter.kt │ ├── JacksonMongoEntityConverter.kt │ ├── MongoEntityConverter.kt │ └── UuidConverter.kt │ ├── exception │ ├── MongoClientException.kt │ ├── MongoClientExceptionTranslator.kt │ └── MongoDuplicateKeyException.kt │ └── factory │ ├── MongoClientFactory.kt │ └── MongoClientFactoryImpl.kt ├── tiny-postgres-event-store-spring-boot-starter ├── pom.xml └── src │ ├── main │ ├── kotlin │ │ └── ru │ │ │ └── quipy │ │ │ ├── PostgresTemplateEventStore.kt │ │ │ ├── autoconfigure │ │ │ └── PostgresEventStoreAutoConfiguration.kt │ │ │ ├── config │ │ │ ├── DatabaseConfig.kt │ │ │ └── LiquibaseSpringConfig.kt │ │ │ └── mappers │ │ │ ├── ActiveEventStreamReaderRowMapper.kt │ │ │ ├── EventRowMapper.kt │ │ │ ├── EventStreamReadIndexRowMapper.kt │ │ │ ├── MapperFactory.kt │ │ │ ├── MapperFactoryImpl.kt │ │ │ └── SnapshotRowMapper.kt │ └── resources │ │ ├── META-INF │ │ └── spring.factories │ │ ├── application.properties │ │ └── database │ │ ├── .env │ │ └── docker-compose.yml │ └── test │ ├── kotlin │ └── ru │ │ └── quipy │ │ ├── PostgresEventStoreTest.kt │ │ └── config │ │ └── TestDbConfig.kt │ └── resources │ └── application.properties └── tiny-postgresql-event-store ├── pom.xml └── src ├── main ├── kotlin │ └── ru │ │ └── quipy │ │ ├── PostgresClientEventStore.kt │ │ ├── config │ │ └── LiquibaseConfig.kt │ │ ├── converter │ │ ├── EntityConverter.kt │ │ ├── JsonEntityConverter.kt │ │ ├── ResultSetToEntityMapper.kt │ │ ├── ResultSetToEntityMapperImpl.kt │ │ └── exception │ │ │ └── NoMapperForClass.kt │ │ ├── db │ │ ├── DataSourceProvider.kt │ │ ├── DatasourceProviderImpl.kt │ │ ├── HikariDatasourceProvider.kt │ │ └── factory │ │ │ ├── ConnectionFactory.kt │ │ │ └── DataSourceConnectionFactoryImpl.kt │ │ ├── exception │ │ └── UnknownEntityClassException.kt │ │ ├── executor │ │ ├── ExceptionLoggingSqlQueriesExecutor.kt │ │ └── QueryExecutor.kt │ │ ├── query │ │ ├── BasicQuery.kt │ │ ├── Query.kt │ │ ├── QueryBuilder.kt │ │ ├── delete │ │ │ └── DeleteQuery.kt │ │ ├── exception │ │ │ ├── InvalidQueryStateException.kt │ │ │ └── UnmappedDtoType.kt │ │ ├── insert │ │ │ ├── BatchInsertQuery.kt │ │ │ ├── InsertQuery.kt │ │ │ └── OnDuplicateKeyUpdateInsertQuery.kt │ │ ├── select │ │ │ └── SelectQuery.kt │ │ └── update │ │ │ └── UpdateQuery.kt │ │ └── tables │ │ ├── Column.kt │ │ ├── Dto.kt │ │ ├── DtoCreator.kt │ │ └── Tables.kt └── resources │ └── liquibase │ └── changelog.sql └── test └── kotlin └── ru └── quipy └── query ├── InsertQueryTest.kt └── SelectQueryTest.kt /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release to Maven Central 2 | 3 | on: 4 | push: 5 | branches: ["master", "master-2.0"] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: Set up JDK 11 14 | uses: actions/setup-java@v3 15 | with: 16 | distribution: 'temurin' 17 | java-version: '11' 18 | server-id: ossrh 19 | server-username: MAVEN_USERNAME 20 | server-password: MAVEN_PASSWORD 21 | gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} 22 | gpg-passphrase: MAVEN_GPG_PASSPHRASE 23 | 24 | - name: Deploy to Maven Central 25 | run: mvn -B deploy -P release 26 | env: 27 | MAVEN_USERNAME: ${{ secrets.OSSRH_USER }} 28 | MAVEN_PASSWORD: ${{ secrets.OSSRH_PWD }} 29 | MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project exclude paths 2 | **/target/ 3 | *.iml 4 | /.idea/ 5 | # IDEA files 6 | 7 | .idea/libraries/** 8 | .idea/compiler.xml 9 | .idea/jarRepositories.xml 10 | .idea/kotlinc.xml 11 | .idea/vcs.xml 12 | .idea/misc.xml 13 | .idea/modules.xml 14 | 15 | **/*.iml 16 | 17 | *.versionsBackup -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | # GitHub Copilot persisted chat sessions 10 | /copilot/chatSessions 11 | -------------------------------------------------------------------------------- /images/AggregareStateTransitions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrsuh/tiny-event-sourcing/880796f04bb6e27aab615c8efccd709d5ca22193/images/AggregareStateTransitions.png -------------------------------------------------------------------------------- /images/AggregationSagaExample.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrsuh/tiny-event-sourcing/880796f04bb6e27aab615c8efccd709d5ca22193/images/AggregationSagaExample.PNG -------------------------------------------------------------------------------- /images/CommandResultsInEvent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrsuh/tiny-event-sourcing/880796f04bb6e27aab615c8efccd709d5ca22193/images/CommandResultsInEvent.png -------------------------------------------------------------------------------- /images/CommandResultsInEventNew.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrsuh/tiny-event-sourcing/880796f04bb6e27aab615c8efccd709d5ca22193/images/CommandResultsInEventNew.png -------------------------------------------------------------------------------- /images/ConfigClassDiagramm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrsuh/tiny-event-sourcing/880796f04bb6e27aab615c8efccd709d5ca22193/images/ConfigClassDiagramm.png -------------------------------------------------------------------------------- /images/EventInsertion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrsuh/tiny-event-sourcing/880796f04bb6e27aab615c8efccd709d5ca22193/images/EventInsertion.png -------------------------------------------------------------------------------- /images/Example1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrsuh/tiny-event-sourcing/880796f04bb6e27aab615c8efccd709d5ca22193/images/Example1.png -------------------------------------------------------------------------------- /images/GeneralAggregateUpdate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrsuh/tiny-event-sourcing/880796f04bb6e27aab615c8efccd709d5ca22193/images/GeneralAggregateUpdate.png -------------------------------------------------------------------------------- /images/NestedSagaExample.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrsuh/tiny-event-sourcing/880796f04bb6e27aab615c8efccd709d5ca22193/images/NestedSagaExample.PNG -------------------------------------------------------------------------------- /images/SagaExample.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrsuh/tiny-event-sourcing/880796f04bb6e27aab615c8efccd709d5ca22193/images/SagaExample.PNG -------------------------------------------------------------------------------- /images/SagaProcessing.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrsuh/tiny-event-sourcing/880796f04bb6e27aab615c8efccd709d5ca22193/images/SagaProcessing.PNG -------------------------------------------------------------------------------- /tiny-event-sourcing-app-base/pom.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 4.0.0 6 | jar 7 | 8 | 9 | ru.quipy 10 | tiny-event-sourcing 11 | 2.7.4 12 | 13 | 14 | tiny-event-sourcing-app-base 15 | tiny-event-sourcing-app-base 16 | 17 | 18 | 11 19 | true 20 | 21 | 22 | 23 | 24 | ru.quipy 25 | tiny-event-sourcing-lib 26 | ${project.version} 27 | 28 | 29 | 30 | ru.quipy 31 | tiny-postgresql-event-store 32 | ${project.version} 33 | 34 | 35 | 36 | org.slf4j 37 | slf4j-api 38 | 2.0.9 39 | 40 | 41 | 42 | ch.qos.logback 43 | logback-classic 44 | 1.4.14 45 | 46 | 47 | 48 | ch.qos.logback 49 | logback-core 50 | 1.4.14 51 | 52 | 53 | 54 | 55 | src/main/kotlin 56 | 57 | 58 | org.springframework.boot 59 | spring-boot-maven-plugin 60 | 61 | 62 | org.jetbrains.kotlin 63 | kotlin-maven-plugin 64 | ${kotlin.version} 65 | 66 | 67 | compile 68 | compile 69 | 70 | compile 71 | 72 | 73 | 74 | 75 | 76 | -Xjsr305=strict 77 | 78 | 79 | spring 80 | 81 | 82 | 83 | 84 | org.jetbrains.kotlin 85 | kotlin-maven-allopen 86 | 1.6.10 87 | 88 | 89 | 90 | 91 | org.apache.maven.plugins 92 | maven-compiler-plugin 93 | 94 | 8 95 | 8 96 | 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /tiny-event-sourcing-app-base/src/main/kotlin/ru.quipy/TinyEsLibConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import ru.quipy.converter.EntityConverter 5 | import ru.quipy.converter.ResultSetToEntityMapper 6 | import ru.quipy.core.AggregateRegistry 7 | import ru.quipy.core.EventSourcingProperties 8 | import ru.quipy.core.EventSourcingService 9 | import ru.quipy.core.EventSourcingServiceFactory 10 | import ru.quipy.database.EventStore 11 | import ru.quipy.db.DataSourceProvider 12 | import ru.quipy.mapper.EventMapper 13 | import ru.quipy.saga.SagaManager 14 | import ru.quipy.saga.aggregate.api.SagaStepAggregate 15 | import ru.quipy.saga.aggregate.logic.SagaStepAggregateState 16 | import ru.quipy.saga.aggregate.stream.SagaEventStream 17 | import ru.quipy.streams.AggregateEventStreamManager 18 | import ru.quipy.streams.AggregateSubscriptionsManager 19 | import ru.quipy.streams.EventStreamReaderManager 20 | import java.util.UUID 21 | 22 | class TinyEsLibConfig ( 23 | val objectMapper: ObjectMapper, 24 | val eventMapper: EventMapper, 25 | val eventSourcingProperties: EventSourcingProperties, 26 | val aggregateRegistry: AggregateRegistry, 27 | val eventStreamReaderManager: EventStreamReaderManager, 28 | val eventStreamManager: AggregateEventStreamManager, 29 | val subscriptionsManager: AggregateSubscriptionsManager, 30 | val eventSourcingServiceFactory: EventSourcingServiceFactory, 31 | val sagaStepEsService: EventSourcingService, 32 | val sagaManager: SagaManager, 33 | val sagaEventStream: SagaEventStream, 34 | val entityConverter: EntityConverter, 35 | val resultSetToEntityMapper: ResultSetToEntityMapper, 36 | val datasourceProvider: DataSourceProvider, 37 | val eventStore: EventStore, 38 | // val databaseConnectionFactory: ConnectionFactory 39 | ) {} -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/Application.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy 2 | 3 | import ru.quipy.application.App 4 | 5 | class Application 6 | fun main(args: Array) { 7 | App.start() 8 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/application/App.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.application 2 | 3 | import ru.quipy.application.context.Context 4 | import ru.quipy.core.EventSourcingProperties 5 | import java.util.Properties 6 | 7 | class App private constructor(val context: Context) { 8 | 9 | companion object { 10 | private var app: App? = null 11 | fun start() { 12 | val properties = Properties(); 13 | properties.load(App::class.java.classLoader.getResourceAsStream("application.properties")) 14 | if (app == null) { 15 | app = App(Context(properties, eventSourcingProperties = EventSourcingProperties())) 16 | } 17 | } 18 | } 19 | 20 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/application/component/BaseComponent.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.application.component 2 | 3 | open class BaseComponent : Component { 4 | override fun postConstruct() { 5 | // no op 6 | } 7 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/application/component/Component.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.application.component 2 | 3 | interface Component { 4 | fun postConstruct() 5 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/application/context/Context.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.application.context 2 | 3 | import com.mongodb.ConnectionString 4 | import com.mongodb.MongoClientSettings 5 | import com.mongodb.client.MongoClients 6 | import com.mongodb.client.MongoDatabase 7 | import com.zaxxer.hikari.HikariConfig 8 | import com.zaxxer.hikari.HikariDataSource 9 | import org.bson.UuidRepresentation 10 | import ru.quipy.TinyEsLibConfig 11 | import ru.quipy.application.config.TinyEsDependencyConfig 12 | import ru.quipy.bankDemo.BankContext 13 | import ru.quipy.config.LiquibaseConfig 14 | import ru.quipy.core.EventSourcingProperties 15 | import ru.quipy.projectDemo.ProjectContext 16 | import java.util.Properties 17 | 18 | class Context private constructor() { 19 | lateinit var tinyEsLibConfig: TinyEsLibConfig 20 | 21 | lateinit var dataSource: HikariDataSource 22 | lateinit var mongoDatabase: MongoDatabase 23 | 24 | lateinit var projectContext: ProjectContext 25 | lateinit var bankContext: BankContext 26 | constructor(properties: Properties, eventSourcingProperties: EventSourcingProperties) : this() { 27 | this.dataSource = dataSource(properties) 28 | LiquibaseConfig().liquibase(dataSource, properties.getProperty("event.sourcing.db-schema")) 29 | 30 | this.mongoDatabase = mongoDatabase(properties) 31 | 32 | tinyEsLibConfig = TinyEsDependencyConfig(properties, dataSource, eventSourcingProperties) 33 | .tinyEsLibConfig 34 | 35 | projectContext = ProjectContext(tinyEsLibConfig) 36 | bankContext = BankContext(tinyEsLibConfig, mongoDatabase) 37 | } 38 | 39 | 40 | private fun dataSource(properties: Properties) : HikariDataSource { 41 | val hikariConfig = HikariConfig() 42 | hikariConfig.jdbcUrl = properties.getProperty("datasource.jdbc-url") 43 | hikariConfig.username = properties.getProperty("datasource.username") 44 | hikariConfig.password = properties.getProperty("datasource.password") 45 | 46 | return HikariDataSource(hikariConfig) 47 | } 48 | 49 | private fun mongoDatabase(properties: Properties) : MongoDatabase { 50 | val clientSettings = MongoClientSettings.builder() 51 | .uuidRepresentation(UuidRepresentation.STANDARD) 52 | .applyConnectionString(ConnectionString(properties.getProperty("mongodb.url"))) 53 | .build() 54 | 55 | return MongoClients.create(clientSettings) 56 | .getDatabase("tiny-es") 57 | } 58 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/BankContext.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo 2 | 3 | import com.mongodb.client.MongoDatabase 4 | import ru.quipy.TinyEsLibConfig 5 | import ru.quipy.application.component.Component 6 | import ru.quipy.bankDemo.accounts.api.AccountAggregate 7 | import ru.quipy.bankDemo.accounts.config.AccountBoundedContextConfig 8 | import ru.quipy.bankDemo.accounts.logic.Account 9 | import ru.quipy.bankDemo.accounts.subscribers.TransactionsSubscriber 10 | import ru.quipy.bankDemo.transfers.api.TransferTransactionAggregate 11 | import ru.quipy.bankDemo.transfers.config.TransactionCoundedContextConfig 12 | import ru.quipy.bankDemo.transfers.db.BankAccountCacheRepository 13 | import ru.quipy.bankDemo.transfers.db.BankAccountCacheRepositoryImpl 14 | import ru.quipy.bankDemo.transfers.logic.TransferTransaction 15 | import ru.quipy.bankDemo.transfers.projections.BankAccountsExistenceCache 16 | import ru.quipy.bankDemo.transfers.service.TransactionService 17 | import ru.quipy.bankDemo.transfers.subscribers.BankAccountsSubscriber 18 | import ru.quipy.core.EventSourcingService 19 | import java.util.UUID 20 | 21 | class BankContext private constructor() { 22 | lateinit var components: List 23 | lateinit var accountBoundedContextConfig: AccountBoundedContextConfig 24 | lateinit var transactionsSubscriber: TransactionsSubscriber 25 | lateinit var accountEventSourcingService: EventSourcingService 26 | 27 | lateinit var transactionCoundedContextConfig: TransactionCoundedContextConfig 28 | lateinit var bankAccountsExistenceCache: BankAccountsExistenceCache 29 | 30 | lateinit var bankAccountCacheRepository: BankAccountCacheRepository 31 | lateinit var transactionService : TransactionService 32 | lateinit var bankAccountsSubscriber: BankAccountsSubscriber 33 | lateinit var transactionEventSourcingService: EventSourcingService 34 | constructor(tinyEsLibConfig: TinyEsLibConfig, mongoDatabase: MongoDatabase) : this() { 35 | accountBoundedContextConfig = AccountBoundedContextConfig(tinyEsLibConfig.eventSourcingServiceFactory) 36 | accountEventSourcingService = accountBoundedContextConfig.accountEsService() 37 | transactionsSubscriber = TransactionsSubscriber(tinyEsLibConfig.subscriptionsManager, accountEventSourcingService) 38 | 39 | transactionCoundedContextConfig = TransactionCoundedContextConfig(tinyEsLibConfig.eventSourcingServiceFactory) 40 | bankAccountCacheRepository = BankAccountCacheRepositoryImpl(mongoDatabase) 41 | 42 | bankAccountsExistenceCache = BankAccountsExistenceCache(bankAccountCacheRepository, tinyEsLibConfig.subscriptionsManager) 43 | 44 | transactionEventSourcingService = transactionCoundedContextConfig.transactionEsService() 45 | transactionService = TransactionService(bankAccountCacheRepository, transactionEventSourcingService) 46 | bankAccountsSubscriber = BankAccountsSubscriber(tinyEsLibConfig.subscriptionsManager, transactionEventSourcingService) 47 | 48 | components = mutableListOf(transactionsSubscriber, bankAccountsExistenceCache, transactionService, bankAccountsSubscriber) 49 | components.forEach { it.postConstruct() } 50 | } 51 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/accounts/api/AccountAggregate.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.accounts.api 2 | 3 | import ru.quipy.core.annotations.AggregateType 4 | import ru.quipy.domain.Aggregate 5 | 6 | @AggregateType(aggregateEventsTableName = "accounts") 7 | class AccountAggregate: Aggregate -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/accounts/api/AccountEvents.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.accounts.api 2 | 3 | import ru.quipy.core.annotations.DomainEvent 4 | import ru.quipy.domain.Event 5 | import java.math.BigDecimal 6 | import java.util.UUID 7 | 8 | const val ACCOUNT_CREATED = "ACCOUNT_CREATED_EVENT" 9 | const val BANK_ACCOUNT_CREATED = "BANK_ACCOUNT_CREATED_EVENT" 10 | const val BANK_ACCOUNT_DEPOSIT = "BANK_ACCOUNT_DEPOSIT_EVENT" 11 | const val BANK_ACCOUNT_WITHDRAWAL = "BANK_ACCOUNT_WITHDRAWAL_EVENT" 12 | const val INTERNAL_ACCOUNT_TRANSFER = "INTERNAL_ACCOUNT_TRANSFER_EVENT" 13 | 14 | const val TRANSFER_TRANSACTION_ACCEPTED = "TRANSFER_TRANSACTION_ACCEPTED" 15 | const val TRANSFER_TRANSACTION_DECLINED = "TRANSFER_TRANSACTION_DECLINED" 16 | const val TRANSFER_TRANSACTION_PROCESSED = "TRANSFER_TRANSACTION_PROCESSED" 17 | const val TRANSFER_TRANSACTION_ROLLBACKED = "TRANSFER_TRANSACTION_ROLLBACKED" 18 | 19 | 20 | @DomainEvent(name = ACCOUNT_CREATED) 21 | data class AccountCreatedEvent( 22 | val accountId: UUID, 23 | val userId: UUID, 24 | ) : Event( 25 | name = ACCOUNT_CREATED, 26 | ) 27 | 28 | @DomainEvent(name = BANK_ACCOUNT_CREATED) 29 | data class BankAccountCreatedEvent( 30 | val accountId: UUID, 31 | val bankAccountId: UUID, 32 | ) : Event( 33 | name = BANK_ACCOUNT_CREATED, 34 | ) 35 | 36 | @DomainEvent(name = BANK_ACCOUNT_DEPOSIT) 37 | data class BankAccountDepositEvent( 38 | val accountId: UUID, 39 | val bankAccountId: UUID, 40 | val amount: BigDecimal, 41 | ) : Event( 42 | name = BANK_ACCOUNT_DEPOSIT, 43 | ) 44 | 45 | @DomainEvent(name = BANK_ACCOUNT_WITHDRAWAL) 46 | data class BankAccountWithdrawalEvent( 47 | val accountId: UUID, 48 | val bankAccountId: UUID, 49 | val amount: BigDecimal, 50 | ) : Event( 51 | name = BANK_ACCOUNT_WITHDRAWAL, 52 | ) 53 | 54 | @DomainEvent(name = INTERNAL_ACCOUNT_TRANSFER) 55 | data class InternalAccountTransferEvent( 56 | val accountId: UUID, 57 | val bankAccountIdFrom: UUID, 58 | val bankAccountIdTo: UUID, 59 | val amount: BigDecimal, 60 | ) : Event( 61 | name = INTERNAL_ACCOUNT_TRANSFER, 62 | ) 63 | 64 | @DomainEvent(name = TRANSFER_TRANSACTION_ACCEPTED) 65 | data class TransferTransactionAcceptedEvent( 66 | val accountId: UUID, 67 | val bankAccountId: UUID, 68 | val transactionId: UUID, 69 | val transferAmount: BigDecimal, 70 | val isDeposit: Boolean 71 | ) : Event( 72 | name = TRANSFER_TRANSACTION_ACCEPTED, 73 | ) 74 | 75 | @DomainEvent(name = TRANSFER_TRANSACTION_DECLINED) 76 | data class TransferTransactionDeclinedEvent( 77 | val accountId: UUID, 78 | val bankAccountId: UUID, 79 | val transactionId: UUID, 80 | val reason: String 81 | ) : Event( 82 | name = TRANSFER_TRANSACTION_DECLINED, 83 | ) 84 | 85 | @DomainEvent(name = TRANSFER_TRANSACTION_PROCESSED) 86 | data class TransferTransactionProcessedEvent( 87 | val accountId: UUID, 88 | val bankAccountId: UUID, 89 | val transactionId: UUID, 90 | ) : Event( 91 | name = TRANSFER_TRANSACTION_PROCESSED, 92 | ) 93 | 94 | @DomainEvent(name = TRANSFER_TRANSACTION_ROLLBACKED) 95 | data class TransferTransactionRollbackedEvent( 96 | val accountId: UUID, 97 | val bankAccountId: UUID, 98 | val transactionId: UUID, 99 | ) : Event( 100 | name = TRANSFER_TRANSACTION_ROLLBACKED, 101 | ) -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/accounts/config/AccountBoundedContextConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.accounts.config 2 | 3 | import ru.quipy.bankDemo.accounts.api.AccountAggregate 4 | import ru.quipy.bankDemo.accounts.logic.Account 5 | import ru.quipy.core.EventSourcingService 6 | import ru.quipy.core.EventSourcingServiceFactory 7 | import java.util.UUID 8 | class AccountBoundedContextConfig(val eventSourcingServiceFactory: EventSourcingServiceFactory) { 9 | fun accountEsService(): EventSourcingService = 10 | eventSourcingServiceFactory.create() 11 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/accounts/subscribers/TransactionsSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.accounts.subscribers 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import ru.quipy.application.component.Component 6 | import ru.quipy.bankDemo.accounts.api.AccountAggregate 7 | import ru.quipy.bankDemo.accounts.logic.Account 8 | import ru.quipy.bankDemo.transfers.api.TransactionConfirmedEvent 9 | import ru.quipy.bankDemo.transfers.api.TransferTransactionAggregate 10 | import ru.quipy.bankDemo.transfers.api.TransferTransactionCreatedEvent 11 | import ru.quipy.core.EventSourcingService 12 | import ru.quipy.streams.AggregateSubscriptionsManager 13 | import java.util.UUID 14 | 15 | class TransactionsSubscriber( 16 | private val subscriptionsManager: AggregateSubscriptionsManager, 17 | private val accountEsService: EventSourcingService 18 | ) : Component { 19 | private val logger: Logger = LoggerFactory.getLogger(TransactionsSubscriber::class.java) 20 | 21 | override fun postConstruct() { 22 | subscriptionsManager.createSubscriber(TransferTransactionAggregate::class, "accounts::transaction-processing-subscriber") { 23 | `when`(TransferTransactionCreatedEvent::class) { event -> 24 | logger.info("Got transaction to process: $event") 25 | 26 | val transactionOutcome1 = accountEsService.update(event.sourceAccountId) { // todo sukhoa idempotence! 27 | it.performTransferFrom( 28 | event.sourceBankAccountId, 29 | event.transferId, 30 | event.transferAmount 31 | ) 32 | } 33 | 34 | val transactionOutcome2 = accountEsService.update(event.destinationAccountId) { // todo sukhoa idempotence! 35 | it.performTransferTo( 36 | event.destinationBankAccountId, 37 | event.transferId, 38 | event.transferAmount 39 | ) 40 | } 41 | 42 | logger.info("Transaction: ${event.transferId}. Outcomes: $transactionOutcome1, $transactionOutcome2") 43 | } 44 | `when`(TransactionConfirmedEvent::class) { event -> 45 | logger.info("Got transaction confirmed event: $event") 46 | 47 | val transactionOutcome1 = accountEsService.update(event.sourceAccountId) { // todo sukhoa idempotence! 48 | it.processPendingTransaction(event.sourceBankAccountId, event.transferId) 49 | } 50 | 51 | val transactionOutcome2 = accountEsService.update(event.destinationAccountId) { // todo sukhoa idempotence! 52 | it.processPendingTransaction(event.destinationBankAccountId, event.transferId) 53 | } 54 | 55 | logger.info("Transaction: ${event.transferId}. Outcomes: $transactionOutcome1, $transactionOutcome2") 56 | } 57 | // todo sukhoa bank account deleted event 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/transfers/api/TransferTransactionAggregate.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.api 2 | 3 | import ru.quipy.core.annotations.AggregateType 4 | import ru.quipy.domain.Aggregate 5 | 6 | @AggregateType(aggregateEventsTableName = "transfers") 7 | class TransferTransactionAggregate: Aggregate -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/transfers/api/TransferTransactionEvents.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.api 2 | 3 | import ru.quipy.core.annotations.DomainEvent 4 | import ru.quipy.domain.Event 5 | import java.math.BigDecimal 6 | import java.util.UUID 7 | 8 | const val TRANSFER_TRANSACTION_CREATED = "TRANSFER_TRANSACTION_CREATED" 9 | const val TRANSFER_PARTICIPANT_ACCEPTED = "TRANSFER_PARTICIPANT_ACCEPTED" 10 | const val TRANSFER_CONFIRMED = "TRANSFER_CONFIRMED" 11 | const val TRANSFER_NOT_CONFIRMED = "TRANSFER_NOT_CONFIRMED" 12 | const val TRANSFER_PARTICIPANT_COMMITTED = "TRANSFER_PARTICIPANT_COMMITTED" 13 | const val TRANSFER_SUCCEEDED = "TRANSFER_SUCCEEDED" 14 | const val TRANSFER_PARTICIPANT_ROLLBACKED = "TRANSFER_PARTICIPANT_ROLLBACKED" 15 | const val TRANSFER_FAILED = "TRANSFER_FAILED" 16 | const val NOOP = "NOOP" 17 | const val TRANSFER_CANCELLED = "TRANSFER_CANCELLED" 18 | 19 | @DomainEvent(name = TRANSFER_TRANSACTION_CREATED) 20 | data class TransferTransactionCreatedEvent( 21 | val transferId: UUID, 22 | val sourceAccountId: UUID, 23 | val sourceBankAccountId: UUID, 24 | val destinationAccountId: UUID, 25 | val destinationBankAccountId: UUID, 26 | val transferAmount: BigDecimal, 27 | ) : Event( 28 | name = TRANSFER_TRANSACTION_CREATED, 29 | ) 30 | 31 | @DomainEvent(name = TRANSFER_PARTICIPANT_ACCEPTED) 32 | data class TransferParticipantAcceptedEvent( 33 | val transferId: UUID, 34 | val participantBankAccountId: UUID, 35 | ) : Event( 36 | name = TRANSFER_PARTICIPANT_ACCEPTED, 37 | ) 38 | 39 | @DomainEvent(name = TRANSFER_CONFIRMED) 40 | data class TransactionConfirmedEvent( 41 | val transferId: UUID, 42 | val sourceAccountId: UUID, 43 | val sourceBankAccountId: UUID, 44 | val destinationAccountId: UUID, 45 | val destinationBankAccountId: UUID, 46 | ) : Event( 47 | name = TRANSFER_CONFIRMED, 48 | ) 49 | 50 | @DomainEvent(name = TRANSFER_NOT_CONFIRMED) 51 | data class TransactionNotConfirmedEvent( 52 | val transferId: UUID, 53 | val sourceAccountId: UUID, 54 | val sourceBankAccountId: UUID, 55 | val destinationAccountId: UUID, 56 | val destinationBankAccountId: UUID, 57 | ) : Event( 58 | name = TRANSFER_NOT_CONFIRMED, 59 | ) 60 | 61 | @DomainEvent(name = NOOP) 62 | data class NoopEvent( 63 | val transferId: UUID, 64 | ) : Event( 65 | name = NOOP, 66 | ) 67 | 68 | @DomainEvent(name = TRANSFER_PARTICIPANT_COMMITTED) 69 | data class TransferParticipantCommittedEvent( 70 | val transferId: UUID, 71 | val participantBankAccountId: UUID, 72 | ) : Event( 73 | name = TRANSFER_PARTICIPANT_COMMITTED, 74 | ) 75 | 76 | @DomainEvent(name = TRANSFER_PARTICIPANT_ROLLBACKED) 77 | data class TransferParticipantRollbackedEvent( 78 | val transferId: UUID, 79 | val participantBankAccountId: UUID, 80 | ) : Event( 81 | name = TRANSFER_PARTICIPANT_ROLLBACKED, 82 | ) 83 | 84 | 85 | @DomainEvent(name = TRANSFER_SUCCEEDED) 86 | data class TransactionSucceededEvent( 87 | val transferId: UUID, 88 | ) : Event( 89 | name = TRANSFER_SUCCEEDED, 90 | ) 91 | 92 | @DomainEvent(name = TRANSFER_FAILED) 93 | data class TransactionFailedEvent( 94 | val transferId: UUID, 95 | ) : Event( 96 | name = TRANSFER_FAILED, 97 | ) 98 | -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/transfers/config/TransactionCoundedContextConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.config 2 | 3 | import ru.quipy.bankDemo.transfers.api.TransferTransactionAggregate 4 | import ru.quipy.bankDemo.transfers.logic.TransferTransaction 5 | import ru.quipy.core.EventSourcingService 6 | import ru.quipy.core.EventSourcingServiceFactory 7 | import java.util.UUID 8 | 9 | class TransactionCoundedContextConfig(val eventSourcingServiceFactory: EventSourcingServiceFactory) { 10 | 11 | fun transactionEsService(): EventSourcingService = 12 | eventSourcingServiceFactory.create() 13 | 14 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/transfers/db/BankAccountCacheRepository.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.db 2 | 3 | import ru.quipy.bankDemo.transfers.db.entity.BankAccount 4 | import java.util.Optional 5 | import java.util.UUID 6 | 7 | interface BankAccountCacheRepository { 8 | fun save(bankAccount: BankAccount) 9 | fun findById(id: UUID) : Optional 10 | fun existsById(bankAccountId: UUID) : Boolean 11 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/transfers/db/BankAccountCacheRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.db 2 | 3 | import com.mongodb.client.MongoCollection 4 | import com.mongodb.client.MongoDatabase 5 | import com.mongodb.client.model.Filters.eq 6 | import org.bson.Document 7 | import ru.quipy.bankDemo.transfers.db.entity.BankAccount 8 | import java.util.Optional 9 | import java.util.UUID 10 | 11 | class BankAccountCacheRepositoryImpl(private val mongoDatabase: MongoDatabase): BankAccountCacheRepository { 12 | override fun save(bankAccount: BankAccount) { 13 | val collection: MongoCollection = mongoDatabase.getCollection("bank-account") 14 | val document = Document(mapOf(Pair("_id", UUID.randomUUID()), 15 | Pair("accountId", bankAccount.accountId), 16 | Pair("bankAccountId", bankAccount.bankAccountId))) 17 | collection.insertOne(document) 18 | } 19 | 20 | override fun findById(id: UUID): Optional { 21 | val collection: MongoCollection = mongoDatabase.getCollection("bank-account") 22 | val document = collection.find(eq("bankAccountId", id)).first() 23 | document?: return Optional.empty() 24 | return Optional.of(BankAccount(document["bankAccountId"] as UUID, document["accountId"] as UUID)) 25 | } 26 | 27 | override fun existsById(bankAccountId: UUID): Boolean { 28 | val collection: MongoCollection = mongoDatabase.getCollection("bank-account") 29 | collection.find(eq("bankAccountId", bankAccountId)).first() ?: return false 30 | return true 31 | } 32 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/transfers/db/entity/BankAccount.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.db.entity 2 | 3 | import java.util.UUID 4 | 5 | data class BankAccount( 6 | val bankAccountId: UUID, 7 | var accountId: UUID 8 | ) -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/transfers/projections/BankAccountsExistenceCache.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.projections 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import ru.quipy.application.component.Component 6 | import ru.quipy.bankDemo.accounts.api.AccountAggregate 7 | import ru.quipy.bankDemo.accounts.api.BankAccountCreatedEvent 8 | import ru.quipy.bankDemo.transfers.db.BankAccountCacheRepository 9 | import ru.quipy.bankDemo.transfers.db.entity.BankAccount 10 | import ru.quipy.streams.AggregateSubscriptionsManager 11 | 12 | class BankAccountsExistenceCache( 13 | private val bankAccountCacheRepository: BankAccountCacheRepository, 14 | private val subscriptionsManager: AggregateSubscriptionsManager 15 | ) : Component { 16 | private val logger: Logger = LoggerFactory.getLogger(BankAccountsExistenceCache::class.java) 17 | 18 | override fun postConstruct() { 19 | subscriptionsManager.createSubscriber(AccountAggregate::class, "transactions::accounts-cache") { 20 | `when`(BankAccountCreatedEvent::class) { event -> 21 | bankAccountCacheRepository.save(BankAccount(event.bankAccountId, event.accountId)) // todo sukhoa idempotence! 22 | logger.info("Update bank account cache, create account ${event.accountId}-${event.bankAccountId}") 23 | } 24 | // todo sukhoa bank account deleted event 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/transfers/service/TransactionService.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.service 2 | 3 | import ru.quipy.application.component.BaseComponent 4 | import ru.quipy.bankDemo.transfers.api.TransferTransactionAggregate 5 | import ru.quipy.bankDemo.transfers.api.TransferTransactionCreatedEvent 6 | import ru.quipy.bankDemo.transfers.db.BankAccountCacheRepository 7 | import ru.quipy.bankDemo.transfers.logic.TransferTransaction 8 | import ru.quipy.core.EventSourcingService 9 | import java.math.BigDecimal 10 | import java.util.UUID 11 | 12 | class TransactionService( 13 | private val bankAccountCacheRepository: BankAccountCacheRepository, 14 | private val transactionEsService: EventSourcingService 15 | ) : BaseComponent() { 16 | fun initiateTransferTransaction( 17 | sourceBankAccountId: UUID, 18 | destinationBankAccountId: UUID, 19 | transferAmount: BigDecimal 20 | ): TransferTransactionCreatedEvent { 21 | val srcBankAccount = bankAccountCacheRepository.findById(sourceBankAccountId).orElseThrow { 22 | IllegalArgumentException("Cannot create transaction. There is no source bank account: $sourceBankAccountId") 23 | } 24 | 25 | val dstBankAccount = bankAccountCacheRepository.findById(destinationBankAccountId).orElseThrow { 26 | IllegalArgumentException("Cannot create transaction. There is no destination bank account: $destinationBankAccountId") 27 | } 28 | 29 | return transactionEsService.create { 30 | it.initiateTransferTransaction( 31 | sourceAccountId = srcBankAccount.accountId, 32 | sourceBankAccountId = srcBankAccount.bankAccountId, 33 | destinationAccountId = dstBankAccount.accountId, 34 | destinationBankAccountId = dstBankAccount.bankAccountId, 35 | transferAmount = transferAmount 36 | ) 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/bankDemo/transfers/subscribers/BankAccountsSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.subscribers 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import ru.quipy.application.component.Component 6 | import ru.quipy.bankDemo.accounts.api.AccountAggregate 7 | import ru.quipy.bankDemo.accounts.api.TransferTransactionAcceptedEvent 8 | import ru.quipy.bankDemo.accounts.api.TransferTransactionDeclinedEvent 9 | import ru.quipy.bankDemo.accounts.api.TransferTransactionProcessedEvent 10 | import ru.quipy.bankDemo.accounts.api.TransferTransactionRollbackedEvent 11 | import ru.quipy.bankDemo.transfers.api.TransferTransactionAggregate 12 | import ru.quipy.bankDemo.transfers.logic.TransferTransaction 13 | import ru.quipy.core.EventSourcingService 14 | import ru.quipy.streams.AggregateSubscriptionsManager 15 | import java.util.UUID 16 | 17 | class BankAccountsSubscriber( 18 | private val subscriptionsManager: AggregateSubscriptionsManager, 19 | private val transactionEsService: EventSourcingService 20 | ) : Component { 21 | private val logger: Logger = LoggerFactory.getLogger(BankAccountsSubscriber::class.java) 22 | override fun postConstruct() { 23 | subscriptionsManager.createSubscriber(AccountAggregate::class, "transactions::bank-accounts-subscriber") { 24 | `when`(TransferTransactionAcceptedEvent::class) { event -> 25 | transactionEsService.update(event.transactionId) { 26 | it.processParticipantAccept(event.bankAccountId) 27 | } 28 | } 29 | `when`(TransferTransactionDeclinedEvent::class) { event -> 30 | transactionEsService.update(event.transactionId) { 31 | it.processParticipantDecline(event.bankAccountId) 32 | } 33 | } 34 | `when`(TransferTransactionProcessedEvent::class) { event -> 35 | transactionEsService.update(event.transactionId) { 36 | it.participantCommitted(event.bankAccountId) 37 | } 38 | } 39 | `when`(TransferTransactionRollbackedEvent::class) { event -> 40 | transactionEsService.update(event.transactionId) { 41 | it.participantRollbacked(event.bankAccountId) 42 | } 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/projectDemo/ProjectContext.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo 2 | 3 | import ru.quipy.TinyEsLibConfig 4 | import ru.quipy.application.component.Component 5 | import ru.quipy.projectDemo.config.ProjectDemoConfig 6 | import ru.quipy.projectDemo.projections.AnnotationBasedProjectEventsSubscriber 7 | import ru.quipy.projectDemo.projections.ProjectEventsSubscriber 8 | 9 | class ProjectContext private constructor() { 10 | lateinit var annotationBasedProjectEventsSubscriber: AnnotationBasedProjectEventsSubscriber 11 | lateinit var components: List 12 | lateinit var projectEventsSubscriber: ProjectEventsSubscriber 13 | lateinit var projectDemoConfig: ProjectDemoConfig 14 | 15 | constructor(tinyEsLibConfig: TinyEsLibConfig) : this() { 16 | annotationBasedProjectEventsSubscriber = AnnotationBasedProjectEventsSubscriber() 17 | projectDemoConfig = projectDemoConfig(tinyEsLibConfig) 18 | projectEventsSubscriber = ProjectEventsSubscriber(tinyEsLibConfig.subscriptionsManager) 19 | 20 | components = mutableListOf(projectDemoConfig, projectEventsSubscriber, annotationBasedProjectEventsSubscriber) 21 | components.forEach { it.postConstruct() } 22 | } 23 | 24 | private fun projectDemoConfig(config: TinyEsLibConfig) : ProjectDemoConfig { 25 | return ProjectDemoConfig( 26 | config.subscriptionsManager, 27 | annotationBasedProjectEventsSubscriber, 28 | config.eventSourcingServiceFactory, 29 | config.eventStreamManager, 30 | config.aggregateRegistry 31 | ) 32 | } 33 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/projectDemo/api/ProjectAggregate.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo.api 2 | 3 | import ru.quipy.core.annotations.AggregateType 4 | import ru.quipy.domain.Aggregate 5 | 6 | // API 7 | @AggregateType(aggregateEventsTableName = "aggregate-project") 8 | class ProjectAggregate : Aggregate -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/projectDemo/api/ProjectAggregateDomainEvents.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo.api 2 | 3 | import ru.quipy.core.annotations.DomainEvent 4 | import ru.quipy.domain.Event 5 | import java.util.UUID 6 | 7 | const val PROJECT_CREATED_EVENT = "PROJECT_CREATED_EVENT" 8 | const val TAG_CREATED_EVENT = "TAG_CREATED_EVENT" 9 | const val TAG_ASSIGNED_TO_TASK_EVENT = "TAG_ASSIGNED_TO_TASK_EVENT" 10 | const val TASK_CREATED_EVENT = "TASK_CREATED_EVENT" 11 | 12 | // API 13 | @DomainEvent(name = PROJECT_CREATED_EVENT) 14 | class ProjectCreatedEvent( 15 | val projectId: String, 16 | createdAt: Long = System.currentTimeMillis(), 17 | ) : Event( 18 | name = PROJECT_CREATED_EVENT, 19 | createdAt = createdAt, 20 | ) 21 | 22 | @DomainEvent(name = TAG_CREATED_EVENT) 23 | class TagCreatedEvent( 24 | val projectId: String, 25 | val tagId: UUID, 26 | val tagName: String, 27 | createdAt: Long = System.currentTimeMillis(), 28 | ) : Event( 29 | name = TAG_CREATED_EVENT, 30 | createdAt = createdAt, 31 | ) 32 | 33 | @DomainEvent(name = TASK_CREATED_EVENT) 34 | class TaskCreatedEvent( 35 | val projectId: String, 36 | val taskId: UUID, 37 | val taskName: String, 38 | createdAt: Long = System.currentTimeMillis(), 39 | ) : Event( 40 | name = TASK_CREATED_EVENT, 41 | createdAt = createdAt 42 | ) 43 | 44 | @DomainEvent(name = TAG_ASSIGNED_TO_TASK_EVENT) 45 | class TagAssignedToTaskEvent( 46 | val projectId: String, 47 | val taskId: UUID, 48 | val tagId: UUID, 49 | createdAt: Long = System.currentTimeMillis(), 50 | ) : SomeBaseEventWithoutAnnotation( 51 | name = TAG_ASSIGNED_TO_TASK_EVENT, 52 | createdAt = createdAt 53 | ) 54 | 55 | // for testing and demo purposes 56 | open class SomeBaseEventWithoutAnnotation( 57 | name: String, 58 | createdAt: Long = System.currentTimeMillis(), 59 | ) : Event(name = name, createdAt = createdAt) 60 | -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/projectDemo/config/ProjectDemoConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo.config 2 | 3 | import org.slf4j.LoggerFactory 4 | import ru.quipy.application.component.Component 5 | import ru.quipy.core.AggregateRegistry 6 | import ru.quipy.core.EventSourcingServiceFactory 7 | import ru.quipy.projectDemo.api.ProjectAggregate 8 | import ru.quipy.projectDemo.logic.ProjectAggregateState 9 | import ru.quipy.projectDemo.projections.AnnotationBasedProjectEventsSubscriber 10 | import ru.quipy.streams.AggregateEventStreamManager 11 | import ru.quipy.streams.AggregateSubscriptionsManager 12 | 13 | class ProjectDemoConfig( 14 | var subscriptionsManager: AggregateSubscriptionsManager, 15 | var projectEventSubscriber: AnnotationBasedProjectEventsSubscriber, 16 | var eventSourcingServiceFactory: EventSourcingServiceFactory, 17 | var eventStreamManager: AggregateEventStreamManager, 18 | var aggregateRegistry: AggregateRegistry 19 | ) : Component { 20 | 21 | private val logger = LoggerFactory.getLogger(ProjectDemoConfig::class.java) 22 | 23 | override fun postConstruct() { 24 | subscriptionsManager.subscribe(projectEventSubscriber) 25 | 26 | eventStreamManager.maintenance { 27 | onRecordHandledSuccessfully { streamName, eventName -> 28 | logger.info("Stream $streamName successfully processed record of $eventName") 29 | } 30 | 31 | onBatchRead { streamName, batchSize -> 32 | logger.info("Stream $streamName read batch size: $batchSize") 33 | } 34 | } 35 | } 36 | 37 | fun demoESService() = eventSourcingServiceFactory.create() 38 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/projectDemo/logic/ProjectAggregateCommands.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo 2 | 3 | import ru.quipy.projectDemo.api.ProjectCreatedEvent 4 | import ru.quipy.projectDemo.api.TagAssignedToTaskEvent 5 | import ru.quipy.projectDemo.api.TagCreatedEvent 6 | import ru.quipy.projectDemo.api.TaskCreatedEvent 7 | import ru.quipy.projectDemo.logic.ProjectAggregateState 8 | import java.util.UUID 9 | 10 | 11 | // Commands : takes something -> returns event 12 | fun ProjectAggregateState.create(id: String): ProjectCreatedEvent { 13 | return ProjectCreatedEvent(projectId = id) 14 | } 15 | 16 | fun ProjectAggregateState.addTask(name: String): TaskCreatedEvent { 17 | return TaskCreatedEvent(projectId = this.getId(), taskId = UUID.randomUUID(), taskName = name) 18 | } 19 | 20 | fun ProjectAggregateState.createTag(name: String): TagCreatedEvent { 21 | if (projectTags.values.any { it.name == name }) { 22 | throw IllegalArgumentException("Tag already exists: $name") 23 | } 24 | return TagCreatedEvent(projectId = this.getId(), tagId = UUID.randomUUID(), tagName = name) 25 | } 26 | 27 | fun ProjectAggregateState.assignTagToTask(tagId: UUID, taskId: UUID): TagAssignedToTaskEvent { 28 | if (!projectTags.containsKey(tagId)) { 29 | throw IllegalArgumentException("Tag doesn't exists: $tagId") 30 | } 31 | 32 | if (!tasks.containsKey(taskId)) { 33 | throw IllegalArgumentException("Task doesn't exists: $taskId") 34 | } 35 | 36 | return TagAssignedToTaskEvent(projectId = this.getId(), tagId = tagId, taskId = taskId) 37 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/projectDemo/logic/ProjectAggregateState.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo.logic 2 | 3 | import ru.quipy.core.annotations.StateTransitionFunc 4 | import ru.quipy.domain.AggregateState 5 | import ru.quipy.projectDemo.api.ProjectAggregate 6 | import ru.quipy.projectDemo.api.ProjectCreatedEvent 7 | import ru.quipy.projectDemo.api.TagAssignedToTaskEvent 8 | import ru.quipy.projectDemo.api.TagCreatedEvent 9 | import ru.quipy.projectDemo.api.TaskCreatedEvent 10 | import java.util.UUID 11 | 12 | // Service's business logic 13 | class ProjectAggregateState: AggregateState { 14 | lateinit var projectId: String 15 | var createdAt: Long = System.currentTimeMillis() 16 | var updatedAt: Long = System.currentTimeMillis() 17 | 18 | var tasks = mutableMapOf() 19 | var projectTags = mutableMapOf() 20 | 21 | override fun getId() = projectId 22 | 23 | // State transition functions 24 | @StateTransitionFunc 25 | fun projectCreatedApply(event: ProjectCreatedEvent) { 26 | projectId = event.projectId 27 | updatedAt = createdAt 28 | } 29 | 30 | @StateTransitionFunc 31 | fun tagCreatedApply(event: TagCreatedEvent) { 32 | projectTags[event.tagId] = ProjectTag(event.tagId, event.tagName) 33 | updatedAt = createdAt 34 | } 35 | 36 | @StateTransitionFunc 37 | fun taskCreatedApply(event: TaskCreatedEvent) { 38 | tasks[event.taskId] = TaskEntity(event.taskId, event.taskName, mutableSetOf()) 39 | updatedAt = createdAt 40 | } 41 | } 42 | 43 | data class TaskEntity( 44 | val id: UUID = UUID.randomUUID(), 45 | val name: String, 46 | val tagsAssigned: MutableSet 47 | ) 48 | 49 | data class ProjectTag( 50 | val id: UUID = UUID.randomUUID(), 51 | val name: String 52 | ) 53 | 54 | @StateTransitionFunc 55 | fun ProjectAggregateState.tagAssignedApply(event: TagAssignedToTaskEvent) { 56 | tasks[event.taskId]?.tagsAssigned?.add(event.tagId) 57 | ?: throw IllegalArgumentException("No such task: ${event.taskId}") // todo sukhoa exception or not? 58 | updatedAt = createdAt 59 | } 60 | -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/projectDemo/projections/AnnotationBasedProjectEventsSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo.projections 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import ru.quipy.application.component.BaseComponent 6 | import ru.quipy.projectDemo.api.ProjectAggregate 7 | import ru.quipy.projectDemo.api.TagCreatedEvent 8 | import ru.quipy.projectDemo.api.TaskCreatedEvent 9 | import ru.quipy.streams.annotation.AggregateSubscriber 10 | import ru.quipy.streams.annotation.SubscribeEvent 11 | 12 | @AggregateSubscriber( 13 | aggregateClass = ProjectAggregate::class, subscriberName = "demo-subs-stream" 14 | ) 15 | class AnnotationBasedProjectEventsSubscriber : BaseComponent() { 16 | 17 | val logger: Logger = LoggerFactory.getLogger(AnnotationBasedProjectEventsSubscriber::class.java) 18 | 19 | @SubscribeEvent 20 | fun taskCreatedSubscriber(event: TaskCreatedEvent) { 21 | logger.info("Task created: {}", event.taskName) 22 | } 23 | 24 | @SubscribeEvent 25 | fun tagCreatedSubscriber(event: TagCreatedEvent) { 26 | logger.info("Tag created: {}", event.tagName) 27 | } 28 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/kotlin/ru/quipy/projectDemo/projections/ProjectEventsSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo.projections 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import ru.quipy.application.component.Component 6 | import ru.quipy.projectDemo.api.ProjectAggregate 7 | import ru.quipy.projectDemo.api.TagAssignedToTaskEvent 8 | import ru.quipy.projectDemo.api.TagCreatedEvent 9 | import ru.quipy.projectDemo.api.TaskCreatedEvent 10 | import ru.quipy.streams.AggregateSubscriptionsManager 11 | 12 | class ProjectEventsSubscriber(var subscriptionsManager: AggregateSubscriptionsManager) : Component { 13 | 14 | val logger: Logger = LoggerFactory.getLogger(ProjectEventsSubscriber::class.java) 15 | 16 | override fun postConstruct() { 17 | subscriptionsManager.createSubscriber(ProjectAggregate::class, "some-meaningful-name") { 18 | 19 | `when`(TaskCreatedEvent::class) { event -> 20 | logger.info("Task created: {}", event.taskName) 21 | } 22 | 23 | `when`(TagCreatedEvent::class) { event -> 24 | logger.info("Tag created: {}", event.tagName) 25 | } 26 | 27 | `when`(TagAssignedToTaskEvent::class) { event -> 28 | logger.info("Tag {} assigned to task {}: ", event.tagId, event.taskId) 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | event.sourcing.db-schema=event_sourcing_store 2 | 3 | # datasource configuration 4 | datasource.jdbc-url=jdbc:postgresql://localhost:5432/tiny_es 5 | datasource.username=tiny_es 6 | datasource.password=tiny_es 7 | 8 | 9 | mongodb.url=mongodb://localhost:27017 -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | System.out 4 | 5 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/test/kotlin/StreamEventOrderingTest.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy 2 | 3 | import org.awaitility.kotlin.await 4 | import org.junit.jupiter.api.BeforeAll 5 | import org.junit.jupiter.api.BeforeEach 6 | import org.junit.jupiter.api.Test 7 | import ru.quipy.core.EventSourcingProperties 8 | import ru.quipy.projectDemo.api.ProjectAggregate 9 | import ru.quipy.projectDemo.api.TagCreatedEvent 10 | import ru.quipy.projectDemo.create 11 | import ru.quipy.projectDemo.createTag 12 | import java.util.concurrent.TimeUnit 13 | 14 | class StreamEventOrderingTest: BaseTest(testId) { 15 | companion object { 16 | const val testId = "StreamEventOrderingTest" 17 | @BeforeAll 18 | @JvmStatic 19 | fun configure() { 20 | configure(eventSourcingProperties = EventSourcingProperties(streamBatchSize = 3)) 21 | } 22 | } 23 | 24 | private val sb = StringBuilder() 25 | 26 | @BeforeEach 27 | fun init() { 28 | cleanDatabase() 29 | } 30 | 31 | @Test 32 | fun testEventOrder() { 33 | demoESService.create { 34 | it.create(testId) 35 | } 36 | 37 | demoESService.update(testId) { 38 | it.createTag("1") 39 | } 40 | demoESService.update(testId) { 41 | it.createTag("2") 42 | } 43 | demoESService.update(testId) { 44 | it.createTag("3") 45 | } 46 | demoESService.update(testId) { 47 | it.createTag("4") 48 | } 49 | demoESService.update(testId) { 50 | it.createTag("5") 51 | } 52 | demoESService.update(testId) { 53 | it.createTag("6") 54 | } 55 | 56 | subscriptionsManager.createSubscriber(ProjectAggregate::class, "StreamEventOrderingTest") { 57 | `when`(TagCreatedEvent::class) { event -> 58 | sb.append(event.tagName).also { 59 | println(sb.toString()) 60 | } 61 | } 62 | } 63 | 64 | await.atMost(10, TimeUnit.MINUTES).until { 65 | sb.toString() == "123456" 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-app/src/test/kotlin/config/DockerPostgresDataSourceInitialize.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.config 2 | 3 | import org.testcontainers.containers.PostgreSQLContainer 4 | import org.testcontainers.utility.DockerImageName 5 | import java.util.Properties 6 | 7 | class DockerPostgresDataSourceInitializer { 8 | private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:14.9-alpine")).apply { 9 | withDatabaseName("tiny_es") 10 | withUsername("tiny_es") 11 | withPassword("tiny_es") 12 | } 13 | 14 | fun initialize(properties: Properties) { 15 | postgresContainer.start() 16 | 17 | properties.setProperty("datasource.jdbc-url", postgresContainer.jdbcUrl) 18 | properties.setProperty("datasource.username", postgresContainer.username) 19 | properties.setProperty("datasource.password", postgresContainer.password) 20 | properties.setProperty("event.sourcing.db-schema", "event_sourcing_store") 21 | } 22 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/core/BasicAggregateRegistry.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.core 2 | 3 | import org.springframework.core.annotation.AnnotationUtils 4 | import ru.quipy.core.AggregateRegistry.* 5 | import ru.quipy.core.annotations.AggregateType 6 | import ru.quipy.domain.Aggregate 7 | import ru.quipy.domain.AggregateState 8 | import java.util.concurrent.ConcurrentHashMap 9 | import kotlin.reflect.KClass 10 | 11 | @Suppress("UNCHECKED_CAST") 12 | class BasicAggregateRegistry : AggregateRegistry { 13 | private val aggregatesInfo = ConcurrentHashMap, AggregateInfo>() 14 | 15 | override fun register( 16 | aggregateClass: KClass, 17 | eventRegistrationBlock: EventRegistrar.() -> Unit 18 | ){ 19 | val aggregateInfo = AnnotationUtils.findAnnotation(aggregateClass.java, AggregateType::class.java) 20 | ?: throw IllegalStateException("No annotation ${aggregateClass.simpleName} provided on aggregate") 21 | 22 | aggregatesInfo.computeIfAbsent(aggregateClass) { 23 | AggregateInfo().also { 24 | it.eventInfo = EventInfoImpl(aggregateClass, aggregateInfo.aggregateEventsTableName) 25 | } 26 | }.also { 27 | eventRegistrationBlock(it.eventInfo as EventRegistrar) 28 | } 29 | } 30 | 31 | override fun > register( 32 | aggregateClass: KClass, 33 | aggregateStateClass: KClass, 34 | eventRegistrationBlock: StateTransitionsRegistrar.() -> Unit 35 | ) { 36 | val aggregateInfo = AnnotationUtils.findAnnotation(aggregateClass.java, AggregateType::class.java) 37 | ?: throw IllegalStateException("No annotation ${aggregateClass.simpleName} provided on aggregate") 38 | 39 | 40 | aggregatesInfo.computeIfAbsent(aggregateClass) { 41 | AggregateInfo().also { 42 | it.stateInfo = AggregateStateInfoImpl(aggregateClass, aggregateStateClass, aggregateInfo.aggregateEventsTableName) 43 | } 44 | }.also { 45 | eventRegistrationBlock(it.stateInfo as StateTransitionsRegistrar) 46 | } 47 | } 48 | 49 | override fun basicAggregateInfo(clazz: KClass): BasicAggregateInfo? { 50 | return aggregatesInfo[clazz]?.let { (it.eventInfo ?: it.stateInfo) as BasicAggregateInfo } 51 | } 52 | 53 | 54 | override fun getEventInfo(clazz: KClass): EventInfo? { 55 | return aggregatesInfo[clazz]?.let { (it.eventInfo ?: it.stateInfo) as EventInfo } 56 | } 57 | 58 | override fun > getStateTransitionInfo(clazz: KClass): AggregateStateInfo? { 59 | return aggregatesInfo[clazz]?.let { it.stateInfo as AggregateStateInfo } 60 | } 61 | 62 | override fun getAllAggregates(): List> { 63 | return aggregatesInfo.toMap().keys.map { it as KClass } 64 | } 65 | 66 | class AggregateInfo { 67 | var eventInfo: EventInfo<*>? = null 68 | var stateInfo: AggregateStateInfo<*, *, *>? = null 69 | } 70 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/core/EventSourcingProperties.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.core 2 | 3 | import kotlin.time.Duration 4 | import kotlin.time.Duration.Companion.seconds 5 | 6 | class EventSourcingProperties ( 7 | var snapshotFrequency: Int = 10, 8 | var snapshotsEnabled: Boolean = false, 9 | var snapshotTableName: String = "snapshots", // todo sukhoa should be per aggregate 10 | var streamReadPeriod: Long = 1_000, 11 | var streamBatchSize: Int = 200, 12 | var autoScanEnabled: Boolean = false, 13 | var scanPackage: String? = null, 14 | var spinLockMaxAttempts: Int = 25, 15 | var maxActiveReaderInactivityPeriod: Duration = 5.seconds, 16 | var readerCommitPeriodMessages: Int = 100, 17 | var readerCommitPeriodMillis: Long = 1000, 18 | val eventReaderHealthCheckPeriod: Duration = 3.seconds, 19 | var sagasEnabled: Boolean = true, 20 | var batchEnabled: Boolean = false, 21 | var batchSize: Int = 1, 22 | var batchPeriodMillis: Long = 50, 23 | var batchMode: String = BatchMode.STORED_PROCEDURE.name, 24 | var dbSchema: String = "event_sourcing_store" 25 | ) 26 | 27 | enum class BatchMode { 28 | JDBC_BATCH, STORED_PROCEDURE 29 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/core/EventSourcingServiceFactory.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.core 2 | 3 | import ru.quipy.database.EventStore 4 | import ru.quipy.domain.* 5 | import ru.quipy.mapper.EventMapper 6 | 7 | 8 | class EventSourcingServiceFactory( 9 | val aggregateRegistry: AggregateRegistry, 10 | val eventMapper: EventMapper, 11 | val eventStore: EventStore, 12 | val eventSourcingProperties: EventSourcingProperties 13 | ) { 14 | 15 | inline fun > create(): EventSourcingService { 16 | return EventSourcingService( 17 | A::class, aggregateRegistry, eventMapper, eventSourcingProperties, eventStore 18 | ) 19 | } 20 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/core/annotations/AggregateType.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.core.annotations 2 | 3 | @Target(AnnotationTarget.CLASS) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class AggregateType( 6 | val aggregateEventsTableName: String, 7 | ) 8 | -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/core/annotations/DomainEvent.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.core.annotations 2 | 3 | @Target(AnnotationTarget.CLASS) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class DomainEvent( 6 | val name: String, 7 | ) 8 | -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/core/annotations/StateTransitionFunc.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.core.annotations 2 | 3 | @Target(AnnotationTarget.FUNCTION) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class StateTransitionFunc 6 | -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/core/exceptions/DuplicateEventIdException.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.core.exceptions 2 | 3 | class DuplicateEventIdException( 4 | override val message: String, 5 | override val cause: Throwable? 6 | ) : RuntimeException( 7 | message, cause 8 | ) -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/core/exceptions/EventRecordOptimisticLockException.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.core.exceptions 2 | 3 | import ru.quipy.domain.EventRecord 4 | 5 | internal class EventRecordOptimisticLockException( 6 | override val message: String, 7 | override val cause: Throwable?, 8 | val eventRecords: List 9 | ) : RuntimeException( 10 | message, cause 11 | ) -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/database/EventStore.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.database 2 | 3 | import ru.quipy.core.exceptions.DuplicateEventIdException 4 | import ru.quipy.domain.* 5 | 6 | /** 7 | * Abstracts away the DB access. Provides the operations for event sourcing functioning. 8 | * You can provide your own implementation of [EventStore] and run event sourcing app 9 | * working on any DB you wish under the hood. 10 | */ 11 | interface EventStore { 12 | 13 | /** 14 | * Appends event record in aggregate event log. 15 | * 16 | * Throws [DuplicateEventIdException] if there is already event with same. 17 | * 18 | * This is used to handle concurrency If one of the insertion operation running in parallel managed to insert 19 | * event, then others should retry their attempt including create aggregate state again, performing validations 20 | * and insertion the resulting event. Some kind of optimistic concurrency control. 21 | * 22 | * We suggest using same (aggregateId, version) combination as a sign of the event is duplicate (version is a 23 | * monotonically increasing sequence always incremented by one). In this case there will be no two events with same 24 | * version within single aggregate instance. That allows you to tackle concurrency issues withing same aggregate instance. 25 | * So that execution will be linearized for every single aggregate instance. 26 | * 27 | */ 28 | @Throws(exceptionClasses = [DuplicateEventIdException::class]) 29 | fun insertEventRecord(aggregateTableName: String, eventRecord: EventRecord) 30 | 31 | @Throws(exceptionClasses = [DuplicateEventIdException::class]) 32 | fun insertEventRecords(aggregateTableName: String, eventRecords: List) 33 | 34 | /** 35 | * Aggregate state version is the number of events that should be applied to empty aggregate state to get current state. 36 | * 37 | * Each event stores the number of the aggregate state version. This is the version of the state after the event is applied. 38 | */ 39 | fun findEventRecordsWithAggregateVersionGraterThan( 40 | aggregateTableName: String, aggregateId: Any, aggregateVersion: Long 41 | ): List 42 | 43 | /** 44 | * Returns a batch of events that has their sequence number greater than passed 45 | */ 46 | fun findBatchOfEventRecordAfter( 47 | aggregateTableName: String, eventSequenceNum: Long, batchSize: Int 48 | ): List 49 | 50 | fun tableExists(aggregateTableName: String): Boolean 51 | 52 | fun updateSnapshotWithLatestVersion(tableName: String, snapshot: Snapshot) 53 | 54 | fun findSnapshotByAggregateId(snapshotsTableName: String, aggregateId: Any): Snapshot? 55 | 56 | fun findStreamReadIndex(streamName: String): EventStreamReadIndex? 57 | 58 | fun getActiveStreamReader(streamName: String): ActiveEventStreamReader? 59 | 60 | fun tryUpdateActiveStreamReader(updatedActiveReader: ActiveEventStreamReader): Boolean 61 | 62 | fun tryReplaceActiveStreamReader(expectedVersion: Long, newActiveReader: ActiveEventStreamReader): Boolean 63 | 64 | fun commitStreamReadIndex(readIndex: EventStreamReadIndex): Boolean 65 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/domain/EventSourcingDomain.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.domain 2 | 3 | import ru.quipy.saga.SagaContext 4 | import java.util.* 5 | 6 | interface Versioned { 7 | var version: Long 8 | } 9 | 10 | interface Unique { // todo sukhoa rename this stuff 11 | val id: ID 12 | } 13 | 14 | interface Aggregate 15 | 16 | interface AggregateState { // todo sukhoa add version of the state 17 | /** 18 | * Returns the ID of the aggregate or null if state is empty 19 | */ 20 | fun getId(): ID? 21 | } 22 | 23 | fun interface AggregateStateTransitionFunction, S : AggregateState<*, A>> { 24 | fun performTransition(state: S, event: E) 25 | } 26 | 27 | abstract class Event( 28 | override val id: UUID = UUID.randomUUID(), 29 | val name: String, 30 | var sagaContext: SagaContext? = null, 31 | override var version: Long = 0L, // this is aggregate version actually or the event count number 32 | var createdAt: Long = System.currentTimeMillis(), 33 | ) : Versioned, Unique 34 | 35 | @Suppress("unused") 36 | data class EventRecord( 37 | override val id: String, 38 | val aggregateId: Any, // todo sukhoa weird? 39 | val aggregateVersion: Long, 40 | val eventTitle: String, 41 | val payload: String, 42 | var sagaContext: SagaContext? = null, 43 | val createdAt: Long = System.currentTimeMillis() 44 | ) : Unique 45 | 46 | @Suppress("UNCHECKED_CAST") 47 | class Snapshot( 48 | override val id : Any, // todo sukhoa weird ANY, should it be parametrized? 49 | val snapshot : Any, // todo sukhoa weird ANY, should it be parametrized? 50 | override var version: Long 51 | ) : Versioned, Unique 52 | 53 | 54 | data class EventStreamReadIndex( 55 | override val id: String, // name of the stream 56 | val readIndex: Long, 57 | override var version: Long 58 | ) : Unique, Versioned { 59 | override fun toString(): String { 60 | return "EventStreamReadIndex(id='$id', readIndex=$readIndex, version=$version)" 61 | } 62 | } 63 | 64 | class ActiveEventStreamReader( 65 | override val id: String, // Represents the stream name. 66 | override var version: Long, 67 | val readerId: String, 68 | val readPosition: Long, 69 | val lastInteraction: Long 70 | ) : Unique, Versioned -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/mapper/EventMapper.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.mapper 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import ru.quipy.domain.Aggregate 5 | import ru.quipy.domain.Event 6 | import kotlin.reflect.KClass 7 | 8 | /** 9 | * Allows to map some row representation (json String by default) of event to instance of [Event] class and vise versa 10 | */ 11 | interface EventMapper { 12 | 13 | fun toEvent(payload: String, eventClass: KClass>): Event 14 | 15 | fun eventToString(event: Event): String 16 | } 17 | 18 | class JsonEventMapper( 19 | val jsonObjectMapper: ObjectMapper 20 | ) : EventMapper { 21 | 22 | override fun toEvent(payload: String, eventClass: KClass>): Event { 23 | return jsonObjectMapper.readValue( 24 | payload, 25 | eventClass.java 26 | ) as Event 27 | } 28 | 29 | override fun eventToString(event: Event): String { 30 | return jsonObjectMapper.writeValueAsString(event) 31 | } 32 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/saga/SagaManager.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.saga 2 | 3 | import ru.quipy.core.EventSourcingService 4 | import ru.quipy.saga.aggregate.api.SagaStepAggregate 5 | import ru.quipy.saga.aggregate.logic.SagaStepAggregateState 6 | import java.util.* 7 | 8 | class SagaManager( 9 | private val sagaStepEsService: EventSourcingService 10 | ) { 11 | fun withContextGiven(sagaContext: SagaContext?) = SagaInvoker(sagaContext) 12 | 13 | fun launchSaga(sagaName: String, stepName: String) = 14 | SagaInvoker(SagaContext()).launchSaga(sagaName, stepName) 15 | 16 | private fun launchSaga( 17 | sagaName: String, 18 | stepName: String, 19 | sagaStepId: UUID?, 20 | sagaContext: SagaContext 21 | ): SagaContext { 22 | if (sagaContext.ctx.containsKey(sagaName)) 23 | throw IllegalArgumentException("The name of the saga $sagaName is already in the context") 24 | 25 | val sagaStep = SagaStep(sagaName, stepName, sagaStepId ?: UUID.randomUUID()) 26 | 27 | sagaStepEsService.create { it.launchSagaStep(sagaStep) } 28 | 29 | return processContext(sagaContext, sagaName, sagaStep) 30 | } 31 | 32 | private fun performSagaStep( 33 | sagaName: String, 34 | stepName: String, 35 | sagaStepId: UUID?, 36 | sagaContext: SagaContext, 37 | ): SagaContext { 38 | if (!sagaContext.ctx.containsKey(sagaName)) 39 | throw IllegalArgumentException("The name of the saga $sagaName does not match the context") 40 | 41 | val sagaInfo = sagaContext.ctx[sagaName] 42 | 43 | val sagaStep = SagaStep( 44 | sagaName, 45 | stepName, 46 | sagaStepId ?: UUID.randomUUID(), 47 | sagaInstanceId = sagaInfo!!.sagaInstanceId, 48 | prevSteps = sagaInfo.stepIdPrevStepsIdsAssociation.keys 49 | ) 50 | 51 | sagaStepEsService.update(sagaStep.sagaInstanceId) { it.initiateSagaStep(sagaStep) } 52 | 53 | return processContext(sagaContext, sagaName, sagaStep) 54 | } 55 | 56 | private fun processContext(sagaContext: SagaContext, sagaName: String, sagaStep: SagaStep): SagaContext { 57 | val processedContext = SagaContext(sagaContext.ctx.toMutableMap().also { 58 | it[sagaName] = SagaInfo( 59 | sagaStep.sagaInstanceId, 60 | sagaStep.stepName, 61 | sagaStep.sagaStepId, 62 | sagaStep.prevSteps, 63 | mapOf(sagaStep.sagaStepId to sagaStep.prevSteps) 64 | ) 65 | }) 66 | processedContext.correlationId = sagaContext.correlationId 67 | processedContext.causationId = sagaContext.causationId 68 | processedContext.currentEventId = sagaContext.currentEventId 69 | 70 | return processedContext 71 | } 72 | 73 | inner class SagaInvoker( 74 | private val sagaContext: SagaContext?, 75 | private var currentSagaStepId: UUID? = null 76 | ) { 77 | fun launchSaga(sagaName: String, stepName: String): SagaInvoker { 78 | val updatedContext = launchSaga(sagaName, stepName, currentSagaStepId, sagaContext ?: SagaContext()) 79 | currentSagaStepId = updatedContext.ctx[sagaName]!!.sagaStepId 80 | return SagaInvoker(updatedContext, currentSagaStepId) 81 | } 82 | 83 | fun performSagaStep(sagaName: String, stepName: String): SagaInvoker { 84 | if (sagaContext == null) 85 | throw IllegalArgumentException("The saga context is not initialized") 86 | 87 | val updatedContext = performSagaStep(sagaName, stepName, currentSagaStepId, sagaContext) 88 | currentSagaStepId = updatedContext.ctx[sagaName]!!.sagaStepId 89 | return SagaInvoker(updatedContext, currentSagaStepId) 90 | } 91 | 92 | fun sagaContext() = sagaContext ?: SagaContext() 93 | } 94 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/saga/aggregate/api/SagaStepAggregate.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.saga.aggregate.api 2 | 3 | import ru.quipy.core.annotations.AggregateType 4 | import ru.quipy.domain.Aggregate 5 | 6 | @AggregateType("sagas") 7 | class SagaStepAggregate : Aggregate -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/saga/aggregate/api/SagaStepEvents.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.saga.aggregate.api 2 | 3 | import ru.quipy.core.annotations.DomainEvent 4 | import ru.quipy.domain.Event 5 | import java.util.UUID 6 | 7 | const val SAGA_STEP_LAUNCHED = "SAGA_STEP_LAUNCHED_EVENT" 8 | const val SAGA_STEP_INITIATED = "SAGA_STEP_INITIATED_EVENT" 9 | const val SAGA_STEP_PROCESSED = "SAGA_STEP_PROCESSED_EVENT" 10 | const val DEFAULT_SAGA_PROCESSED = "DEFAULT_SAGA_PROCESSED_EVENT" 11 | 12 | @DomainEvent(SAGA_STEP_LAUNCHED) 13 | data class SagaStepLaunchedEvent( 14 | val sagaName: String, 15 | val stepName: String, 16 | val sagaStepId: UUID, 17 | val sagaInstanceId: UUID, 18 | val prevSteps: Set = setOf() 19 | ) : Event( 20 | name = SAGA_STEP_LAUNCHED, 21 | ) 22 | 23 | @DomainEvent(SAGA_STEP_INITIATED) 24 | data class SagaStepInitiatedEvent( 25 | val sagaName: String, 26 | val stepName: String, 27 | val sagaStepId: UUID, 28 | val sagaInstanceId: UUID, 29 | val prevSteps: Set = setOf() 30 | ) : Event( 31 | name = SAGA_STEP_INITIATED, 32 | ) 33 | 34 | @DomainEvent(SAGA_STEP_PROCESSED) 35 | data class SagaStepProcessedEvent( 36 | val sagaName: String, 37 | val stepName: String, 38 | val sagaStepId: UUID, 39 | val sagaInstanceId: UUID, 40 | val prevSteps: Set = setOf(), 41 | val eventName: String 42 | ) : Event( 43 | name = SAGA_STEP_PROCESSED, 44 | ) 45 | 46 | @DomainEvent(DEFAULT_SAGA_PROCESSED) 47 | data class DefaultSagaProcessedEvent( 48 | val correlationId: UUID, 49 | val currentEventId: UUID, 50 | val causationId: UUID?, 51 | val eventName: String 52 | ) : Event( 53 | name = DEFAULT_SAGA_PROCESSED, 54 | ) 55 | -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/saga/aggregate/logic/SagaStepAggregateState.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.saga.aggregate.logic 2 | 3 | import ru.quipy.core.annotations.StateTransitionFunc 4 | import ru.quipy.domain.AggregateState 5 | import ru.quipy.saga.SagaStep 6 | import ru.quipy.saga.aggregate.api.* 7 | import java.util.UUID 8 | 9 | class SagaStepAggregateState : AggregateState { 10 | private lateinit var sagaName: String 11 | private lateinit var sagaInstanceId: UUID 12 | private var sagaSteps = mutableListOf() 13 | private var processedSagaSteps = mutableListOf() 14 | override fun getId() = sagaInstanceId 15 | 16 | fun launchSagaStep(sagaStep: SagaStep): SagaStepLaunchedEvent { 17 | return SagaStepLaunchedEvent( 18 | sagaStep.sagaName, 19 | sagaStep.stepName, 20 | sagaStep.sagaStepId, 21 | sagaStep.sagaInstanceId, 22 | sagaStep.prevSteps 23 | ) 24 | } 25 | 26 | fun initiateSagaStep(sagaStep: SagaStep): SagaStepInitiatedEvent { 27 | if (sagaSteps.contains(sagaStep.sagaStepId)) { 28 | throw IllegalStateException("Duplicate step: $sagaStep") 29 | } 30 | 31 | return SagaStepInitiatedEvent( 32 | sagaStep.sagaName, 33 | sagaStep.stepName, 34 | sagaStep.sagaStepId, 35 | sagaStep.sagaInstanceId, 36 | sagaStep.prevSteps 37 | ) 38 | } 39 | 40 | fun processSagaStep(sagaStep: SagaStep, eventName: String): SagaStepProcessedEvent { 41 | if (processedSagaSteps.contains(sagaStep.sagaStepId)) { 42 | throw IllegalStateException("Duplicate step: $sagaStep") 43 | } 44 | 45 | return SagaStepProcessedEvent( 46 | sagaStep.sagaName, 47 | sagaStep.stepName, 48 | sagaStep.sagaStepId, 49 | sagaStep.sagaInstanceId, 50 | sagaStep.prevSteps, 51 | eventName 52 | ) 53 | } 54 | 55 | fun containsProcessedSagaStep(sagaStepId: UUID): Boolean { 56 | return processedSagaSteps.contains(sagaStepId) 57 | } 58 | 59 | fun processDefaultSaga( 60 | correlationId: UUID, 61 | currentEventId: UUID, 62 | eventName: String, 63 | causationId: UUID? = null 64 | ): DefaultSagaProcessedEvent { 65 | if (processedSagaSteps.contains(currentEventId)) { 66 | throw IllegalStateException("Duplicate step: $correlationId") 67 | } 68 | 69 | return DefaultSagaProcessedEvent( 70 | correlationId, 71 | currentEventId, 72 | causationId, 73 | eventName 74 | ) 75 | } 76 | 77 | @StateTransitionFunc 78 | fun launchSagaStep(sagaStepEvent: SagaStepLaunchedEvent) { 79 | sagaName = sagaStepEvent.sagaName 80 | sagaInstanceId = sagaStepEvent.sagaInstanceId 81 | sagaSteps.add(sagaStepEvent.sagaStepId) 82 | } 83 | 84 | @StateTransitionFunc 85 | fun initiateSagaStep(sagaStepEvent: SagaStepInitiatedEvent) { 86 | sagaSteps.add(sagaStepEvent.sagaStepId) 87 | } 88 | 89 | @StateTransitionFunc 90 | fun processSagaStep(sagaStepEvent: SagaStepProcessedEvent) { 91 | processedSagaSteps.add(sagaStepEvent.sagaStepId) 92 | } 93 | 94 | @StateTransitionFunc 95 | fun processDefaultSaga(sagaEvent: DefaultSagaProcessedEvent) { 96 | if (!this::sagaInstanceId.isInitialized) { 97 | sagaInstanceId = sagaEvent.correlationId 98 | } 99 | processedSagaSteps.add(sagaEvent.currentEventId) 100 | } 101 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/streams/AggregateEventStreamManager.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.streams 2 | 3 | import kotlinx.coroutines.asCoroutineDispatcher 4 | import ru.quipy.core.AggregateRegistry 5 | import ru.quipy.core.EventSourcingProperties 6 | import ru.quipy.database.EventStore 7 | import ru.quipy.domain.Aggregate 8 | import ru.quipy.streams.annotation.RetryConf 9 | import ru.quipy.streams.annotation.RetryFailedStrategy 10 | import ru.quipy.utils.NamedThreadFactory 11 | import java.util.concurrent.ConcurrentHashMap 12 | import java.util.concurrent.Executors 13 | import kotlin.reflect.KClass 14 | 15 | 16 | class AggregateEventStreamManager( 17 | private val aggregateRegistry: AggregateRegistry, 18 | private val eventStore: EventStore, 19 | private val eventSourcingProperties: EventSourcingProperties, 20 | private val streamManager: EventStreamReaderManager 21 | ) { 22 | private val eventStreamListener: EventStreamListenerImpl = EventStreamListenerImpl()// todo sukhoa make injectable 23 | 24 | private val eventStreams = ConcurrentHashMap>() 25 | 26 | fun createEventStream( 27 | streamName: String, 28 | aggregateClass: KClass, 29 | retryConfig: RetryConf = RetryConf(3, RetryFailedStrategy.SKIP_EVENT) 30 | ): AggregateEventStream { 31 | val aggregateInfo = (aggregateRegistry.basicAggregateInfo(aggregateClass) 32 | ?: throw IllegalArgumentException("Aggregate $aggregateClass is not registered")) 33 | 34 | val eventStoreReader = streamManager.createStreamReader( 35 | eventStore, 36 | streamName, 37 | aggregateInfo as AggregateRegistry.BasicAggregateInfo, 38 | eventSourcingProperties, 39 | eventStreamListener, 40 | Executors.newSingleThreadExecutor(NamedThreadFactory("$streamName-store-reader")).asCoroutineDispatcher() // eventStoreReaderDispatcher 41 | ) 42 | 43 | val eventsChannel = EventsChannel() 44 | 45 | val existing = eventStreams.putIfAbsent( 46 | streamName, BufferedAggregateEventStream( 47 | streamName, 48 | eventSourcingProperties.streamReadPeriod, 49 | eventSourcingProperties.streamBatchSize, 50 | eventsChannel, 51 | eventStoreReader, 52 | retryConfig, 53 | eventStreamListener, 54 | Executors.newSingleThreadExecutor(NamedThreadFactory("$streamName-stream-reader")).asCoroutineDispatcher() // eventStreamsDispatcher 55 | ) 56 | ) 57 | 58 | if (existing != null) throw IllegalStateException("There is already stream $streamName for aggregate ${aggregateClass.simpleName}") 59 | 60 | return eventStreams[streamName] as AggregateEventStream 61 | } 62 | 63 | fun destroy() { 64 | eventStreams.values.forEach { 65 | it.stopAndDestroy() 66 | } 67 | } 68 | 69 | fun maintenance(block: EventStreamListener.() -> Unit) { // todo sukhoa naming 70 | block(eventStreamListener) 71 | } 72 | 73 | fun streamsInfo() = eventStreams.map { (_, stream) -> 74 | StreamInfo(stream.streamName) 75 | }.toList() 76 | 77 | fun getStreamByName(name: String) = eventStreams[name] 78 | 79 | data class StreamInfo( 80 | val streamName: String 81 | ) 82 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/streams/EventsChannel.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.streams 2 | 3 | import kotlinx.coroutines.channels.BufferOverflow 4 | import kotlinx.coroutines.channels.Channel 5 | import ru.quipy.domain.EventRecord 6 | 7 | class EventsChannel { 8 | private val eventsChannel: Channel = Channel( 9 | capacity = Channel.RENDEZVOUS, 10 | onBufferOverflow = BufferOverflow.SUSPEND 11 | ) 12 | 13 | private val acknowledgesChannel: Channel = Channel( 14 | capacity = Channel.RENDEZVOUS, 15 | onBufferOverflow = BufferOverflow.SUSPEND 16 | ) 17 | 18 | suspend fun sendEvent(eventRecord: EventRecord) { 19 | eventsChannel.send(eventRecord) 20 | } 21 | 22 | suspend fun receiveEvent(): EventRecord { 23 | return eventsChannel.receive() 24 | } 25 | 26 | suspend fun sendConfirmation(isConfirmed: Boolean) { 27 | acknowledgesChannel.send(isConfirmed) 28 | } 29 | 30 | suspend fun receiveConfirmation(): Boolean { 31 | return acknowledgesChannel.receive() 32 | } 33 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/streams/annotation/AggregateSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.streams.annotation 2 | 3 | import ru.quipy.domain.Aggregate 4 | import kotlin.reflect.KClass 5 | 6 | @Target(AnnotationTarget.CLASS) 7 | @Retention(AnnotationRetention.RUNTIME) 8 | annotation class AggregateSubscriber( 9 | val aggregateClass: KClass, 10 | val subscriberName: String, 11 | val retry: RetryConf = RetryConf(3, RetryFailedStrategy.SKIP_EVENT) 12 | ) 13 | 14 | annotation class RetryConf( 15 | val maxAttempts: Int, 16 | val lastAttemptFailedStrategy: RetryFailedStrategy 17 | ) 18 | 19 | /** 20 | * In case event handling failure (when subscriber failed to process it correct - exception or just returning false for some reason) 21 | * we can choose between two strategies: 22 | * - suspend the whole stream and wait till the situation is soled manually somehow 23 | * - skip the event and take the next one sacrificing the projection data consistency but continuing pocessing events 24 | */ 25 | enum class RetryFailedStrategy { 26 | SUSPEND, 27 | SKIP_EVENT 28 | } 29 | -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/streams/annotation/SubscribeEvent.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.streams.annotation 2 | 3 | @Target(AnnotationTarget.FUNCTION) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class SubscribeEvent 6 | -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/utils/Batcher.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.utils 2 | 3 | import org.slf4j.LoggerFactory 4 | import ru.quipy.core.exceptions.DuplicateEventIdException 5 | import java.util.concurrent.CompletableFuture 6 | import java.util.concurrent.Executors 7 | import java.util.concurrent.locks.ReentrantLock 8 | import kotlin.concurrent.withLock 9 | import kotlin.time.Duration 10 | import kotlin.time.Duration.Companion.milliseconds 11 | 12 | class Batcher( 13 | private val batchCommandSize: Int, 14 | private val batchWindow: Duration, 15 | private val batchUpdater: (List) -> Array 16 | ) { 17 | private val lock = ReentrantLock() 18 | 19 | private val collectingBatch = mutableListOf() 20 | private val idSet = mutableSetOf() 21 | private var collectingStartedAt = System.currentTimeMillis() 22 | 23 | private val batchExecJob = PeriodicalJob( 24 | "event-store", 25 | "batch-execute", 26 | Executors.newSingleThreadExecutor(NamedThreadFactory("batcher-executor")), 27 | delayer = Delayer.InvocationRatePreservingDelay(2.milliseconds), 28 | ) { _, _ -> 29 | if (collectingBatch.size >= batchCommandSize || System.currentTimeMillis() - collectingStartedAt >= batchWindow.inWholeMilliseconds) { 30 | executeBatch() 31 | } 32 | } 33 | 34 | fun delayedExecution(id: String, statement: String): CompletableFuture { 35 | val command = Command(statement, CompletableFuture()) 36 | lock.withLock { 37 | if (!idSet.add(id)) { 38 | throw DuplicateEventIdException( 39 | "There is record with such an id in batch. Record cannot be saved $id", 40 | null 41 | ) 42 | } 43 | collectingBatch.add(command) 44 | } 45 | return command.completableFuture 46 | } 47 | 48 | private fun executeBatch() { 49 | var copyCommands: List 50 | lock.withLock { 51 | copyCommands = collectingBatch.toList() 52 | if (collectingBatch.isNotEmpty()) { 53 | collectingBatch.clear() 54 | collectingStartedAt = System.currentTimeMillis() 55 | idSet.clear() 56 | } 57 | 58 | if (copyCommands.isEmpty()) return 59 | 60 | try { 61 | val errored = batchUpdater(copyCommands.map { it.statement }.toList()).toSet() 62 | copyCommands.forEachIndexed { i, c -> 63 | c.completableFuture.complete(!errored.contains(i)) 64 | } 65 | } catch (e: Exception) { 66 | val batchStatement = copyCommands.joinToString(";") { it.statement } 67 | logger.error("Batch execution failed Statement: $batchStatement", e) 68 | copyCommands.forEach { it.completableFuture.completeExceptionally(e) } 69 | } 70 | } 71 | } 72 | 73 | class Command( 74 | val statement: String, 75 | val completableFuture: CompletableFuture 76 | ) 77 | 78 | companion object { 79 | private val logger = LoggerFactory.getLogger(Batcher::class.java) 80 | } 81 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-lib/src/main/kotlin/ru/quipy/utils/NamedThreadFactory.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.utils 2 | 3 | import java.util.concurrent.ThreadFactory 4 | import java.util.concurrent.atomic.AtomicInteger 5 | 6 | class NamedThreadFactory(private val prefix: String) : ThreadFactory { 7 | private val sequence = AtomicInteger(1) 8 | 9 | override fun newThread(r: Runnable): Thread { 10 | val thread = Thread(r) 11 | val seq = sequence.getAndIncrement() 12 | thread.name = prefix + (if (seq > 1) "-$seq" else "") 13 | thread.isDaemon = true 14 | return thread 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tiny-event-sourcing-sagas-projections/src/main/kotlin/ru/quipy/SagasProjectionsApplication.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | @SpringBootApplication 6 | class SagasProjectionsApplication 7 | 8 | fun main(args: Array) { 9 | runApplication(*args) 10 | } 11 | -------------------------------------------------------------------------------- /tiny-event-sourcing-sagas-projections/src/main/kotlin/ru/quipy/projections/SagaDefaultProjections.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projections 2 | 3 | import org.springframework.data.annotation.Id 4 | import org.springframework.data.mongodb.core.mapping.Document 5 | import org.springframework.data.mongodb.repository.MongoRepository 6 | import org.springframework.stereotype.Component 7 | import org.springframework.stereotype.Repository 8 | import ru.quipy.saga.aggregate.api.DefaultSagaProcessedEvent 9 | import ru.quipy.saga.aggregate.api.SagaStepAggregate 10 | import ru.quipy.streams.AggregateSubscriptionsManager 11 | import javax.annotation.PostConstruct 12 | 13 | @Component 14 | class SagaDefaultProjections( 15 | private val sagaDefaultProjectionsRepository: SagaDefaultProjectionsRepository, 16 | private val subscriptionsManager: AggregateSubscriptionsManager 17 | ) { 18 | @PostConstruct 19 | fun init() { 20 | subscriptionsManager.createSubscriber(SagaStepAggregate::class, "local-sagas::sagas-default-projections") { 21 | `when`(DefaultSagaProcessedEvent::class) { event -> 22 | val projectionOptional = sagaDefaultProjectionsRepository.findById(event.correlationId.toString()) 23 | 24 | val currentEventId = event.currentEventId.toString() 25 | val causationId = event.causationId?.toString() 26 | 27 | val newStep = SagaDefaultStep( 28 | currentEventId, 29 | causationId, 30 | event.createdAt.toString(), 31 | event.eventName 32 | ) 33 | 34 | val saga: SagaDefault 35 | if (projectionOptional.isEmpty) { 36 | saga = SagaDefault(event.correlationId.toString()) 37 | insertNextStep(saga.sagaSteps, newStep) 38 | } else { 39 | saga = projectionOptional.get() 40 | if (!saga.sagaSteps.contains(newStep)) { 41 | insertNextStep(saga.sagaSteps, newStep) 42 | } 43 | } 44 | 45 | sagaDefaultProjectionsRepository.save(saga) 46 | } 47 | } 48 | } 49 | 50 | private fun insertNextStep(sagaSteps: MutableList, sagaStep: SagaDefaultStep) { 51 | var indexToInsert = 0 52 | if (sagaStep.causationId != null) { 53 | indexToInsert = sagaSteps.indices.findLast { 54 | sagaStep.causationId == sagaSteps[it].currentEventId 55 | }?.inc() 56 | ?: sagaSteps.size 57 | } 58 | 59 | if (indexToInsert < sagaSteps.size) { 60 | sagaSteps.add(indexToInsert, sagaStep) 61 | } else { 62 | sagaSteps.add(sagaStep) 63 | } 64 | } 65 | } 66 | 67 | @Document("sagas-default-projections") 68 | data class SagaDefault( 69 | @Id 70 | val correlationId: String, 71 | val sagaSteps: MutableList = mutableListOf() 72 | ) 73 | 74 | data class SagaDefaultStep( 75 | val currentEventId: String, 76 | val causationId: String? = null, 77 | val processedAt: String, 78 | var eventName: String 79 | ) 80 | 81 | @Repository 82 | interface SagaDefaultProjectionsRepository : MongoRepository -------------------------------------------------------------------------------- /tiny-event-sourcing-sagas-projections/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.data.mongodb.host=localhost 2 | spring.data.mongodb.port=27017 3 | spring.data.mongodb.database=tiny-es 4 | spring.main.allow-bean-definition-overriding=true 5 | 6 | management.endpoints.web.exposure.include=health,metrics,prometheus 7 | management.endpoint.health.probes.enabled=true 8 | management.metrics.export.defaults.enabled=false 9 | management.metrics.export.prometheus.enabled=true 10 | management.metrics.tags.application=${spring.application.name} 11 | 12 | event.sourcing.snapshot-frequency=100 13 | event.sourcing.auto-scan-enabled=true 14 | event.sourcing.scan-package=ru.quipy 15 | -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/.env: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=tiny_es 2 | POSTGRES_USER=tiny_es 3 | POSTGRES_PASSWORD=tiny_es 4 | POSTGRES_DB_PORT=5432 -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | mongodb: 5 | build: mongo/ 6 | ports: 7 | - "27017:27017" 8 | 9 | # volumes: 10 | # - ~/apps/mongo:/data/db 11 | 12 | volumes: 13 | pg_data: -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/mongo/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mongo:5.0 2 | RUN echo "rs.initiate({'_id':'rs0','members':[{'_id':0,'host':'127.0.0.1:27017'}]});" > /docker-entrypoint-initdb.d/replica-init.js 3 | RUN cat /docker-entrypoint-initdb.d/replica-init.js 4 | CMD [ "--bind_ip_all", "--replSet", "rs0" ] -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/Application.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | 6 | @SpringBootApplication 7 | class Application 8 | 9 | fun main(args: Array) { 10 | runApplication(*args) 11 | } 12 | -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/bankDemo/accounts/api/AccountAggregate.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.accounts.api 2 | 3 | import ru.quipy.core.annotations.AggregateType 4 | import ru.quipy.domain.Aggregate 5 | 6 | @AggregateType(aggregateEventsTableName = "accounts") 7 | class AccountAggregate: Aggregate -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/bankDemo/accounts/api/AccountEvents.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.accounts.api 2 | 3 | import ru.quipy.core.annotations.DomainEvent 4 | import ru.quipy.domain.Event 5 | import java.math.BigDecimal 6 | import java.util.* 7 | 8 | const val ACCOUNT_CREATED = "ACCOUNT_CREATED_EVENT" 9 | const val BANK_ACCOUNT_CREATED = "BANK_ACCOUNT_CREATED_EVENT" 10 | const val BANK_ACCOUNT_DEPOSIT = "BANK_ACCOUNT_DEPOSIT_EVENT" 11 | const val BANK_ACCOUNT_WITHDRAWAL = "BANK_ACCOUNT_WITHDRAWAL_EVENT" 12 | const val INTERNAL_ACCOUNT_TRANSFER = "INTERNAL_ACCOUNT_TRANSFER_EVENT" 13 | 14 | const val TRANSFER_TRANSACTION_ACCEPTED = "TRANSFER_TRANSACTION_ACCEPTED" 15 | const val TRANSFER_TRANSACTION_DECLINED = "TRANSFER_TRANSACTION_DECLINED" 16 | const val TRANSFER_TRANSACTION_PROCESSED = "TRANSFER_TRANSACTION_PROCESSED" 17 | const val TRANSFER_TRANSACTION_ROLLBACKED = "TRANSFER_TRANSACTION_ROLLBACKED" 18 | 19 | 20 | @DomainEvent(name = ACCOUNT_CREATED) 21 | data class AccountCreatedEvent( 22 | val accountId: UUID, 23 | val userId: UUID, 24 | ) : Event( 25 | name = ACCOUNT_CREATED, 26 | ) 27 | 28 | @DomainEvent(name = BANK_ACCOUNT_CREATED) 29 | data class BankAccountCreatedEvent( 30 | val accountId: UUID, 31 | val bankAccountId: UUID, 32 | ) : Event( 33 | name = BANK_ACCOUNT_CREATED, 34 | ) 35 | 36 | @DomainEvent(name = BANK_ACCOUNT_DEPOSIT) 37 | data class BankAccountDepositEvent( 38 | val accountId: UUID, 39 | val bankAccountId: UUID, 40 | val amount: BigDecimal, 41 | ) : Event( 42 | name = BANK_ACCOUNT_DEPOSIT, 43 | ) 44 | 45 | @DomainEvent(name = BANK_ACCOUNT_WITHDRAWAL) 46 | data class BankAccountWithdrawalEvent( 47 | val accountId: UUID, 48 | val bankAccountId: UUID, 49 | val amount: BigDecimal, 50 | ) : Event( 51 | name = BANK_ACCOUNT_WITHDRAWAL, 52 | ) 53 | 54 | @DomainEvent(name = INTERNAL_ACCOUNT_TRANSFER) 55 | data class InternalAccountTransferEvent( 56 | val accountId: UUID, 57 | val bankAccountIdFrom: UUID, 58 | val bankAccountIdTo: UUID, 59 | val amount: BigDecimal, 60 | ) : Event( 61 | name = INTERNAL_ACCOUNT_TRANSFER, 62 | ) 63 | 64 | @DomainEvent(name = TRANSFER_TRANSACTION_ACCEPTED) 65 | data class TransferTransactionAcceptedEvent( 66 | val accountId: UUID, 67 | val bankAccountId: UUID, 68 | val transactionId: UUID, 69 | val transferAmount: BigDecimal, 70 | val isDeposit: Boolean 71 | ) : Event( 72 | name = TRANSFER_TRANSACTION_ACCEPTED, 73 | ) 74 | 75 | @DomainEvent(name = TRANSFER_TRANSACTION_DECLINED) 76 | data class TransferTransactionDeclinedEvent( 77 | val accountId: UUID, 78 | val bankAccountId: UUID, 79 | val transactionId: UUID, 80 | val reason: String 81 | ) : Event( 82 | name = TRANSFER_TRANSACTION_DECLINED, 83 | ) 84 | 85 | @DomainEvent(name = TRANSFER_TRANSACTION_PROCESSED) 86 | data class TransferTransactionProcessedEvent( 87 | val accountId: UUID, 88 | val bankAccountId: UUID, 89 | val transactionId: UUID, 90 | ) : Event( 91 | name = TRANSFER_TRANSACTION_PROCESSED, 92 | ) 93 | 94 | @DomainEvent(name = TRANSFER_TRANSACTION_ROLLBACKED) 95 | data class TransferTransactionRollbackedEvent( 96 | val accountId: UUID, 97 | val bankAccountId: UUID, 98 | val transactionId: UUID, 99 | ) : Event( 100 | name = TRANSFER_TRANSACTION_ROLLBACKED, 101 | ) -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/bankDemo/accounts/config/AccountBoundedContextConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.accounts.config 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import ru.quipy.bankDemo.accounts.api.AccountAggregate 7 | import ru.quipy.bankDemo.accounts.logic.Account 8 | import ru.quipy.core.EventSourcingService 9 | import ru.quipy.core.EventSourcingServiceFactory 10 | import java.util.* 11 | 12 | @Configuration 13 | class AccountBoundedContextConfig { 14 | 15 | @Autowired 16 | private lateinit var eventSourcingServiceFactory: EventSourcingServiceFactory 17 | 18 | @Bean 19 | fun accountEsService(): EventSourcingService = 20 | eventSourcingServiceFactory.create() 21 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/bankDemo/accounts/controller/AccountController.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.accounts.controller 2 | 3 | import org.springframework.web.bind.annotation.GetMapping 4 | import org.springframework.web.bind.annotation.PathVariable 5 | import org.springframework.web.bind.annotation.PostMapping 6 | import org.springframework.web.bind.annotation.RequestMapping 7 | import org.springframework.web.bind.annotation.RestController 8 | import ru.quipy.bankDemo.accounts.api.AccountAggregate 9 | import ru.quipy.bankDemo.accounts.api.AccountCreatedEvent 10 | import ru.quipy.bankDemo.accounts.api.BankAccountCreatedEvent 11 | import ru.quipy.bankDemo.accounts.logic.Account 12 | import ru.quipy.bankDemo.accounts.logic.BankAccount 13 | import ru.quipy.core.EventSourcingService 14 | import java.util.* 15 | 16 | @RestController 17 | @RequestMapping("/accounts") 18 | class AccountController( 19 | val accountEsService: EventSourcingService 20 | ) { 21 | 22 | @PostMapping("/{holderId}") 23 | fun createAccount(@PathVariable holderId: UUID) : AccountCreatedEvent { 24 | return accountEsService.create { it.createNewAccount(holderId = holderId) } 25 | } 26 | 27 | @GetMapping("/{accountId}") 28 | fun getAccount(@PathVariable accountId: UUID) : Account? { 29 | return accountEsService.getState(accountId) 30 | } 31 | 32 | @PostMapping("/{accountId}/bankAccount") 33 | fun createBankAccount(@PathVariable accountId: UUID) : BankAccountCreatedEvent { 34 | return accountEsService.update(accountId) { it.createNewBankAccount() } 35 | } 36 | 37 | @GetMapping("/{accountId}/bankAccount/{bankAccountId}") 38 | fun getBankAccount(@PathVariable accountId: UUID, @PathVariable bankAccountId: UUID) : BankAccount? { 39 | return accountEsService.getState(accountId)?.bankAccounts?.get(bankAccountId) 40 | } 41 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/bankDemo/accounts/subscribers/TransactionsSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.accounts.subscribers 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.stereotype.Component 6 | import ru.quipy.bankDemo.accounts.api.AccountAggregate 7 | import ru.quipy.bankDemo.transfers.api.TransferTransactionAggregate 8 | import ru.quipy.bankDemo.transfers.api.TransferTransactionCreatedEvent 9 | import ru.quipy.bankDemo.accounts.logic.Account 10 | import ru.quipy.bankDemo.transfers.api.TransactionConfirmedEvent 11 | import ru.quipy.core.EventSourcingService 12 | import ru.quipy.streams.AggregateSubscriptionsManager 13 | import java.util.* 14 | import javax.annotation.PostConstruct 15 | 16 | @Component 17 | class TransactionsSubscriber( 18 | private val subscriptionsManager: AggregateSubscriptionsManager, 19 | private val accountEsService: EventSourcingService 20 | ) { 21 | private val logger: Logger = LoggerFactory.getLogger(TransactionsSubscriber::class.java) 22 | 23 | @PostConstruct 24 | fun init() { 25 | subscriptionsManager.createSubscriber(TransferTransactionAggregate::class, "accounts::transaction-processing-subscriber") { 26 | `when`(TransferTransactionCreatedEvent::class) { event -> 27 | logger.info("Got transaction to process: $event") 28 | 29 | val transactionOutcome1 = accountEsService.update(event.sourceAccountId) { // todo sukhoa idempotence! 30 | it.performTransferFrom( 31 | event.sourceBankAccountId, 32 | event.transferId, 33 | event.transferAmount 34 | ) 35 | } 36 | 37 | val transactionOutcome2 = accountEsService.update(event.destinationAccountId) { // todo sukhoa idempotence! 38 | it.performTransferTo( 39 | event.destinationBankAccountId, 40 | event.transferId, 41 | event.transferAmount 42 | ) 43 | } 44 | 45 | logger.info("Transaction: ${event.transferId}. Outcomes: $transactionOutcome1, $transactionOutcome2") 46 | } 47 | `when`(TransactionConfirmedEvent::class) { event -> 48 | logger.info("Got transaction confirmed event: $event") 49 | 50 | val transactionOutcome1 = accountEsService.update(event.sourceAccountId) { // todo sukhoa idempotence! 51 | it.processPendingTransaction(event.sourceBankAccountId, event.transferId) 52 | } 53 | 54 | val transactionOutcome2 = accountEsService.update(event.destinationAccountId) { // todo sukhoa idempotence! 55 | it.processPendingTransaction(event.destinationBankAccountId, event.transferId) 56 | } 57 | 58 | logger.info("Transaction: ${event.transferId}. Outcomes: $transactionOutcome1, $transactionOutcome2") 59 | } 60 | // todo sukhoa bank account deleted event 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/bankDemo/transfers/api/TransferTransactionAggregate.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.api 2 | 3 | import ru.quipy.core.annotations.AggregateType 4 | import ru.quipy.domain.Aggregate 5 | 6 | @AggregateType(aggregateEventsTableName = "transfers") 7 | class TransferTransactionAggregate: Aggregate -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/bankDemo/transfers/config/TransactionCoundedContextConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.config 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import ru.quipy.bankDemo.transfers.api.TransferTransactionAggregate 7 | import ru.quipy.bankDemo.transfers.logic.TransferTransaction 8 | import ru.quipy.core.EventSourcingService 9 | import ru.quipy.core.EventSourcingServiceFactory 10 | import java.util.* 11 | 12 | @Configuration 13 | class TransactionCoundedContextConfig { 14 | 15 | @Autowired 16 | private lateinit var eventSourcingServiceFactory: EventSourcingServiceFactory 17 | 18 | @Bean 19 | fun transactionEsService(): EventSourcingService = 20 | eventSourcingServiceFactory.create() 21 | 22 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/bankDemo/transfers/projections/BankAccountsExistenceCache.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.projections 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.data.annotation.Id 6 | import org.springframework.data.mongodb.core.mapping.Document 7 | import org.springframework.data.mongodb.repository.MongoRepository 8 | import org.springframework.stereotype.Component 9 | import org.springframework.stereotype.Repository 10 | import ru.quipy.bankDemo.accounts.api.AccountAggregate 11 | import ru.quipy.bankDemo.accounts.api.BankAccountCreatedEvent 12 | import ru.quipy.streams.AggregateSubscriptionsManager 13 | import java.util.* 14 | import javax.annotation.PostConstruct 15 | 16 | @Component 17 | class BankAccountsExistenceCache( 18 | private val bankAccountCacheRepository: BankAccountCacheRepository, 19 | private val subscriptionsManager: AggregateSubscriptionsManager 20 | ) { 21 | private val logger: Logger = LoggerFactory.getLogger(BankAccountsExistenceCache::class.java) 22 | 23 | @PostConstruct 24 | fun init() { 25 | subscriptionsManager.createSubscriber(AccountAggregate::class, "transactions::accounts-cache") { 26 | `when`(BankAccountCreatedEvent::class) { event -> 27 | bankAccountCacheRepository.save(BankAccount(event.bankAccountId, event.accountId)) // todo sukhoa idempotence! 28 | logger.info("Update bank account cache, create account ${event.accountId}-${event.bankAccountId}") 29 | } 30 | // todo sukhoa bank account deleted event 31 | } 32 | } 33 | } 34 | 35 | @Document("transactions-accounts-cache") 36 | data class BankAccount( 37 | @Id 38 | val bankAccountId: UUID, 39 | var accountId: UUID 40 | ) 41 | 42 | @Repository 43 | interface BankAccountCacheRepository: MongoRepository 44 | -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/bankDemo/transfers/service/TransactionService.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.service 2 | 3 | import org.springframework.stereotype.Service 4 | import ru.quipy.bankDemo.transfers.api.TransferTransactionAggregate 5 | import ru.quipy.bankDemo.transfers.api.TransferTransactionCreatedEvent 6 | import ru.quipy.bankDemo.transfers.logic.TransferTransaction 7 | import ru.quipy.bankDemo.transfers.projections.BankAccountCacheRepository 8 | import ru.quipy.core.EventSourcingService 9 | import java.math.BigDecimal 10 | import java.util.* 11 | 12 | @Service 13 | class TransactionService( 14 | private val bankAccountCacheRepository: BankAccountCacheRepository, 15 | private val transactionEsService: EventSourcingService 16 | ) { 17 | fun initiateTransferTransaction( 18 | sourceBankAccountId: UUID, 19 | destinationBankAccountId: UUID, 20 | transferAmount: BigDecimal 21 | ): TransferTransactionCreatedEvent { 22 | val srcBankAccount = bankAccountCacheRepository.findById(sourceBankAccountId).orElseThrow { 23 | IllegalArgumentException("Cannot create transaction. There is no source bank account: $sourceBankAccountId") 24 | } 25 | 26 | val dstBankAccount = bankAccountCacheRepository.findById(destinationBankAccountId).orElseThrow { 27 | IllegalArgumentException("Cannot create transaction. There is no destination bank account: $destinationBankAccountId") 28 | } 29 | 30 | return transactionEsService.create { 31 | it.initiateTransferTransaction( 32 | sourceAccountId = srcBankAccount.accountId, 33 | sourceBankAccountId = srcBankAccount.bankAccountId, 34 | destinationAccountId = dstBankAccount.accountId, 35 | destinationBankAccountId = dstBankAccount.bankAccountId, 36 | transferAmount = transferAmount 37 | ) 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/bankDemo/transfers/subscribers/BankAccountsSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.bankDemo.transfers.subscribers 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.stereotype.Component 6 | import ru.quipy.bankDemo.accounts.api.* 7 | import ru.quipy.bankDemo.transfers.api.TransferTransactionAggregate 8 | import ru.quipy.bankDemo.transfers.api.TransferTransactionCreatedEvent 9 | import ru.quipy.bankDemo.accounts.logic.Account 10 | import ru.quipy.bankDemo.transfers.logic.TransferTransaction 11 | import ru.quipy.core.EventSourcingService 12 | import ru.quipy.streams.AggregateSubscriptionsManager 13 | import java.util.* 14 | import javax.annotation.PostConstruct 15 | 16 | @Component 17 | class BankAccountsSubscriber( 18 | private val subscriptionsManager: AggregateSubscriptionsManager, 19 | private val transactionEsService: EventSourcingService 20 | ) { 21 | private val logger: Logger = LoggerFactory.getLogger(BankAccountsSubscriber::class.java) 22 | 23 | @PostConstruct 24 | fun init() { 25 | subscriptionsManager.createSubscriber(AccountAggregate::class, "transactions::bank-accounts-subscriber") { 26 | `when`(TransferTransactionAcceptedEvent::class) { event -> 27 | transactionEsService.update(event.transactionId) { 28 | it.processParticipantAccept(event.bankAccountId) 29 | } 30 | } 31 | `when`(TransferTransactionDeclinedEvent::class) { event -> 32 | transactionEsService.update(event.transactionId) { 33 | it.processParticipantDecline(event.bankAccountId) 34 | } 35 | } 36 | `when`(TransferTransactionProcessedEvent::class) { event -> 37 | transactionEsService.update(event.transactionId) { 38 | it.participantCommitted(event.bankAccountId) 39 | } 40 | } 41 | `when`(TransferTransactionRollbackedEvent::class) { event -> 42 | transactionEsService.update(event.transactionId) { 43 | it.participantRollbacked(event.bankAccountId) 44 | } 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/projectDemo/api/ProjectAggregate.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo.api 2 | 3 | import ru.quipy.core.annotations.AggregateType 4 | import ru.quipy.domain.Aggregate 5 | 6 | // API 7 | @AggregateType(aggregateEventsTableName = "aggregate-project") 8 | class ProjectAggregate : Aggregate -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/projectDemo/api/ProjectAggregateDomainEvents.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo.api 2 | 3 | import ru.quipy.core.annotations.DomainEvent 4 | import ru.quipy.domain.Event 5 | import java.util.* 6 | 7 | const val PROJECT_CREATED_EVENT = "PROJECT_CREATED_EVENT" 8 | const val TAG_CREATED_EVENT = "TAG_CREATED_EVENT" 9 | const val TAG_ASSIGNED_TO_TASK_EVENT = "TAG_ASSIGNED_TO_TASK_EVENT" 10 | const val TASK_CREATED_EVENT = "TASK_CREATED_EVENT" 11 | 12 | // API 13 | @DomainEvent(name = PROJECT_CREATED_EVENT) 14 | class ProjectCreatedEvent( 15 | val projectId: String, 16 | createdAt: Long = System.currentTimeMillis(), 17 | ) : Event( 18 | name = PROJECT_CREATED_EVENT, 19 | createdAt = createdAt, 20 | ) 21 | 22 | @DomainEvent(name = TAG_CREATED_EVENT) 23 | class TagCreatedEvent( 24 | val projectId: String, 25 | val tagId: UUID, 26 | val tagName: String, 27 | createdAt: Long = System.currentTimeMillis(), 28 | ) : Event( 29 | name = TAG_CREATED_EVENT, 30 | createdAt = createdAt, 31 | ) 32 | 33 | @DomainEvent(name = TASK_CREATED_EVENT) 34 | class TaskCreatedEvent( 35 | val projectId: String, 36 | val taskId: UUID, 37 | val taskName: String, 38 | createdAt: Long = System.currentTimeMillis(), 39 | ) : Event( 40 | name = TASK_CREATED_EVENT, 41 | createdAt = createdAt 42 | ) 43 | 44 | @DomainEvent(name = TAG_ASSIGNED_TO_TASK_EVENT) 45 | class TagAssignedToTaskEvent( 46 | val projectId: String, 47 | val taskId: UUID, 48 | val tagId: UUID, 49 | createdAt: Long = System.currentTimeMillis(), 50 | ) : SomeBaseEventWithoutAnnotation( 51 | name = TAG_ASSIGNED_TO_TASK_EVENT, 52 | createdAt = createdAt 53 | ) 54 | 55 | // for testing and demo purposes 56 | open class SomeBaseEventWithoutAnnotation( 57 | name: String, 58 | createdAt: Long = System.currentTimeMillis(), 59 | ) : Event(name = name, createdAt = createdAt) 60 | -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/projectDemo/config/ProjectDemoConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo.config 2 | 3 | import org.slf4j.LoggerFactory 4 | import org.springframework.beans.factory.annotation.Autowired 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import ru.quipy.core.AggregateRegistry 8 | import ru.quipy.core.EventSourcingServiceFactory 9 | import ru.quipy.projectDemo.api.ProjectAggregate 10 | import ru.quipy.projectDemo.logic.ProjectAggregateState 11 | import ru.quipy.projectDemo.projections.AnnotationBasedProjectEventsSubscriber 12 | import ru.quipy.streams.AggregateEventStreamManager 13 | import ru.quipy.streams.AggregateSubscriptionsManager 14 | import javax.annotation.PostConstruct 15 | 16 | @Configuration 17 | class ProjectDemoConfig { 18 | 19 | private val logger = LoggerFactory.getLogger(ProjectDemoConfig::class.java) 20 | 21 | @Autowired 22 | private lateinit var subscriptionsManager: AggregateSubscriptionsManager 23 | 24 | @Autowired 25 | private lateinit var projectEventSubscriber: AnnotationBasedProjectEventsSubscriber 26 | 27 | @Autowired 28 | private lateinit var eventSourcingServiceFactory: EventSourcingServiceFactory 29 | 30 | @Autowired 31 | private lateinit var eventStreamManager: AggregateEventStreamManager 32 | 33 | @Autowired 34 | private lateinit var aggregateRegistry: AggregateRegistry 35 | 36 | @PostConstruct 37 | fun init() { 38 | // autoscan enabled see event.sourcing.auto-scan-enabled property 39 | // aggregateRegistry.register(ProjectAggregate::class, ProjectAggregateState::class) { 40 | // registerStateTransition(TagCreatedEvent::class, ProjectAggregateState::tagCreatedApply) 41 | // registerStateTransition(TaskCreatedEvent::class, ProjectAggregateState::taskCreatedApply) 42 | // registerStateTransition(TagAssignedToTaskEvent::class, ProjectAggregateState::tagAssignedApply) 43 | // } 44 | 45 | subscriptionsManager.subscribe(projectEventSubscriber) 46 | 47 | eventStreamManager.maintenance { 48 | onRecordHandledSuccessfully { streamName, eventName -> 49 | logger.info("Stream $streamName successfully processed record of $eventName") 50 | } 51 | 52 | onBatchRead { streamName, batchSize -> 53 | logger.info("Stream $streamName read batch size: $batchSize") 54 | } 55 | } 56 | } 57 | 58 | @Bean 59 | fun demoESService() = eventSourcingServiceFactory.create() 60 | 61 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/projectDemo/logic/ProjectAggregateCommands.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo 2 | 3 | import ru.quipy.projectDemo.api.ProjectCreatedEvent 4 | import ru.quipy.projectDemo.api.TagAssignedToTaskEvent 5 | import ru.quipy.projectDemo.api.TagCreatedEvent 6 | import ru.quipy.projectDemo.api.TaskCreatedEvent 7 | import ru.quipy.projectDemo.logic.ProjectAggregateState 8 | import java.util.* 9 | 10 | 11 | // Commands : takes something -> returns event 12 | fun ProjectAggregateState.create(id: String): ProjectCreatedEvent { 13 | return ProjectCreatedEvent(projectId = id) 14 | } 15 | 16 | fun ProjectAggregateState.addTask(name: String): TaskCreatedEvent { 17 | return TaskCreatedEvent(projectId = this.getId(), taskId = UUID.randomUUID(), taskName = name) 18 | } 19 | 20 | fun ProjectAggregateState.createTag(name: String): TagCreatedEvent { 21 | if (projectTags.values.any { it.name == name }) { 22 | throw IllegalArgumentException("Tag already exists: $name") 23 | } 24 | return TagCreatedEvent(projectId = this.getId(), tagId = UUID.randomUUID(), tagName = name) 25 | } 26 | 27 | fun ProjectAggregateState.assignTagToTask(tagId: UUID, taskId: UUID): TagAssignedToTaskEvent { 28 | if (!projectTags.containsKey(tagId)) { 29 | throw IllegalArgumentException("Tag doesn't exists: $tagId") 30 | } 31 | 32 | if (!tasks.containsKey(taskId)) { 33 | throw IllegalArgumentException("Task doesn't exists: $taskId") 34 | } 35 | 36 | return TagAssignedToTaskEvent(projectId = this.getId(), tagId = tagId, taskId = taskId) 37 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/projectDemo/logic/ProjectAggregateState.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo.logic 2 | 3 | import ru.quipy.core.annotations.StateTransitionFunc 4 | import ru.quipy.domain.AggregateState 5 | import ru.quipy.projectDemo.api.ProjectAggregate 6 | import ru.quipy.projectDemo.api.ProjectCreatedEvent 7 | import ru.quipy.projectDemo.api.TagAssignedToTaskEvent 8 | import ru.quipy.projectDemo.api.TagCreatedEvent 9 | import ru.quipy.projectDemo.api.TaskCreatedEvent 10 | import java.util.UUID 11 | 12 | // Service's business logic 13 | class ProjectAggregateState: AggregateState { 14 | lateinit var projectId: String 15 | var createdAt: Long = System.currentTimeMillis() 16 | var updatedAt: Long = System.currentTimeMillis() 17 | 18 | var tasks = mutableMapOf() 19 | var projectTags = mutableMapOf() 20 | 21 | override fun getId() = projectId 22 | 23 | // State transition functions 24 | @StateTransitionFunc 25 | fun projectCreatedApply(event: ProjectCreatedEvent) { 26 | projectId = event.projectId 27 | updatedAt = createdAt 28 | } 29 | 30 | @StateTransitionFunc 31 | fun tagCreatedApply(event: TagCreatedEvent) { 32 | projectTags[event.tagId] = ProjectTag(event.tagId, event.tagName) 33 | updatedAt = createdAt 34 | } 35 | 36 | @StateTransitionFunc 37 | fun taskCreatedApply(event: TaskCreatedEvent) { 38 | tasks[event.taskId] = TaskEntity(event.taskId, event.taskName, mutableSetOf()) 39 | updatedAt = createdAt 40 | } 41 | } 42 | 43 | data class TaskEntity( 44 | val id: UUID = UUID.randomUUID(), 45 | val name: String, 46 | val tagsAssigned: MutableSet 47 | ) 48 | 49 | data class ProjectTag( 50 | val id: UUID = UUID.randomUUID(), 51 | val name: String 52 | ) 53 | 54 | @StateTransitionFunc 55 | fun ProjectAggregateState.tagAssignedApply(event: TagAssignedToTaskEvent) { 56 | tasks[event.taskId]?.tagsAssigned?.add(event.tagId) 57 | ?: throw IllegalArgumentException("No such task: ${event.taskId}") // todo sukhoa exception or not? 58 | updatedAt = createdAt 59 | } 60 | -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/projectDemo/projections/AnnotationBasedProjectEventsSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo.projections 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.stereotype.Service 6 | import ru.quipy.projectDemo.api.ProjectAggregate 7 | import ru.quipy.projectDemo.api.TagCreatedEvent 8 | import ru.quipy.projectDemo.api.TaskCreatedEvent 9 | import ru.quipy.streams.annotation.AggregateSubscriber 10 | import ru.quipy.streams.annotation.SubscribeEvent 11 | 12 | @Service 13 | @AggregateSubscriber( 14 | aggregateClass = ProjectAggregate::class, subscriberName = "demo-subs-stream" 15 | ) 16 | class AnnotationBasedProjectEventsSubscriber { 17 | 18 | val logger: Logger = LoggerFactory.getLogger(AnnotationBasedProjectEventsSubscriber::class.java) 19 | 20 | @SubscribeEvent 21 | fun taskCreatedSubscriber(event: TaskCreatedEvent) { 22 | logger.info("Task created: {}", event.taskName) 23 | } 24 | 25 | @SubscribeEvent 26 | fun tagCreatedSubscriber(event: TagCreatedEvent) { 27 | logger.info("Tag created: {}", event.tagName) 28 | } 29 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/projectDemo/projections/ProjectEventsSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.projectDemo.projections 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.beans.factory.annotation.Autowired 6 | import org.springframework.stereotype.Service 7 | import ru.quipy.projectDemo.api.ProjectAggregate 8 | import ru.quipy.projectDemo.api.TagAssignedToTaskEvent 9 | import ru.quipy.projectDemo.api.TagCreatedEvent 10 | import ru.quipy.projectDemo.api.TaskCreatedEvent 11 | import ru.quipy.streams.AggregateSubscriptionsManager 12 | import javax.annotation.PostConstruct 13 | 14 | @Service 15 | class ProjectEventsSubscriber { 16 | 17 | val logger: Logger = LoggerFactory.getLogger(ProjectEventsSubscriber::class.java) 18 | 19 | @Autowired 20 | lateinit var subscriptionsManager: AggregateSubscriptionsManager 21 | 22 | @PostConstruct 23 | fun init() { 24 | subscriptionsManager.createSubscriber(ProjectAggregate::class, "some-meaningful-name") { 25 | 26 | `when`(TaskCreatedEvent::class) { event -> 27 | logger.info("Task created: {}", event.taskName) 28 | } 29 | 30 | `when`(TagCreatedEvent::class) { event -> 31 | logger.info("Tag created: {}", event.tagName) 32 | } 33 | 34 | `when`(TagAssignedToTaskEvent::class) { event -> 35 | logger.info("Tag {} assigned to task {}: ", event.tagId, event.taskId) 36 | } 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/flights/api/FlightAggregate.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.flights.api 2 | 3 | import ru.quipy.core.annotations.AggregateType 4 | import ru.quipy.domain.Aggregate 5 | 6 | @AggregateType("flights") 7 | class FlightAggregate : Aggregate -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/flights/api/FlightsEvents.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.flights.api 2 | 3 | import ru.quipy.core.annotations.DomainEvent 4 | import ru.quipy.domain.Event 5 | import java.util.UUID 6 | 7 | const val FLIGHTS_RESERVED = "FLIGHTS_RESERVED_EVENT" 8 | const val FLIGHTS_RESERVATION_FAILED = "FLIGHTS_RESERVATION_FAILED_EVENT" 9 | 10 | @DomainEvent(FLIGHTS_RESERVED) 11 | data class FlightReservedEvent ( 12 | val flightReservationId: UUID 13 | ) : Event( 14 | name = FLIGHTS_RESERVED, 15 | ) 16 | 17 | @DomainEvent(FLIGHTS_RESERVATION_FAILED) 18 | data class FlightReservationCanceledEvent ( 19 | val flightReservationId: UUID 20 | ): Event( 21 | name = FLIGHTS_RESERVATION_FAILED, 22 | ) -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/flights/config/FlightBoundedContextConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.flights.config 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import ru.quipy.core.EventSourcingService 7 | import ru.quipy.core.EventSourcingServiceFactory 8 | import ru.quipy.sagaDemo.flights.api.FlightAggregate 9 | import ru.quipy.sagaDemo.flights.logic.Flight 10 | import java.util.* 11 | 12 | @Configuration 13 | class FlightBoundedContextConfig { 14 | 15 | @Autowired 16 | private lateinit var eventSourcingServiceFactory: EventSourcingServiceFactory 17 | 18 | @Bean 19 | fun flightEsService(): EventSourcingService = 20 | eventSourcingServiceFactory.create() 21 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/flights/logic/Flight.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.flights.logic 2 | 3 | import ru.quipy.core.annotations.StateTransitionFunc 4 | import ru.quipy.domain.AggregateState 5 | import ru.quipy.sagaDemo.flights.api.FlightAggregate 6 | import ru.quipy.sagaDemo.flights.api.FlightReservationCanceledEvent 7 | import ru.quipy.sagaDemo.flights.api.FlightReservedEvent 8 | import java.util.* 9 | 10 | class Flight : AggregateState { 11 | lateinit var flightId: UUID 12 | private var canceled: Boolean = false 13 | 14 | override fun getId() = flightId 15 | 16 | fun reserveFlight(id: UUID = UUID.randomUUID()): FlightReservedEvent { 17 | return FlightReservedEvent(id) 18 | } 19 | 20 | fun canselFlight(id: UUID): FlightReservationCanceledEvent { 21 | return FlightReservationCanceledEvent(id) 22 | } 23 | 24 | @StateTransitionFunc 25 | fun reserveFlight(event: FlightReservedEvent) { 26 | flightId = event.flightReservationId 27 | } 28 | 29 | @StateTransitionFunc 30 | fun canselFlight(event: FlightReservationCanceledEvent) { 31 | canceled = true 32 | } 33 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/flights/subscribers/PaymentSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.flights.subscribers 2 | 3 | import org.springframework.stereotype.Component 4 | import ru.quipy.core.EventSourcingService 5 | import ru.quipy.saga.SagaManager 6 | import ru.quipy.sagaDemo.flights.api.FlightAggregate 7 | import ru.quipy.sagaDemo.flights.logic.Flight 8 | import ru.quipy.sagaDemo.payment.api.PaymentAggregate 9 | import ru.quipy.sagaDemo.payment.api.PaymentSucceededEvent 10 | import ru.quipy.streams.AggregateSubscriptionsManager 11 | import java.util.* 12 | import javax.annotation.PostConstruct 13 | 14 | @Component 15 | class PaymentSubscriber ( 16 | private val subscriptionsManager: AggregateSubscriptionsManager, 17 | private val flightEsService: EventSourcingService, 18 | private val sagaManager: SagaManager 19 | ) { 20 | 21 | @PostConstruct 22 | fun init() { 23 | subscriptionsManager.createSubscriber(PaymentAggregate::class, "flights::payment-subscriber") { 24 | `when`(PaymentSucceededEvent::class) { event -> 25 | val sagaContext = sagaManager 26 | .withContextGiven(event.sagaContext) 27 | .launchSaga("TRIP_RESERVATION3", "reservation flight3") 28 | .performSagaStep("TRIP_RESERVATION", "reservation flight") 29 | .performSagaStep("TRIP_RESERVATION2", "reservation flight2") 30 | .sagaContext() 31 | 32 | flightEsService.create(sagaContext) { it.reserveFlight(event.paymentId) } 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/payment/api/PaymentAggregate.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.payment.api 2 | 3 | import ru.quipy.core.annotations.AggregateType 4 | import ru.quipy.domain.Aggregate 5 | 6 | @AggregateType("payment") 7 | class PaymentAggregate : Aggregate -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/payment/api/PaymentEvents.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.payment.api 2 | 3 | import ru.quipy.core.annotations.DomainEvent 4 | import ru.quipy.domain.Event 5 | import java.util.UUID 6 | 7 | const val PAYMENT_SUCCEEDED = "PAYMENT_SUCCEEDED_EVENT" 8 | const val PAYMENT_CANCELED = "PAYMENT_CANCELED_EVENT" 9 | 10 | @DomainEvent(PAYMENT_SUCCEEDED) 11 | data class PaymentSucceededEvent ( 12 | val paymentId: UUID, 13 | val amount: Int 14 | ) : Event( 15 | name = PAYMENT_SUCCEEDED, 16 | ) 17 | 18 | @DomainEvent(PAYMENT_CANCELED) 19 | data class PaymentCanceledEvent ( 20 | val paymentId: UUID, 21 | ) : Event( 22 | name = PAYMENT_CANCELED, 23 | ) -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/payment/config/PaymentBoundedContextConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.payment.config 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import ru.quipy.core.EventSourcingService 7 | import ru.quipy.core.EventSourcingServiceFactory 8 | import ru.quipy.sagaDemo.payment.api.PaymentAggregate 9 | import ru.quipy.sagaDemo.payment.logic.Payment 10 | import java.util.* 11 | 12 | @Configuration 13 | class PaymentBoundedContextConfig { 14 | 15 | @Autowired 16 | private lateinit var eventSourcingServiceFactory: EventSourcingServiceFactory 17 | 18 | @Bean 19 | fun paymentEsService(): EventSourcingService = 20 | eventSourcingServiceFactory.create() 21 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/payment/logic/Payment.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.payment.logic 2 | 3 | import ru.quipy.core.annotations.StateTransitionFunc 4 | import ru.quipy.domain.AggregateState 5 | import ru.quipy.domain.Event 6 | import ru.quipy.sagaDemo.payment.api.PaymentAggregate 7 | import ru.quipy.sagaDemo.payment.api.PaymentCanceledEvent 8 | import ru.quipy.sagaDemo.payment.api.PaymentSucceededEvent 9 | import java.util.* 10 | 11 | class Payment : AggregateState { 12 | lateinit var paymentId: UUID 13 | private var failed: Boolean = false 14 | 15 | override fun getId() = paymentId 16 | 17 | fun processPayment(id: UUID = UUID.randomUUID(), amount: Int) : Event { 18 | return if (amount > 0) 19 | PaymentSucceededEvent(id, amount) 20 | else 21 | PaymentCanceledEvent(id) 22 | } 23 | 24 | fun canselPayment(id: UUID) : PaymentCanceledEvent { 25 | return PaymentCanceledEvent(id) 26 | } 27 | 28 | @StateTransitionFunc 29 | fun processPayment(event: PaymentSucceededEvent) { 30 | paymentId = event.paymentId 31 | } 32 | 33 | @StateTransitionFunc 34 | fun canselPayment(event: PaymentCanceledEvent) { 35 | paymentId = event.paymentId 36 | failed = true 37 | } 38 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/payment/subscribers/TripSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.payment.subscribers 2 | 3 | import org.springframework.stereotype.Component 4 | 5 | import ru.quipy.core.EventSourcingService 6 | import ru.quipy.saga.SagaManager 7 | import ru.quipy.sagaDemo.payment.api.PaymentAggregate 8 | import ru.quipy.sagaDemo.payment.logic.Payment 9 | import ru.quipy.sagaDemo.trips.api.TripAggregate 10 | import ru.quipy.sagaDemo.trips.api.TripReservationStartedEvent 11 | import ru.quipy.streams.AggregateSubscriptionsManager 12 | import java.util.* 13 | import javax.annotation.PostConstruct 14 | 15 | @Component 16 | class TripSubscriber( 17 | private val subscriptionsManager: AggregateSubscriptionsManager, 18 | private val paymentEsService: EventSourcingService, 19 | private val sagaManager: SagaManager 20 | ) { 21 | 22 | @PostConstruct 23 | fun init() { 24 | subscriptionsManager.createSubscriber(TripAggregate::class, "payment::trips-subscriber") { 25 | `when`(TripReservationStartedEvent::class) { event -> 26 | val sagaContext = sagaManager 27 | .withContextGiven(event.sagaContext) 28 | .launchSaga("TRIP_RESERVATION2", "process payment2") 29 | .performSagaStep("TRIP_RESERVATION", "process payment") 30 | .sagaContext() 31 | 32 | paymentEsService.create(sagaContext) { it.processPayment(event.tripId,100) } 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/trips/api/TripAggregate.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.trips.api 2 | 3 | import ru.quipy.core.annotations.AggregateType 4 | import ru.quipy.domain.Aggregate 5 | 6 | @AggregateType("trips") 7 | class TripAggregate : Aggregate -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/trips/api/TripEvents.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.trips.api 2 | 3 | import ru.quipy.core.annotations.DomainEvent 4 | import ru.quipy.domain.Event 5 | import java.util.UUID 6 | 7 | const val TRIP_RESERVATION_STARTED = "TRIP_RESERVATION_STARTED_EVENT" 8 | const val TRIP_RESERVATION_FAILED = "TRIP_RESERVATION_FAILED_EVENT" 9 | const val TRIP_RESERVATION_CONFIRMED = "TRIP_RESERVATION_CONFIRMED_EVENT" 10 | 11 | @DomainEvent(TRIP_RESERVATION_STARTED) 12 | data class TripReservationStartedEvent ( 13 | val tripId: UUID 14 | ) : Event( 15 | name = TRIP_RESERVATION_STARTED, 16 | ) 17 | 18 | @DomainEvent(TRIP_RESERVATION_FAILED) 19 | data class TripReservationFailedEvent ( 20 | val tripId: UUID 21 | ) : Event( 22 | name = TRIP_RESERVATION_FAILED, 23 | ) 24 | 25 | @DomainEvent(TRIP_RESERVATION_CONFIRMED) 26 | data class TripReservationConfirmedEvent ( 27 | val tripId: UUID 28 | ) : Event( 29 | name = TRIP_RESERVATION_CONFIRMED, 30 | ) -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/trips/config/TripBoundedContextConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.trips.config 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import ru.quipy.core.EventSourcingService 7 | import ru.quipy.core.EventSourcingServiceFactory 8 | import ru.quipy.sagaDemo.trips.api.TripAggregate 9 | import ru.quipy.sagaDemo.trips.logic.Trip 10 | import java.util.* 11 | 12 | @Configuration 13 | class TripBoundedContextConfig { 14 | 15 | @Autowired 16 | private lateinit var eventSourcingServiceFactory: EventSourcingServiceFactory 17 | 18 | @Bean 19 | fun tripEsService(): EventSourcingService = 20 | eventSourcingServiceFactory.create() 21 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/trips/controller/TripController.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.trips.controller 2 | 3 | import org.springframework.web.bind.annotation.* 4 | import ru.quipy.core.EventSourcingService 5 | import ru.quipy.saga.SagaManager 6 | import ru.quipy.sagaDemo.trips.api.TripAggregate 7 | import ru.quipy.sagaDemo.trips.api.TripReservationStartedEvent 8 | import ru.quipy.sagaDemo.trips.api.TripReservationFailedEvent 9 | import ru.quipy.sagaDemo.trips.logic.Trip 10 | import java.util.* 11 | 12 | @RestController 13 | class TripController( 14 | val tripEsService: EventSourcingService, 15 | val sagaManager: SagaManager, 16 | ) { 17 | 18 | @GetMapping 19 | fun reserveTrip() : TripReservationStartedEvent { 20 | val sagaContext = sagaManager 21 | .launchSaga("TRIP_RESERVATION", "start reservation") 22 | .sagaContext() 23 | 24 | return tripEsService.create(sagaContext) { it.startReservationTrip() } 25 | } 26 | 27 | @DeleteMapping("/{id}") 28 | fun cancelTrip(@PathVariable id: UUID) : TripReservationFailedEvent { 29 | return tripEsService.update(id) { it.cancelTrip(id) } 30 | } 31 | 32 | @GetMapping("/{id}") 33 | fun getAccount(@PathVariable id: UUID) : Trip? { 34 | return tripEsService.getState(id) 35 | } 36 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/trips/logic/Trip.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.trips.logic 2 | 3 | import ru.quipy.core.annotations.StateTransitionFunc 4 | import ru.quipy.domain.AggregateState 5 | import ru.quipy.sagaDemo.trips.api.TripAggregate 6 | import ru.quipy.sagaDemo.trips.api.TripReservationConfirmedEvent 7 | import ru.quipy.sagaDemo.trips.api.TripReservationStartedEvent 8 | import ru.quipy.sagaDemo.trips.api.TripReservationFailedEvent 9 | import java.util.* 10 | 11 | class Trip : AggregateState { 12 | private lateinit var tripId: UUID 13 | private var canceled: Boolean = false 14 | private var confirmed: Boolean = false 15 | 16 | override fun getId() = tripId 17 | 18 | fun startReservationTrip(id: UUID = UUID.randomUUID()) : TripReservationStartedEvent { 19 | return TripReservationStartedEvent(id) 20 | } 21 | 22 | fun cancelTrip(id: UUID) : TripReservationFailedEvent { 23 | return TripReservationFailedEvent(id) 24 | } 25 | 26 | fun confirmTrip(id: UUID) : TripReservationConfirmedEvent { 27 | return TripReservationConfirmedEvent(id) 28 | } 29 | 30 | @StateTransitionFunc 31 | fun startReservationTrip(event: TripReservationStartedEvent) { 32 | tripId = event.tripId 33 | } 34 | 35 | @StateTransitionFunc 36 | fun cancelTrip(event: TripReservationFailedEvent) { 37 | canceled = true 38 | } 39 | 40 | @StateTransitionFunc 41 | fun confirmTrip(event: TripReservationConfirmedEvent) { 42 | confirmed = true 43 | } 44 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/trips/subscribers/FlightSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.trips.subscribers 2 | 3 | import org.springframework.stereotype.Component 4 | import ru.quipy.core.EventSourcingService 5 | import ru.quipy.saga.SagaManager 6 | import ru.quipy.sagaDemo.flights.api.FlightAggregate 7 | import ru.quipy.sagaDemo.flights.api.FlightReservedEvent 8 | import ru.quipy.sagaDemo.trips.api.TripAggregate 9 | import ru.quipy.sagaDemo.trips.logic.Trip 10 | import ru.quipy.streams.AggregateSubscriptionsManager 11 | import java.util.* 12 | import javax.annotation.PostConstruct 13 | 14 | @Component 15 | class FlightSubscriber ( 16 | private val subscriptionsManager: AggregateSubscriptionsManager, 17 | private val tripEsService: EventSourcingService, 18 | private val sagaManager: SagaManager 19 | ) { 20 | 21 | @PostConstruct 22 | fun init() { 23 | subscriptionsManager.createSubscriber(FlightAggregate::class, "trips::flights-subscriber") { 24 | `when`(FlightReservedEvent::class) { event -> 25 | val sagaContext = sagaManager 26 | .withContextGiven(event.sagaContext) 27 | .performSagaStep("TRIP_RESERVATION", "finish reservation") 28 | .performSagaStep("TRIP_RESERVATION2", "finish reservation2") 29 | .performSagaStep("TRIP_RESERVATION3", "finish reservation3") 30 | .sagaContext() 31 | 32 | tripEsService.update(event.flightReservationId, sagaContext) { it.confirmTrip(event.flightReservationId) } 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/kotlin/ru.quipy/sagaDemo/trips/subscribers/PaymentTripSubscriber.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.sagaDemo.trips.subscribers 2 | 3 | import org.springframework.stereotype.Component 4 | import ru.quipy.core.EventSourcingService 5 | import ru.quipy.saga.SagaManager 6 | import ru.quipy.sagaDemo.payment.api.PaymentAggregate 7 | import ru.quipy.sagaDemo.payment.api.PaymentCanceledEvent 8 | import ru.quipy.sagaDemo.trips.api.TripAggregate 9 | import ru.quipy.sagaDemo.trips.logic.Trip 10 | import ru.quipy.streams.AggregateSubscriptionsManager 11 | import java.util.* 12 | import javax.annotation.PostConstruct 13 | 14 | @Component 15 | class PaymentTripSubscriber ( 16 | private val subscriptionsManager: AggregateSubscriptionsManager, 17 | private val tripEsService: EventSourcingService, 18 | private val sagaManager: SagaManager 19 | ) { 20 | 21 | @PostConstruct 22 | fun init() { 23 | subscriptionsManager.createSubscriber(PaymentAggregate::class, "trips::payment-subscriber") { 24 | `when`(PaymentCanceledEvent::class) { event -> 25 | val sagaContext = sagaManager 26 | .withContextGiven(event.sagaContext) 27 | .performSagaStep("TRIP_RESERVATION", "payment failed").sagaContext() 28 | 29 | tripEsService.update(event.paymentId, sagaContext) { it.cancelTrip(event.paymentId) } 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.data.mongodb.host=localhost 2 | spring.data.mongodb.port=27017 3 | spring.data.mongodb.database=tiny-es 4 | 5 | spring.main.allow-bean-definition-overriding=true 6 | 7 | management.endpoints.web.exposure.include=health,metrics,prometheus 8 | management.endpoint.health.probes.enabled=true 9 | management.metrics.export.defaults.enabled=false 10 | management.metrics.export.prometheus.enabled=true 11 | management.metrics.tags.application=${spring.application.name} 12 | 13 | event.sourcing.snapshot-frequency=100 14 | event.sourcing.auto-scan-enabled=true 15 | event.sourcing.scan-package=ru.quipy 16 | 17 | # pg events storage schema 18 | event.sourcing.db-schema=event_sourcing_store 19 | 20 | spring.datasource.hikari.idle-timeout=30000 21 | spring.datasource.hikari.maximum-pool-size=20 22 | 23 | # datasource configuration 24 | spring.datasource.hikari.jdbc-url=jdbc:postgresql://localhost:5432/tiny_es 25 | spring.datasource.hikari.username=tiny_es 26 | spring.datasource.hikari.password=tiny_es -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/test/kotlin/ru.quipy/BaseTest.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | import org.springframework.beans.factory.annotation.Value 5 | import org.springframework.data.mongodb.core.MongoTemplate 6 | import org.springframework.data.mongodb.core.query.Criteria 7 | import org.springframework.data.mongodb.core.query.Query 8 | import ru.quipy.tables.EventRecordTable 9 | import ru.quipy.tables.EventStreamActiveReadersTable 10 | import ru.quipy.tables.EventStreamReadIndexTable 11 | import ru.quipy.tables.SnapshotTable 12 | 13 | open class BaseTest(private val testId: String) { 14 | @Autowired 15 | lateinit var mongoTemplate: MongoTemplate 16 | 17 | @Value("\${event.sourcing.db-schema:event_sourcing_store}") 18 | private lateinit var schema: String 19 | 20 | @Autowired 21 | private lateinit var databaseConnectionFactory: ru.quipy.db.factory.ConnectionFactory 22 | open fun cleanDatabase() { 23 | mongoTemplate.remove(Query.query(Criteria.where("aggregateId").`is`(testId)), "aggregate-project") 24 | mongoTemplate.remove(Query.query(Criteria.where("_id").`is`(testId)), "snapshots") 25 | 26 | databaseConnectionFactory.getDatabaseConnection().use { connection -> connection.createStatement().execute( 27 | "truncate ${schema}.${EventRecordTable.name};" + 28 | "truncate ${schema}.${SnapshotTable.name};" + 29 | "truncate ${schema}.${EventStreamReadIndexTable.name};" + 30 | "truncate ${schema}.${EventStreamActiveReadersTable.name};") 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/test/kotlin/ru.quipy/StreamEventOrderingTest.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy 2 | 3 | import org.awaitility.kotlin.await 4 | import org.junit.jupiter.api.BeforeEach 5 | import org.junit.jupiter.api.Test 6 | import org.springframework.beans.factory.annotation.Autowired 7 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration 8 | import org.springframework.boot.test.context.SpringBootTest 9 | import org.springframework.test.annotation.DirtiesContext 10 | import org.springframework.test.context.ActiveProfiles 11 | import org.springframework.test.context.ContextConfiguration 12 | import ru.quipy.config.DockerPostgresDataSourceInitializer 13 | import ru.quipy.core.EventSourcingProperties 14 | import ru.quipy.core.EventSourcingService 15 | import ru.quipy.projectDemo.api.ProjectAggregate 16 | import ru.quipy.projectDemo.api.TagCreatedEvent 17 | import ru.quipy.projectDemo.create 18 | import ru.quipy.projectDemo.createTag 19 | import ru.quipy.projectDemo.logic.ProjectAggregateState 20 | import ru.quipy.streams.AggregateSubscriptionsManager 21 | import java.util.concurrent.TimeUnit 22 | 23 | @SpringBootTest 24 | @ActiveProfiles("test") 25 | @ContextConfiguration( 26 | initializers = [DockerPostgresDataSourceInitializer::class]) 27 | @EnableAutoConfiguration 28 | @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) 29 | class StreamEventOrderingTest: BaseTest(testId) { 30 | companion object { 31 | const val testId = "StreamEventOrderingTest" 32 | } 33 | private val properties: EventSourcingProperties = EventSourcingProperties( 34 | streamBatchSize = 3 35 | ) 36 | @Autowired 37 | private lateinit var esService: EventSourcingService 38 | 39 | @Autowired 40 | private lateinit var subscriptionsManager: AggregateSubscriptionsManager 41 | 42 | private val sb = StringBuilder() 43 | 44 | @BeforeEach 45 | fun init() { 46 | cleanDatabase() 47 | } 48 | 49 | @Test 50 | fun testEventOrder() { 51 | esService.create { 52 | it.create(testId) 53 | } 54 | 55 | esService.update(testId) { 56 | it.createTag("1") 57 | } 58 | esService.update(testId) { 59 | it.createTag("2") 60 | } 61 | esService.update(testId) { 62 | it.createTag("3") 63 | } 64 | esService.update(testId) { 65 | it.createTag("4") 66 | } 67 | esService.update(testId) { 68 | it.createTag("5") 69 | } 70 | esService.update(testId) { 71 | it.createTag("6") 72 | } 73 | 74 | subscriptionsManager.createSubscriber(ProjectAggregate::class, "StreamEventOrderingTest") { 75 | `when`(TagCreatedEvent::class) { event -> 76 | sb.append(event.tagName).also { 77 | println(sb.toString()) 78 | } 79 | } 80 | } 81 | 82 | await.atMost(10, TimeUnit.MINUTES).until { 83 | sb.toString() == "123456" 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/test/kotlin/ru.quipy/config/DockerPostgresDataSourceInitialize.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.config 2 | 3 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase 4 | import org.springframework.context.ApplicationContextInitializer 5 | import org.springframework.context.ConfigurableApplicationContext 6 | import org.springframework.test.context.support.TestPropertySourceUtils 7 | import org.testcontainers.containers.PostgreSQLContainer 8 | import org.testcontainers.utility.DockerImageName 9 | 10 | @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) 11 | class DockerPostgresDataSourceInitializer : ApplicationContextInitializer { 12 | private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:14.9-alpine")).apply { 13 | withDatabaseName("tiny_es") 14 | withUsername("tiny_es") 15 | withPassword("tiny_es") 16 | } 17 | 18 | override fun initialize(configurableApplicationContext: ConfigurableApplicationContext) { 19 | postgresContainer.start() 20 | 21 | TestPropertySourceUtils.addInlinedPropertiesToEnvironment( 22 | configurableApplicationContext, 23 | "spring.datasource.hikari.jdbc-url=" + postgresContainer.jdbcUrl, 24 | "spring.datasource.hikari.username=" + postgresContainer.username, 25 | "spring.datasource.hikari.password=" + postgresContainer.password 26 | ) 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-app/src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | data: 3 | mongodb: 4 | host: localhost 5 | port: 27019 6 | database: tiny-es-test 7 | mongodb: 8 | embedded: 9 | version: 4.0.12 -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-boot-starter/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | ru.quipy 9 | tiny-event-sourcing 10 | 2.7.4 11 | 12 | 13 | tiny-event-sourcing-spring-boot-starter 14 | Tiny event sourcing spring boot starter 15 | tiny-event-sourcing-spring-boot-starter 16 | 2.7.4 17 | 18 | 19 | 17 20 | 17 21 | UTF-8 22 | 1.6.10 23 | 24 | 25 | 26 | 27 | 28 | org.springframework.boot 29 | spring-boot-dependencies 30 | 2.7.5 31 | pom 32 | import 33 | 34 | 35 | 36 | 37 | 38 | 39 | tiny-event-sourcing-lib 40 | ru.quipy 41 | ${project.version} 42 | 43 | 44 | 45 | org.springframework.boot 46 | spring-boot-starter 47 | 2.7.5 48 | 49 | 50 | 51 | 52 | src/main/kotlin 53 | ${project.basedir}/src/test/kotlin 54 | 55 | 56 | org.springframework.boot 57 | spring-boot-maven-plugin 58 | 59 | 60 | org.jetbrains.kotlin 61 | kotlin-maven-plugin 62 | ${kotlin.verion} 63 | 64 | 65 | compile 66 | compile 67 | 68 | compile 69 | 70 | 71 | 72 | 73 | 74 | -Xjsr305=strict 75 | 76 | 77 | spring 78 | 79 | 80 | 81 | 82 | org.jetbrains.kotlin 83 | kotlin-maven-allopen 84 | ${kotlin.verion} 85 | 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /tiny-event-sourcing-spring-boot-starter/src/main/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ 2 | ru.quipy.config.EventSourcingLibConfig -------------------------------------------------------------------------------- /tiny-mongo-event-store-spring-boot-starter/src/main/kotlin/ru/quipy/autoconfigure/MongoEventStoreAutoConfiguration.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.autoconfigure 2 | 3 | import org.springframework.boot.autoconfigure.condition.ConditionalOnBean 4 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.data.mongodb.MongoDatabaseFactory 8 | import org.springframework.data.mongodb.MongoTransactionManager 9 | import org.springframework.data.mongodb.core.MongoTemplate 10 | import ru.quipy.MongoTemplateEventStore 11 | import ru.quipy.database.EventStore 12 | import ru.quipy.eventstore.MongoClientEventStore 13 | import ru.quipy.eventstore.converter.JacksonMongoEntityConverter 14 | import ru.quipy.eventstore.factory.MongoClientFactory 15 | 16 | @Configuration 17 | class MongoEventStoreAutoConfiguration { 18 | @Bean("mongoTemplateEventStore") 19 | @ConditionalOnBean(MongoTemplate::class) 20 | @ConditionalOnMissingBean 21 | fun mongoTemplateEventStore(): EventStore = MongoTemplateEventStore() 22 | 23 | @Bean("mongoClientEventStore") 24 | @ConditionalOnBean(MongoClientFactory::class) 25 | @ConditionalOnMissingBean 26 | fun mongoClientEventStore(databaseFactory: MongoClientFactory): EventStore { 27 | return MongoClientEventStore(JacksonMongoEntityConverter(), databaseFactory) 28 | } 29 | 30 | @Bean 31 | @ConditionalOnBean(MongoTransactionManager::class) 32 | @ConditionalOnMissingBean 33 | fun transactionManager(dbFactory: MongoDatabaseFactory): MongoTransactionManager { 34 | return MongoTransactionManager(dbFactory) 35 | } 36 | } -------------------------------------------------------------------------------- /tiny-mongo-event-store-spring-boot-starter/src/main/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ 2 | ru.quipy.autoconfigure.MongoEventStoreAutoConfiguration 3 | spring.autoconfigure.exclude=\ 4 | org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration, \ 5 | org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration -------------------------------------------------------------------------------- /tiny-mongo-event-store-spring-boot-starter/src/test/kotlin/ru/quipy/updateSerial/TestAggregate.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.updateSerial 2 | 3 | import ru.quipy.core.annotations.AggregateType 4 | import ru.quipy.core.annotations.DomainEvent 5 | import ru.quipy.core.annotations.StateTransitionFunc 6 | import ru.quipy.domain.Aggregate 7 | import ru.quipy.domain.AggregateState 8 | import ru.quipy.domain.Event 9 | import ru.quipy.updateSerial.UpdateSerialTest.Companion.CREATED_EVENT_NAME 10 | import ru.quipy.updateSerial.UpdateSerialTest.Companion.TEST_EVENT_NAME_1 11 | import ru.quipy.updateSerial.UpdateSerialTest.Companion.TEST_EVENT_NAME_2 12 | import ru.quipy.updateSerial.UpdateSerialTest.Companion.TEST_TABLE_NAME 13 | import java.util.* 14 | 15 | @AggregateType(aggregateEventsTableName = TEST_TABLE_NAME) 16 | class TestAggregate : Aggregate 17 | 18 | class TestAggregateState : AggregateState { 19 | private lateinit var id: UUID 20 | private var order: Int = 0 21 | 22 | override fun getId() = id 23 | 24 | fun create(id: UUID) = TestCreatedEvent(id) 25 | 26 | @StateTransitionFunc 27 | fun create(event: TestCreatedEvent) { 28 | id = event.testId 29 | } 30 | 31 | /** 32 | * This command generates the list of different type of events like it can be in a real world 33 | */ 34 | fun testUpdateSerial(size: Int) = List(size) {index -> 35 | when (index % 2) { 36 | 0 -> TestEvent_1(order + index + 1) 37 | else -> TestEvent_2(order + index + 1) 38 | } 39 | } 40 | 41 | @StateTransitionFunc 42 | fun testUpdateSerial(event: TestEvent_1) { 43 | order = event.order 44 | } 45 | 46 | @StateTransitionFunc 47 | fun testUpdateSerial(event: TestEvent_2) { 48 | order = event.order 49 | } 50 | } 51 | 52 | @DomainEvent(name = CREATED_EVENT_NAME) 53 | class TestCreatedEvent( 54 | val testId: UUID, 55 | ) : Event( 56 | name = CREATED_EVENT_NAME, 57 | createdAt = System.currentTimeMillis(), 58 | ) 59 | 60 | @DomainEvent(name = TEST_EVENT_NAME_1) 61 | class TestEvent_1( 62 | val order: Int 63 | ) : Event( 64 | name = TEST_EVENT_NAME_1, 65 | createdAt = System.currentTimeMillis(), 66 | ) 67 | 68 | @DomainEvent(name = TEST_EVENT_NAME_2) 69 | class TestEvent_2( 70 | val order: Int 71 | ) : Event( 72 | name = TEST_EVENT_NAME_2, 73 | createdAt = System.currentTimeMillis(), 74 | ) -------------------------------------------------------------------------------- /tiny-mongo-event-store-spring-boot-starter/src/test/kotlin/ru/quipy/updateSerial/UpdateSerialTestConfiguration.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.updateSerial 2 | 3 | import com.mongodb.client.MongoClient 4 | import org.springframework.beans.factory.annotation.Qualifier 5 | import org.springframework.beans.factory.annotation.Value 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import org.springframework.context.annotation.Primary 9 | import org.springframework.test.context.ActiveProfiles 10 | import ru.quipy.MongoTemplateEventStore 11 | import ru.quipy.core.AggregateRegistry 12 | import ru.quipy.core.EventSourcingProperties 13 | import ru.quipy.core.EventSourcingService 14 | import ru.quipy.core.EventSourcingServiceFactory 15 | import ru.quipy.database.EventStore 16 | import ru.quipy.eventstore.MongoClientEventStore 17 | import ru.quipy.eventstore.converter.JacksonMongoEntityConverter 18 | import ru.quipy.eventstore.factory.MongoClientFactoryImpl 19 | import ru.quipy.mapper.JsonEventMapper 20 | import java.util.* 21 | 22 | @Configuration 23 | @ActiveProfiles("test") 24 | class UpdateSerialTestConfiguration { 25 | @Bean 26 | fun service( 27 | eventSourcingServiceFactory: EventSourcingServiceFactory 28 | ): EventSourcingService = eventSourcingServiceFactory.create() 29 | 30 | @Bean 31 | @Primary 32 | fun mongoTemplateEventStore(): EventStore = MongoTemplateEventStore() 33 | 34 | @Value("\${spring.data.mongodb.database}") 35 | private lateinit var databaseName: String 36 | 37 | @Bean 38 | fun mongoClientEventStore(mongoClient: MongoClient): EventStore = MongoClientEventStore( 39 | JacksonMongoEntityConverter(), 40 | MongoClientFactoryImpl(mongoClient, databaseName) 41 | ) 42 | 43 | @Bean 44 | @Qualifier("service-with-mongo-template") 45 | fun serviceWithMongoTemplateEventStore( 46 | eventSourcingProperties: EventSourcingProperties, 47 | aggregateRegistry: AggregateRegistry, 48 | eventMapper: JsonEventMapper, 49 | ): EventSourcingService { 50 | return EventSourcingServiceFactory( 51 | aggregateRegistry, eventMapper, mongoTemplateEventStore(), eventSourcingProperties 52 | ).create() 53 | } 54 | 55 | @Bean 56 | @Qualifier("service-with-mongo-client") 57 | fun serviceWithMongoClientEventStore( 58 | eventSourcingProperties: EventSourcingProperties, 59 | aggregateRegistry: AggregateRegistry, 60 | eventMapper: JsonEventMapper, 61 | mongoClient: MongoClient 62 | ): EventSourcingService { 63 | return EventSourcingServiceFactory( 64 | aggregateRegistry, eventMapper, mongoClientEventStore(mongoClient), eventSourcingProperties 65 | ).create() 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /tiny-mongo-event-store-spring-boot-starter/src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | event: 2 | sourcing: 3 | auto-scan-enabled: true 4 | scan-package: ru.quipy.updateSerial 5 | spin-lock-max-attempts: 11000 6 | snapshot-frequency: 10000 7 | spring: 8 | data: 9 | mongodb: 10 | host: localhost 11 | port: 27018 12 | database: tiny-es-test 13 | mongodb: 14 | embedded: 15 | version: 4.0.12 -------------------------------------------------------------------------------- /tiny-mongo-event-store/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | jar 7 | 8 | 9 | ru.quipy 10 | tiny-event-sourcing 11 | 2.7.4 12 | 13 | 14 | tiny-mongo-event-store 15 | Tiny event sourcing mongodb event-store operations 16 | tiny-mongo-event-store 17 | 18 | 19 | 11 20 | 2.6.6 21 | 1.6.21 22 | 1.5.2 23 | 4.2.0 24 | true 25 | 26 | 27 | 28 | 29 | tiny-event-sourcing-lib 30 | ru.quipy 31 | ${project.version} 32 | 33 | 34 | 35 | org.mongodb 36 | mongodb-driver-sync 37 | 4.7.1 38 | 39 | 40 | 41 | 42 | src/main/kotlin 43 | ${project.basedir}/src/test/kotlin 44 | 45 | 46 | org.jetbrains.kotlin 47 | kotlin-maven-plugin 48 | ${kotlin.version} 49 | 50 | 51 | compile 52 | compile 53 | 54 | compile 55 | 56 | 57 | 58 | 59 | 60 | -Xjsr305=strict 61 | 62 | 63 | spring 64 | 65 | 66 | 67 | 68 | org.jetbrains.kotlin 69 | kotlin-maven-allopen 70 | ${kotlin.version} 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /tiny-mongo-event-store/src/main/kotlin/ru/quipy/eventstore/converter/BsonConverter.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.eventstore.converter 2 | 3 | interface BsonConverter { 4 | fun convertToBsonType(value: Any): T? 5 | fun convertFromBsonType(value: Any): V? 6 | } -------------------------------------------------------------------------------- /tiny-mongo-event-store/src/main/kotlin/ru/quipy/eventstore/converter/JacksonMongoEntityConverter.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.eventstore.converter 2 | 3 | import com.fasterxml.jackson.annotation.JsonAutoDetect 4 | import com.fasterxml.jackson.annotation.JsonTypeInfo 5 | import com.fasterxml.jackson.annotation.PropertyAccessor 6 | import com.fasterxml.jackson.databind.MapperFeature 7 | import com.fasterxml.jackson.databind.ObjectMapper 8 | import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator 9 | import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator 10 | import com.fasterxml.jackson.databind.jsontype.TypeResolverBuilder 11 | import com.fasterxml.jackson.module.kotlin.KotlinModule 12 | import org.bson.Document 13 | import kotlin.reflect.KClass 14 | 15 | 16 | private const val TYPE_PROPERTY_KEY = "_class" 17 | private const val BASE_TYPE_PREFIX = "java" 18 | private const val EXPECTED_ID_KEY = "id" 19 | private const val TARGET_ID_KEY = "_id" 20 | 21 | class JacksonMongoEntityConverter : MongoEntityConverter { 22 | 23 | private val objectMapper: ObjectMapper = initMapper() 24 | 25 | private fun initMapper(): ObjectMapper { 26 | val mapper = ObjectMapper() 27 | mapper.configure(MapperFeature.REQUIRE_SETTERS_FOR_GETTERS, true) 28 | mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) 29 | 30 | val polymorphicTypeValidator: PolymorphicTypeValidator = BasicPolymorphicTypeValidator.builder() 31 | .allowIfBaseType(BASE_TYPE_PREFIX) 32 | .build() 33 | val typeResolver: TypeResolverBuilder<*> = ObjectMapper.DefaultTypeResolverBuilder( 34 | ObjectMapper.DefaultTyping.JAVA_LANG_OBJECT, 35 | polymorphicTypeValidator 36 | ) 37 | typeResolver.init(JsonTypeInfo.Id.CLASS, null) 38 | typeResolver.inclusion(JsonTypeInfo.As.PROPERTY) 39 | typeResolver.typeProperty(TYPE_PROPERTY_KEY) 40 | 41 | mapper.setDefaultTyping(typeResolver) 42 | mapper.registerModule(KotlinModule()) 43 | 44 | return mapper 45 | } 46 | 47 | private val converters: List> = listOf( 48 | UuidConverter() 49 | ) 50 | 51 | override fun convertObjectToBsonDocument(obj: T): Document { 52 | val document = Document.parse(objectMapper.writeValueAsString(obj)) 53 | if (document.containsKey(EXPECTED_ID_KEY)) { 54 | document[TARGET_ID_KEY] = document.remove(EXPECTED_ID_KEY) 55 | } 56 | documentBypass(document) { converter, value -> 57 | converter.convertToBsonType(value) 58 | } 59 | 60 | return document 61 | } 62 | 63 | override fun convertBsonDocumentToObject(document: Document, clazz: KClass): T { 64 | if (document.containsKey(TARGET_ID_KEY)) { 65 | document[EXPECTED_ID_KEY] = document.remove(TARGET_ID_KEY) 66 | } 67 | documentBypass(document) { converter, value -> 68 | converter.convertFromBsonType(value) 69 | } 70 | return objectMapper.readValue(document.toJson(), clazz.java) 71 | } 72 | 73 | private fun documentBypass(document: Document, handler: (converter: BsonConverter<*, *>, value: Any) -> Any?) { 74 | document.forEach { 75 | if (it.value is Document) { 76 | documentBypass(it.value as Document, handler) 77 | } else { 78 | for (converter in converters) { 79 | val newValue = handler(converter, it.value) 80 | if (newValue != null) { 81 | document[it.key] = newValue 82 | break 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | } -------------------------------------------------------------------------------- /tiny-mongo-event-store/src/main/kotlin/ru/quipy/eventstore/converter/MongoEntityConverter.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.eventstore.converter 2 | 3 | import org.bson.Document 4 | import kotlin.reflect.KClass 5 | 6 | interface MongoEntityConverter { 7 | fun convertObjectToBsonDocument(obj: T): Document 8 | fun convertBsonDocumentToObject(document: Document, clazz: KClass): T 9 | } -------------------------------------------------------------------------------- /tiny-mongo-event-store/src/main/kotlin/ru/quipy/eventstore/converter/UuidConverter.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.eventstore.converter 2 | 3 | import java.util.UUID 4 | 5 | class UuidConverter : BsonConverter> { 6 | 7 | override fun convertFromBsonType(value: Any): List? { 8 | if (value !is UUID) return null 9 | return listOf( 10 | UUID::class.qualifiedName, 11 | value.toString() 12 | ) 13 | } 14 | 15 | override fun convertToBsonType(value: Any): UUID? { 16 | if (value !is List<*> 17 | || value.size != 2 18 | || value[1] !is String 19 | ) return null 20 | if (UUID::class.qualifiedName != value[0]) return null 21 | return UUID.fromString(value[1] as String) 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /tiny-mongo-event-store/src/main/kotlin/ru/quipy/eventstore/exception/MongoClientException.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.eventstore.exception 2 | 3 | open class MongoClientException( 4 | override val message: String?, 5 | override val cause: Throwable? 6 | ) : RuntimeException( 7 | message, cause 8 | ) 9 | -------------------------------------------------------------------------------- /tiny-mongo-event-store/src/main/kotlin/ru/quipy/eventstore/exception/MongoClientExceptionTranslator.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.eventstore.exception 2 | 3 | import com.mongodb.* 4 | 5 | class MongoClientExceptionTranslator { 6 | 7 | private fun translateException(ex: Exception): MongoClientException? { 8 | when (ex) { 9 | is DuplicateKeyException -> { 10 | return MongoDuplicateKeyException(ex.message, ex) 11 | } 12 | is MongoWriteException -> { 13 | if (ErrorCategory.fromErrorCode(ex.code) == ErrorCategory.DUPLICATE_KEY) { 14 | return MongoDuplicateKeyException(ex.message, ex) 15 | } 16 | } 17 | is MongoBulkWriteException -> { 18 | for (x in ex.writeErrors) { 19 | if (x.code == 11000) { 20 | return MongoDuplicateKeyException(ex.message, ex) 21 | } 22 | } 23 | } 24 | is MongoServerException -> { 25 | if (ex.code == 11000) { 26 | return MongoDuplicateKeyException(ex.message, ex) 27 | } 28 | } 29 | } 30 | return null 31 | } 32 | 33 | fun withTranslation(action: () -> T): T { 34 | try { 35 | return action() 36 | } catch (ex: Exception) { 37 | throw this.translateException(ex) ?: ex 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /tiny-mongo-event-store/src/main/kotlin/ru/quipy/eventstore/exception/MongoDuplicateKeyException.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.eventstore.exception 2 | 3 | open class MongoDuplicateKeyException( 4 | override val message: String?, 5 | override val cause: Throwable? 6 | ) : MongoClientException( 7 | message, cause 8 | ) { 9 | constructor(cause: Throwable?) : this(null, cause) 10 | } -------------------------------------------------------------------------------- /tiny-mongo-event-store/src/main/kotlin/ru/quipy/eventstore/factory/MongoClientFactory.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.eventstore.factory 2 | 3 | import com.mongodb.client.MongoClient 4 | import com.mongodb.client.MongoDatabase 5 | 6 | interface MongoClientFactory { 7 | fun getDatabase(): MongoDatabase 8 | fun getClient(): MongoClient 9 | } -------------------------------------------------------------------------------- /tiny-mongo-event-store/src/main/kotlin/ru/quipy/eventstore/factory/MongoClientFactoryImpl.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.eventstore.factory 2 | 3 | import com.mongodb.ConnectionString 4 | import com.mongodb.MongoClientSettings 5 | import com.mongodb.client.MongoClient 6 | import com.mongodb.client.MongoClients 7 | import com.mongodb.client.MongoDatabase 8 | import org.bson.UuidRepresentation 9 | 10 | class MongoClientFactoryImpl : MongoClientFactory { 11 | private val client: MongoClient 12 | private val databaseName: String 13 | 14 | constructor(databaseName: String) : this("mongodb://localhost", databaseName) 15 | 16 | constructor(connectionString: String, databaseName: String) 17 | : this(ConnectionString(connectionString), databaseName) 18 | 19 | constructor(connectionString: ConnectionString, databaseName: String) { 20 | this.client = MongoClients.create( 21 | MongoClientSettings.builder() 22 | .uuidRepresentation(UuidRepresentation.JAVA_LEGACY) 23 | .applyConnectionString(connectionString) 24 | .build() 25 | ) 26 | this.databaseName = databaseName 27 | } 28 | 29 | constructor(mongoClient: MongoClient, databaseName: String) { 30 | this.client = mongoClient 31 | this.databaseName = databaseName 32 | } 33 | 34 | override fun getDatabase(): MongoDatabase { 35 | return client.getDatabase(databaseName) 36 | } 37 | 38 | 39 | override fun getClient(): MongoClient { 40 | return this.client 41 | } 42 | } -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/main/kotlin/ru/quipy/config/DatabaseConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.config 2 | 3 | import com.zaxxer.hikari.HikariConfig 4 | import com.zaxxer.hikari.HikariDataSource 5 | import org.springframework.beans.factory.annotation.Value 6 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 7 | import org.springframework.context.annotation.Bean 8 | import org.springframework.context.annotation.Configuration 9 | import org.springframework.context.annotation.Primary 10 | 11 | @Configuration 12 | class DatabaseConfig { 13 | @Bean 14 | @Primary 15 | @ConditionalOnProperty("spring.datasource.hikari.jdbc-url") 16 | fun dataSource(@Value("\${spring.datasource.hikari.jdbc-url}") databaseUrl: String, 17 | @Value("\${spring.datasource.hikari.username:}") username: String, 18 | @Value("\${spring.datasource.hikari.password:}") password: String, 19 | @Value("\${spring.datasource.hikari.idleTimeout:30000}") idleTimeout: Long, 20 | @Value("\${spring.datasource.hikari.maximumPoolSize:20}") maxPoolSize: Int): HikariDataSource { 21 | val hikariConfig = HikariConfig() 22 | hikariConfig.maximumPoolSize = maxPoolSize 23 | hikariConfig.idleTimeout = idleTimeout 24 | hikariConfig.jdbcUrl = databaseUrl 25 | hikariConfig.username = username 26 | hikariConfig.password = password 27 | 28 | return HikariDataSource(hikariConfig) 29 | } 30 | } -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/main/kotlin/ru/quipy/config/LiquibaseSpringConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.config 2 | 3 | import liquibase.integration.spring.SpringLiquibase 4 | import org.springframework.beans.factory.annotation.Value 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.context.annotation.Primary 8 | import java.sql.SQLException 9 | import javax.sql.DataSource 10 | 11 | @Configuration 12 | class LiquibaseSpringConfig { 13 | 14 | @Bean 15 | @Primary 16 | fun liquibase(dataSource: DataSource, 17 | @Value("\${event.sourcing.db-schema:event_sourcing_store}") schema: String): SpringLiquibase { 18 | try { 19 | dataSource.connection.use { connection -> 20 | connection.createStatement() 21 | .execute("CREATE SCHEMA IF NOT EXISTS $schema;") 22 | } 23 | } catch (e: SQLException) { 24 | throw RuntimeException(e) 25 | } 26 | val liquibase = SpringLiquibase() 27 | liquibase.resourceLoader = LiquibaseConfig().getResourceReader() 28 | liquibase.liquibaseSchema = schema 29 | liquibase.defaultSchema = schema 30 | liquibase.changeLog = "classpath:liquibase/changelog.sql" 31 | liquibase.dataSource = dataSource 32 | liquibase.setChangeLogParameters( 33 | mapOf( 34 | Pair("schema", schema) 35 | ) 36 | ) 37 | return liquibase 38 | } 39 | } -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/main/kotlin/ru/quipy/mappers/ActiveEventStreamReaderRowMapper.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.mappers 2 | 3 | import ru.quipy.converter.ResultSetToEntityMapper 4 | import org.springframework.jdbc.core.RowMapper 5 | import ru.quipy.domain.ActiveEventStreamReader 6 | import java.sql.ResultSet 7 | 8 | class ActiveEventStreamReaderRowMapper(private val entityMapper: ResultSetToEntityMapper) : RowMapper { 9 | override fun mapRow(rs: ResultSet, rowNum: Int): ActiveEventStreamReader? { 10 | return entityMapper.convert(rs, ActiveEventStreamReader::class, false) 11 | } 12 | } -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/main/kotlin/ru/quipy/mappers/EventRowMapper.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.mappers 2 | 3 | import ru.quipy.converter.ResultSetToEntityMapper 4 | import org.springframework.jdbc.core.RowMapper 5 | import ru.quipy.domain.EventRecord 6 | import java.sql.ResultSet 7 | 8 | class EventRowMapper(private val entityMapper: ResultSetToEntityMapper) : RowMapper { 9 | override fun mapRow(rs: ResultSet, rowNum: Int): EventRecord? { 10 | return entityMapper.convert(rs, EventRecord::class, false) 11 | } 12 | } -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/main/kotlin/ru/quipy/mappers/EventStreamReadIndexRowMapper.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.mappers 2 | 3 | import ru.quipy.converter.ResultSetToEntityMapper 4 | import org.springframework.jdbc.core.RowMapper 5 | import ru.quipy.domain.EventStreamReadIndex 6 | import java.sql.ResultSet 7 | 8 | class EventStreamReadIndexRowMapper(private val entityMapper: ResultSetToEntityMapper) : RowMapper { 9 | override fun mapRow(rs: ResultSet, rowNum: Int): EventStreamReadIndex? { 10 | return entityMapper.convert(rs, EventStreamReadIndex::class, false) 11 | } 12 | } -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/main/kotlin/ru/quipy/mappers/MapperFactory.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.mappers 2 | 3 | import org.springframework.jdbc.core.RowMapper 4 | import kotlin.reflect.KClass 5 | 6 | interface MapperFactory { 7 | fun getMapper(clazz: KClass): RowMapper 8 | } -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/main/kotlin/ru/quipy/mappers/MapperFactoryImpl.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.mappers 2 | 3 | import ru.quipy.converter.ResultSetToEntityMapper 4 | import org.springframework.jdbc.core.RowMapper 5 | import ru.quipy.domain.ActiveEventStreamReader 6 | import ru.quipy.domain.EventRecord 7 | import ru.quipy.domain.EventStreamReadIndex 8 | import ru.quipy.domain.Snapshot 9 | import kotlin.reflect.KClass 10 | 11 | class MapperFactoryImpl(private val entityMapper: ResultSetToEntityMapper) : MapperFactory { 12 | override fun getMapper(clazz: KClass): RowMapper { 13 | return when (clazz) { 14 | EventRecord::class -> EventRowMapper(entityMapper) 15 | Snapshot::class -> SnapshotRowMapper(entityMapper) 16 | EventStreamReadIndex::class -> EventStreamReadIndexRowMapper(entityMapper) 17 | ActiveEventStreamReader::class -> ActiveEventStreamReaderRowMapper(entityMapper) 18 | else -> throw IllegalStateException("No mapper defined for entity ${clazz.simpleName}") 19 | } as RowMapper 20 | } 21 | } -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/main/kotlin/ru/quipy/mappers/SnapshotRowMapper.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.mappers 2 | 3 | import ru.quipy.converter.ResultSetToEntityMapper 4 | import org.springframework.jdbc.core.RowMapper 5 | import ru.quipy.domain.Snapshot 6 | import java.sql.ResultSet 7 | 8 | class SnapshotRowMapper(private val entityMapper: ResultSetToEntityMapper) : RowMapper { 9 | override fun mapRow(rs: ResultSet, rowNum: Int): Snapshot? { 10 | return entityMapper.convert(rs, Snapshot::class, false) 11 | } 12 | } -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/main/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ 2 | ru.quipy.autoconfigure.PostgresEventStoreAutoConfiguration 3 | spring.autoconfigure.exclude=\ 4 | org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.liquibase.change-log=classpath:liquibase/changelog.sql 2 | 3 | spring.datasource.hikari.driver-class-name=org.postgresql.Driver 4 | spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/main/resources/database/.env: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=tiny_es 2 | POSTGRES_USER=tiny_es 3 | POSTGRES_PASSWORD=tiny_es 4 | POSTGRES_DB_PORT=5432 -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/main/resources/database/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | db: 4 | hostname: event_sourcing_db 5 | image: postgres:14.9-alpine 6 | env_file: 7 | - ./.env 8 | environment: 9 | POSTGRES_DB: ${POSTGRES_DB} 10 | POSTGRES_USER: ${POSTGRES_USER} 11 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 12 | ports: 13 | - ${POSTGRES_DB_PORT}:${POSTGRES_DB_PORT} 14 | restart: unless-stopped 15 | -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/test/kotlin/ru/quipy/config/TestDbConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.config 2 | 3 | import com.zaxxer.hikari.HikariConfig 4 | import com.zaxxer.hikari.HikariDataSource 5 | import org.springframework.beans.factory.annotation.Value 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import org.testcontainers.containers.PostgreSQLContainer 9 | import org.testcontainers.utility.DockerImageName 10 | 11 | @Configuration 12 | class TestDbConfig { 13 | @Bean 14 | fun dataSource( 15 | @Value("\${jdbc.dbName:}") dbName: String, 16 | @Value("\${jdbc.username:}") username: String, 17 | @Value("\${jdbc.password:}") password: String, 18 | @Value("\${schema:event_sourcing_store}") schema: String) 19 | : HikariDataSource { 20 | val container = PostgreSQLContainer(DockerImageName.parse("postgres:14.9-alpine")).apply { 21 | withDatabaseName(dbName) 22 | withUsername(username) 23 | withPassword(password) 24 | } 25 | if (!container.isRunning) { 26 | container.start() 27 | } 28 | val hikariConfig = HikariConfig() 29 | hikariConfig.maximumPoolSize = 20 30 | hikariConfig.idleTimeout = 30000 31 | hikariConfig.jdbcUrl = container.jdbcUrl 32 | hikariConfig.username = username 33 | hikariConfig.password = password 34 | 35 | return HikariDataSource(hikariConfig) 36 | } 37 | } -------------------------------------------------------------------------------- /tiny-postgres-event-store-spring-boot-starter/src/test/resources/application.properties: -------------------------------------------------------------------------------- 1 | jdbc.dbName=tiny_es 2 | jdbc.username=tiny_es 3 | jdbc.password=tiny_es 4 | 5 | event.sourcing.db-schema=event_sourcing_store 6 | -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/config/LiquibaseConfig.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.config 2 | 3 | import liquibase.Liquibase 4 | import liquibase.database.jvm.JdbcConnection 5 | import liquibase.resource.FileSystemResourceAccessor 6 | import org.springframework.core.io.FileSystemResourceLoader 7 | import java.io.File 8 | import java.sql.SQLException 9 | import javax.sql.DataSource 10 | 11 | class LiquibaseConfig { 12 | fun liquibase(dataSource: DataSource, schema: String): Liquibase { 13 | try { 14 | dataSource.connection.use { connection -> 15 | connection.createStatement() 16 | .execute("CREATE SCHEMA IF NOT EXISTS $schema;") 17 | } 18 | } catch (e: SQLException) { 19 | throw RuntimeException(e) 20 | } 21 | dataSource.connection.use { connection -> 22 | val databaseConnection = JdbcConnection(connection) 23 | val chageset = LiquibaseConfig::class.java.classLoader.getResource("liquibase").path 24 | val fileSystemResourceAccessor = FileSystemResourceAccessor(File(chageset)) 25 | val liquibase = Liquibase("changelog.sql", fileSystemResourceAccessor, databaseConnection) 26 | liquibase.setChangeLogParameter("schema", schema) 27 | liquibase.update("") 28 | return liquibase 29 | } 30 | } 31 | 32 | fun getResourceReader() : FileSystemResourceLoader { 33 | return FileSystemResourceLoader() 34 | } 35 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/converter/EntityConverter.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.converter 2 | 3 | import kotlin.reflect.KClass 4 | 5 | interface EntityConverter { 6 | fun serialize(obj: T): String 7 | fun toObject(converted: String, clazz: KClass): T 8 | fun toNullableObject(converted: String, clazz: KClass): T? 9 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/converter/JsonEntityConverter.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.converter 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import kotlin.reflect.KClass 5 | 6 | class JsonEntityConverter(private val objectMapper: ObjectMapper) : EntityConverter { 7 | override fun serialize(obj: T): String { 8 | return objectMapper.writeValueAsString(obj) 9 | } 10 | 11 | override fun toObject(converted: String, clazz: KClass): T { 12 | return objectMapper.readValue(converted, clazz.java) 13 | } 14 | 15 | override fun toNullableObject(converted: String, clazz: KClass): T? { 16 | return objectMapper.readValue(converted, clazz.java) 17 | } 18 | 19 | 20 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/converter/ResultSetToEntityMapper.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.converter 2 | 3 | import java.sql.ResultSet 4 | import kotlin.reflect.KClass 5 | 6 | interface ResultSetToEntityMapper { 7 | fun convert(resultSet: ResultSet?, clazz: KClass, scroll: Boolean = true) : T? 8 | fun convertMany(resultSet: ResultSet?, clazz: KClass) : List 9 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/converter/ResultSetToEntityMapperImpl.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.converter 2 | 3 | import ru.quipy.converter.exception.NoMapperForClass 4 | import ru.quipy.domain.ActiveEventStreamReader 5 | import ru.quipy.domain.EventRecord 6 | import ru.quipy.domain.EventStreamReadIndex 7 | import ru.quipy.domain.Snapshot 8 | import ru.quipy.saga.SagaContext 9 | import ru.quipy.tables.EventRecordTable 10 | import ru.quipy.tables.EventStreamActiveReadersTable 11 | import ru.quipy.tables.EventStreamReadIndexTable 12 | import ru.quipy.tables.SnapshotTable 13 | import java.sql.ResultSet 14 | import kotlin.reflect.KClass 15 | 16 | @Suppress("UNCHECKED_CAST") 17 | class ResultSetToEntityMapperImpl(private val entityConverter: EntityConverter) : ResultSetToEntityMapper { 18 | override fun convert(resultSet: ResultSet?, clazz: KClass, scroll: Boolean) : T? { 19 | resultSet ?: return null 20 | if (scroll && !resultSet.next()) return null 21 | return when(clazz) { 22 | EventRecord::class -> mapToEventRecord(resultSet) 23 | Snapshot::class -> mapToSnapshot(resultSet) 24 | EventStreamReadIndex::class -> mapToEventStreamReader(resultSet) 25 | ActiveEventStreamReader::class -> mapToActiveEventStreamReader(resultSet) 26 | else -> throw NoMapperForClass(clazz.simpleName) 27 | } as T 28 | } 29 | 30 | override fun convertMany(resultSet: ResultSet?, clazz: KClass) : List { 31 | resultSet ?: return listOf() 32 | val result = mutableListOf() 33 | var res: T?; 34 | do { 35 | res = convert(resultSet, clazz) 36 | if (res != null) result.add(res) 37 | } while (res != null) 38 | 39 | return result 40 | } 41 | private fun mapToEventRecord(resultSet: ResultSet): EventRecord { 42 | return EventRecord( 43 | resultSet.getString(EventRecordTable.id.index), 44 | resultSet.getString(EventRecordTable.aggregateId.index), 45 | resultSet.getLong(EventRecordTable.aggregateVersion.index), 46 | resultSet.getString(EventRecordTable.eventTitle.index), 47 | resultSet.getString(EventRecordTable.payload.index), 48 | entityConverter.toNullableObject(resultSet.getString(EventRecordTable.sagaContext.index), SagaContext::class), 49 | resultSet.getLong(EventRecordTable.createdAt.index) 50 | ) 51 | } 52 | 53 | private fun mapToSnapshot(resultSet: ResultSet) : Snapshot { 54 | return Snapshot( 55 | resultSet.getString(SnapshotTable.id.index), 56 | entityConverter.toObject(resultSet.getString(SnapshotTable.snapshot.index), 57 | Class.forName(resultSet.getString(SnapshotTable.aggregateStateClassName.index)).kotlin), 58 | resultSet.getLong(SnapshotTable.version.index) 59 | ) 60 | } 61 | 62 | private fun mapToEventStreamReader(resultSet: ResultSet) : EventStreamReadIndex { 63 | return EventStreamReadIndex( 64 | resultSet.getString(EventStreamReadIndexTable.id.index), 65 | resultSet.getLong(EventStreamReadIndexTable.readIndex.index), 66 | resultSet.getLong(EventStreamReadIndexTable.version.index) 67 | ) 68 | } 69 | 70 | private fun mapToActiveEventStreamReader(resultSet: ResultSet) : ActiveEventStreamReader { 71 | return ActiveEventStreamReader( 72 | resultSet.getString(EventStreamActiveReadersTable.id.index), 73 | resultSet.getLong(EventStreamActiveReadersTable.version.index), 74 | resultSet.getString(EventStreamActiveReadersTable.readerId.index), 75 | resultSet.getLong(EventStreamActiveReadersTable.readPosition.index), 76 | resultSet.getLong(EventStreamActiveReadersTable.lastInteraction.index) 77 | ) 78 | } 79 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/converter/exception/NoMapperForClass.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.converter.exception 2 | 3 | class NoMapperForClass(message: String?): Exception(message) -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/db/DataSourceProvider.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.db 2 | 3 | import javax.sql.DataSource 4 | 5 | interface DataSourceProvider { 6 | fun dataSource() : DataSource 7 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/db/DatasourceProviderImpl.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.db 2 | 3 | import javax.sql.DataSource 4 | 5 | class DatasourceProviderImpl(private val dataSource: DataSource) : DataSourceProvider { 6 | override fun dataSource(): DataSource { 7 | return dataSource 8 | } 9 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/db/HikariDatasourceProvider.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.db 2 | 3 | import com.zaxxer.hikari.HikariDataSource 4 | 5 | class HikariDatasourceProvider(private val datasource: HikariDataSource) : DataSourceProvider { 6 | override fun dataSource(): HikariDataSource { 7 | return datasource 8 | } 9 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/db/factory/ConnectionFactory.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.db.factory 2 | 3 | import java.sql.Connection 4 | 5 | interface ConnectionFactory { 6 | fun getDatabaseConnection() : Connection 7 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/db/factory/DataSourceConnectionFactoryImpl.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.db.factory 2 | 3 | import ru.quipy.db.DataSourceProvider 4 | import java.sql.Connection 5 | 6 | class DataSourceConnectionFactoryImpl(private val dataSourceProvider: DataSourceProvider) : ConnectionFactory { 7 | override fun getDatabaseConnection(): Connection { 8 | return this.dataSourceProvider.dataSource().connection 9 | } 10 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/exception/UnknownEntityClassException.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.exception 2 | 3 | class UnknownEntityClassException(message: String?) : Exception(message) -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/executor/ExceptionLoggingSqlQueriesExecutor.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.executor 2 | 3 | import org.slf4j.Logger 4 | import ru.quipy.db.factory.ConnectionFactory 5 | import ru.quipy.query.BasicQuery 6 | import ru.quipy.query.Query 7 | import ru.quipy.query.insert.BatchInsertQuery 8 | import java.sql.ResultSet 9 | import java.sql.SQLException 10 | 11 | open class ExceptionLoggingSqlQueriesExecutor( 12 | private val connectionFactory: ConnectionFactory, 13 | private val logger: Logger) : QueryExecutor { 14 | override fun executeReturningBoolean(query: BasicQuery): Boolean { 15 | return try { 16 | executeDependingOnQueryType(query) 17 | true 18 | } catch (ex: SQLException) { 19 | logger.error(ex.message) 20 | false 21 | } 22 | } 23 | 24 | override fun execute(query: BasicQuery) { 25 | executeDependingOnQueryType(query) 26 | } 27 | 28 | override fun executeAndProcessResultSet(query: BasicQuery, 29 | processFunction: (ResultSet?) -> E?): E? { 30 | if (query is BatchInsertQuery) { 31 | throw UnsupportedOperationException("Cannot return result set executing batch insert") 32 | } 33 | return connectionFactory.getDatabaseConnection().use { connection -> 34 | processFunction( 35 | connection.prepareStatement(query.build()).executeQuery() 36 | ) 37 | } 38 | } 39 | 40 | open fun executeDependingOnQueryType(query: BasicQuery) { 41 | when (query) { 42 | is BatchInsertQuery -> executeBatchInsert(query) 43 | else -> { 44 | connectionFactory.getDatabaseConnection().use { connection -> 45 | connection.prepareStatement(query.build()) 46 | .execute() 47 | } 48 | } 49 | } 50 | } 51 | 52 | open fun executeBatchInsert(query: BatchInsertQuery) { 53 | var sqls = query.build().split("\n") 54 | connectionFactory.getDatabaseConnection().use { connection -> 55 | val prepared = connection.createStatement() 56 | for (sql in sqls) { 57 | prepared.addBatch(sql) 58 | } 59 | prepared.executeBatch() 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/executor/QueryExecutor.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.executor 2 | 3 | import ru.quipy.query.BasicQuery 4 | import ru.quipy.query.Query 5 | import java.sql.ResultSet 6 | 7 | interface QueryExecutor { 8 | fun execute(query: BasicQuery) 9 | fun executeAndProcessResultSet(query: BasicQuery, processFunction: (ResultSet?) -> E?): E? 10 | fun executeReturningBoolean(query: BasicQuery): Boolean 11 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/query/BasicQuery.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.query 2 | 3 | import ru.quipy.query.exception.InvalidQueryStateException 4 | import java.sql.PreparedStatement 5 | 6 | abstract class BasicQuery(protected val schema: String, protected val relation: String) : Query { 7 | protected val columns: MutableList = mutableListOf() 8 | protected val values: MutableList = mutableListOf() 9 | protected val conditions = mutableListOf() 10 | protected var returnEntity: Boolean = false 11 | open fun withColumns(vararg columns: String): T { 12 | this.columns.clear() 13 | for (t in columns) 14 | this.columns.add(t) 15 | return this as T 16 | } 17 | 18 | open fun withValues(vararg values: Any): T { 19 | this.values.clear() 20 | for (t in values) 21 | this.values.add(t) 22 | return this as T 23 | } 24 | 25 | fun andWhere(condition: String) : T { 26 | conditions.add(condition) 27 | return this as T 28 | } 29 | 30 | fun returningEntity() : T { 31 | returnEntity = true 32 | return this as T 33 | } 34 | 35 | protected open fun validate() { 36 | if (columns.size != values.size) 37 | throw InvalidQueryStateException("Columns size doesn't match values size" + 38 | "\ncolumns[${columns.joinToString(", " )}]" + 39 | "\nvalues[${values.joinToString(", " )}]") 40 | } 41 | 42 | protected fun convertValueToString(value: Any) : String { 43 | return when (value) { 44 | is Long -> value.toString() 45 | is String -> "'$value'" 46 | else -> throw Exception("Unknown type") 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/query/Query.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.query 2 | 3 | import java.sql.Connection 4 | 5 | interface Query { 6 | fun build() : String 7 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/query/delete/DeleteQuery.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.query.delete 2 | 3 | import ru.quipy.query.BasicQuery 4 | import java.sql.Connection 5 | 6 | class DeleteQuery(schema:String, relation: String) : BasicQuery(schema, relation) { 7 | override fun build(): String { 8 | var sql = String.format( 9 | "delete from %s.%s where %s", 10 | schema, 11 | relation, 12 | conditions.joinToString { " and " } 13 | ) 14 | 15 | if (returnEntity) { 16 | sql = "$sql returning *" 17 | } 18 | return sql 19 | } 20 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/query/exception/InvalidQueryStateException.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.query.exception 2 | 3 | class InvalidQueryStateException(message: String) : Exception(message) { 4 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/query/exception/UnmappedDtoType.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.query.exception 2 | 3 | class UnmappedDtoType(message: String?) : Exception(message) -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/query/insert/BatchInsertQuery.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.query.insert 2 | 3 | import ru.quipy.query.BasicQuery 4 | import ru.quipy.query.exception.InvalidQueryStateException 5 | 6 | class BatchInsertQuery(schema: String, relation: String) 7 | : BasicQuery(schema, relation) { 8 | private val batches = ArrayList>() 9 | override fun withValues(vararg values: Any): BatchInsertQuery { 10 | val batch = ArrayList() 11 | for (value in values) { 12 | batch.add(value) 13 | } 14 | batches.add(batch) 15 | return this 16 | } 17 | 18 | fun getTemplate() : String { 19 | return String.format( 20 | "insert into %s.%s (%s) values (%s)", 21 | schema, relation, 22 | columns.joinToString(), 23 | columns.joinToString {"?"} 24 | ) 25 | } 26 | override fun validate() { 27 | for (batch in batches) { 28 | if (batch.size != columns.size) { 29 | throw InvalidQueryStateException("Columns size doesn't match batch values size" + 30 | "\ncolumns[${columns.joinToString( )}]" + 31 | "\nvalues[${values.joinToString()}]") 32 | } 33 | } 34 | } 35 | override fun build(): String { 36 | validate() 37 | val resultQueries = mutableListOf() 38 | for (batch in batches) { 39 | super.withValues(values = batch.toTypedArray()) 40 | resultQueries.add(String.format( 41 | "insert into %s.%s (%s) values (%s)", 42 | schema, relation, 43 | columns.joinToString(), 44 | values.joinToString {convertValueToString(it)} 45 | )) 46 | } 47 | 48 | return resultQueries.joinToString(";\n") 49 | } 50 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/query/insert/InsertQuery.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.query.insert 2 | 3 | import ru.quipy.query.BasicQuery 4 | import java.sql.Connection 5 | 6 | open class InsertQuery(schema: String, relation: String) : BasicQuery(schema, relation) { 7 | override fun build(): String { 8 | validate() 9 | return String.format( 10 | "insert into %s.%s (%s) values (%s)", 11 | schema, relation, 12 | columns.joinToString(), 13 | values.joinToString { convertValueToString(it) } 14 | ) 15 | } 16 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/query/insert/OnDuplicateKeyUpdateInsertQuery.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.query.insert 2 | 3 | import ru.quipy.query.BasicQuery 4 | import ru.quipy.query.exception.InvalidQueryStateException 5 | 6 | class OnDuplicateKeyUpdateInsertQuery(schema: String, relation: String) 7 | : BasicQuery(schema, relation) { 8 | private val duplicateKeyUpdateColumns: MutableList = mutableListOf() 9 | private val conflictingColumns: MutableList = mutableListOf() 10 | fun onDuplicateKeyUpdateColumns(vararg columns: String) : OnDuplicateKeyUpdateInsertQuery { 11 | duplicateKeyUpdateColumns.addAll(columns) 12 | return this 13 | } 14 | 15 | fun withPossiblyConflictingColumns(vararg columns: String) : OnDuplicateKeyUpdateInsertQuery { 16 | conflictingColumns.addAll(columns) 17 | return this 18 | } 19 | 20 | override fun validate() { 21 | super.validate() 22 | val unknownColumns = duplicateKeyUpdateColumns.filter { !columns.contains(it) } 23 | if (unknownColumns.isNotEmpty()) { 24 | throw InvalidQueryStateException( 25 | "Unknown columns for updating on duplicated key: [${unknownColumns.joinToString { ", " }}]") 26 | } 27 | } 28 | 29 | override fun build(): String { 30 | validate() 31 | val duplicateColumnToValue = mutableMapOf() 32 | for (column in duplicateKeyUpdateColumns) { 33 | val value = values[columns.indexOf(column)] 34 | duplicateColumnToValue[column] = convertValueToString(value) 35 | } 36 | var sql = "insert into ${schema}.${relation} (${columns.joinToString()}) values (${values.joinToString {convertValueToString(it)}}) " + 37 | "on conflict (${conflictingColumns.joinToString()}) do update " + 38 | "set ${duplicateColumnToValue.entries.joinToString {"${it.key}=${it.value}"}}" 39 | if (conditions.isNotEmpty()) { 40 | sql += " where ${conditions.joinToString(" and ")}" 41 | } 42 | return sql 43 | } 44 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/query/select/SelectQuery.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.query.select 2 | 3 | import ru.quipy.query.BasicQuery 4 | import ru.quipy.query.exception.InvalidQueryStateException 5 | 6 | class SelectQuery(schema: String, relation: String) : BasicQuery(schema, relation) { 7 | private var limit: Int = -1 8 | private var orderByColumn: String? = null 9 | private var sortingOrder: SortingOrder = SortingOrder.ASCENDING 10 | fun limit(limit: Int) : SelectQuery { 11 | if (limit > 0) { 12 | this.limit = limit 13 | } 14 | return this 15 | } 16 | 17 | override fun build(): String { 18 | // no need to validate query state 19 | var columnsStr = "*" 20 | if (columns.isNotEmpty()) { 21 | columnsStr = columns.joinToString() 22 | } 23 | var sql = String.format("select $columnsStr from ${schema}.${relation}") 24 | if (conditions.isNotEmpty()) { 25 | sql += " where ${conditions.joinToString( " and " )}" 26 | } 27 | if (orderByColumn != null) sql += " order by $orderByColumn ${sortingOrder.sqlName}" 28 | if (limit > 0) sql = "$sql limit $limit" 29 | return sql 30 | } 31 | 32 | override fun withValues(vararg values: Any): SelectQuery { 33 | throw UnsupportedOperationException() 34 | } 35 | 36 | fun orderBy(column: String, mode: SortingOrder = SortingOrder.ASCENDING): SelectQuery { 37 | orderByColumn = column 38 | sortingOrder = sortingOrder 39 | return this 40 | } 41 | 42 | override fun validate() { 43 | super.validate() 44 | if (orderByColumn != null && !columns.contains(orderByColumn)) { 45 | throw InvalidQueryStateException("Colunm $orderByColumn is not present in select columns " + 46 | "[${columns.joinToString(", " )}]") 47 | } 48 | } 49 | 50 | enum class SortingOrder(val sqlName: String) { 51 | ASCENDING("asc"), 52 | DESCENDING("desc"); 53 | } 54 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/query/update/UpdateQuery.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.query.update 2 | 3 | import ru.quipy.query.BasicQuery 4 | import java.sql.Connection 5 | import java.sql.ResultSet 6 | 7 | class UpdateQuery(schema: String, relation: String) : BasicQuery(schema, relation) { 8 | private var columnValueMap = mutableMapOf() 9 | 10 | fun set(column: String, value: Any) : UpdateQuery { 11 | columnValueMap[column] = value 12 | return this 13 | } 14 | 15 | override fun build(): String { 16 | validate() 17 | var sql = String.format( 18 | "update %s.%s set %s where %s", 19 | schema, 20 | relation, 21 | columnValueMap.map { "${it.key} = ${convertValueToString(it.value)}" }.joinToString(), 22 | conditions.joinToString(" and ") 23 | ) 24 | 25 | if (returnEntity) { 26 | sql = "$sql returning *" 27 | } 28 | return sql 29 | } 30 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/tables/Column.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.tables 2 | 3 | import kotlin.reflect.KClass 4 | 5 | class Column { 6 | val index: Int 7 | val name: String 8 | val type: KClass 9 | 10 | constructor(index: Int, name: String, type: KClass) { 11 | this.index = index 12 | this.name = name 13 | this.type = type 14 | } 15 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/tables/Dto.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.tables 2 | 3 | import ru.quipy.converter.EntityConverter 4 | import ru.quipy.domain.ActiveEventStreamReader 5 | import ru.quipy.domain.EventRecord 6 | import ru.quipy.domain.EventStreamReadIndex 7 | import ru.quipy.domain.Snapshot 8 | import kotlin.reflect.jvm.jvmName 9 | 10 | interface Dto { 11 | fun values() : Array 12 | } 13 | class EventRecordDto( 14 | val id: String, 15 | val aggregateTableName: String, 16 | val aggregateId: String, 17 | val aggregateVersion: Long, 18 | val eventTitle: String, 19 | val payload: String, 20 | var sagaContext: String 21 | ) : Dto { 22 | constructor(eventRecord: EventRecord, aggregateTableName: String, entityConverter: EntityConverter) 23 | : this( 24 | eventRecord.id, 25 | aggregateTableName, 26 | eventRecord.aggregateId.toString(), 27 | eventRecord.aggregateVersion, 28 | eventRecord.eventTitle, 29 | eventRecord.payload, 30 | eventRecord.sagaContext?.let { entityConverter.serialize(it)} ?: "null" 31 | ) 32 | 33 | override fun values(): Array { 34 | return arrayOf( 35 | id, aggregateTableName, aggregateId, aggregateVersion, eventTitle, payload, sagaContext 36 | ) 37 | } 38 | } 39 | 40 | class SnapshotDto( 41 | val id : String, 42 | val snapshotTableName: String, 43 | val aggregateStateClassName: String, 44 | val snapshot : String, 45 | var version: Long 46 | ) : Dto { 47 | constructor(snapshot: Snapshot, snapshotTableName: String, entityConverter: EntityConverter) 48 | : this(snapshot.id.toString(), 49 | snapshotTableName, 50 | snapshot.snapshot::class.jvmName, 51 | entityConverter.serialize(snapshot.snapshot), 52 | snapshot.version) 53 | 54 | override fun values(): Array { 55 | return arrayOf(id, snapshotTableName, aggregateStateClassName, snapshot, version) 56 | } 57 | } 58 | 59 | 60 | class EventStreamReadIndexDto( 61 | val id: String, 62 | val readIndex: Long, 63 | var version: Long 64 | ) : Dto { 65 | constructor(eventStreamReadIndex: EventStreamReadIndex) 66 | : this(eventStreamReadIndex.id, 67 | eventStreamReadIndex.readIndex, 68 | eventStreamReadIndex.version) 69 | 70 | override fun values(): Array { 71 | return arrayOf(id, readIndex, version) 72 | } 73 | } 74 | 75 | class ActiveEventStreamReaderDto( 76 | val id: String, 77 | var version: Long, 78 | val readerId: String, 79 | val readPosition: Long, 80 | val lastInteraction: Long 81 | ) : Dto { 82 | constructor(activeEventStreamReader: ActiveEventStreamReader) 83 | : this(activeEventStreamReader.id, 84 | activeEventStreamReader.version, 85 | activeEventStreamReader.readerId, 86 | activeEventStreamReader.readPosition, 87 | activeEventStreamReader.lastInteraction) 88 | 89 | override fun values(): Array { 90 | return arrayOf(id, version, readerId, readPosition, lastInteraction) 91 | } 92 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/kotlin/ru/quipy/tables/DtoCreator.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.tables 2 | 3 | import ru.quipy.converter.EntityConverter 4 | import ru.quipy.domain.ActiveEventStreamReader 5 | import ru.quipy.domain.EventRecord 6 | import ru.quipy.domain.EventStreamReadIndex 7 | import ru.quipy.domain.Snapshot 8 | 9 | class DtoCreator { 10 | companion object { 11 | fun create(entity: E, tableName: String, entityConverter: EntityConverter) : Dto { 12 | return when (entity) { 13 | is EventRecord -> EventRecordDto(entity as EventRecord, tableName, entityConverter) 14 | is Snapshot -> SnapshotDto(entity as Snapshot, tableName, entityConverter) 15 | is EventStreamReadIndex -> EventStreamReadIndexDto(entity as EventStreamReadIndex) 16 | is ActiveEventStreamReader -> ActiveEventStreamReaderDto(entity as ActiveEventStreamReader) 17 | else -> throw IllegalStateException("No dto defined for entity ${entity!!::class.simpleName}") 18 | } 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/main/resources/liquibase/changelog.sql: -------------------------------------------------------------------------------- 1 | --liquibase formatted sql 2 | --changeset vekajp:es-001 3 | 4 | create sequence if not exists ${schema}.event_record_created_at_sequence; 5 | 6 | create table if not exists ${schema}.event_record 7 | ( 8 | id text primary key, 9 | aggregate_table_name text, 10 | aggregate_id text, 11 | aggregate_version bigint, 12 | event_title text, 13 | payload text, 14 | saga_context text, 15 | created_at bigint default nextval('${schema}.event_record_created_at_sequence'), 16 | unique (aggregate_id, aggregate_version) 17 | ); 18 | 19 | CREATE INDEX idx_created_at ON ${schema}.event_record(aggregate_table_name, created_at); 20 | CREATE INDEX idx_aggregate_id_version ON ${schema}.event_record(aggregate_id, aggregate_version); 21 | 22 | create table if not exists ${schema}.snapshot 23 | ( 24 | id text primary key, 25 | snapshot_table_name text, 26 | aggregate_state_class_name text, 27 | snapshot text, 28 | version bigint 29 | ); 30 | 31 | create table if not exists ${schema}.event_stream_read_index 32 | ( 33 | id text primary key, 34 | read_index bigint, 35 | version bigint 36 | ); 37 | 38 | create table if not exists ${schema}.event_stream_active_readers 39 | ( 40 | id text primary key, 41 | version bigint, 42 | reader_id text, 43 | read_position bigint, 44 | last_interaction bigint 45 | ); 46 | 47 | CREATE OR REPLACE FUNCTION ${schema}.execute_batch( 48 | p_commands TEXT[] 49 | ) RETURNS INT[] LANGUAGE plpgsql AS ' 50 | DECLARE 51 | p_results INT[] := ''{}''; 52 | i INT; 53 | BEGIN 54 | FOR i IN array_lower(p_commands, 1) .. array_upper(p_commands, 1) LOOP 55 | BEGIN 56 | EXECUTE p_commands[i]; 57 | EXCEPTION 58 | WHEN OTHERS THEN 59 | p_results := array_append(p_results, i); 60 | END; 61 | END LOOP; 62 | RETURN p_results; 63 | END;'; -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/test/kotlin/ru/quipy/query/InsertQueryTest.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.query 2 | 3 | import ru.quipy.query.insert.InsertQuery 4 | import ru.quipy.query.insert.OnDuplicateKeyUpdateInsertQuery 5 | import org.junit.jupiter.api.Assertions 6 | import org.junit.jupiter.api.Test 7 | 8 | class InsertQueryTest { 9 | private val schema = "schema" 10 | private val relation = "relation" 11 | private val columns = arrayOf("a", "b", "c") 12 | private val onDuplicateKeyUpdateColumns = arrayOf("a", "b") 13 | private val values = arrayOf("a1", "b1", "c1") 14 | @Test 15 | fun testSimpleInsertQuery() { 16 | val query = InsertQuery(schema, relation) 17 | .withColumns(columns = columns) 18 | .withValues(values = values) 19 | 20 | Assertions.assertEquals(query.build(), 21 | "insert into schema.relation (a, b, c) values ('a1', 'b1', 'c1')" 22 | ) 23 | } 24 | 25 | @Test 26 | fun testOnDuplicateKeyUpdateQuery() { 27 | val query = OnDuplicateKeyUpdateInsertQuery(schema, relation) 28 | .withColumns(columns = columns) 29 | .withValues(values = columns) 30 | .withPossiblyConflictingColumns("c") 31 | .onDuplicateKeyUpdateColumns(columns = onDuplicateKeyUpdateColumns) 32 | 33 | Assertions.assertEquals(query.build(), 34 | "insert into schema.relation (a, b, c) values ('a', 'b', 'c')" + 35 | " on conflict (c) do update set a='a', b='b'" 36 | ) 37 | } 38 | 39 | // TODO append tests for every query and case 40 | } -------------------------------------------------------------------------------- /tiny-postgresql-event-store/src/test/kotlin/ru/quipy/query/SelectQueryTest.kt: -------------------------------------------------------------------------------- 1 | package ru.quipy.query 2 | 3 | import ru.quipy.query.select.SelectQuery 4 | import org.junit.jupiter.api.Assertions 5 | import org.junit.jupiter.api.Test 6 | 7 | class SelectQueryTest { 8 | private val schema = "schema" 9 | private val relation = "relation" 10 | private val columns = arrayOf("a", "b", "c") 11 | @Test 12 | fun testSelectQuerySqlWithoutConditionsWithColumnsAndLimit() { 13 | val limit = 101; 14 | val query = SelectQuery(schema, relation) 15 | .withColumns(columns = columns) 16 | .limit(limit) 17 | 18 | Assertions.assertEquals(query.build(), 19 | String.format("select %s from %s.%s limit %d", columns.joinToString(), schema, relation, limit) 20 | ) 21 | } 22 | 23 | @Test 24 | fun testSelectQuerySqlWithConditionsWithoutColumnsAndNoLimit() { 25 | val query = SelectQuery(schema, relation) 26 | .andWhere("a = b") 27 | .andWhere("b > c") 28 | 29 | Assertions.assertEquals(query.build(), 30 | String.format("select * from %s.%s where a = b and b > c", schema, relation) 31 | ) 32 | } 33 | } --------------------------------------------------------------------------------