Skip to content

[#29] Feature/aggregate with immutable identifier #54

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -84,7 +84,19 @@ If you want to build the extension locally, you need to check it out from GiHub

Please execute the following command line if you are interested in producing KDoc and Source archives:

./mvnw clean install -Pjavadoc-and-sources
./mvnw clean install -Pdocs-and-sources

### Collecting code coverage data

If you are interested in test code coverage, please run the following command:

./mvnw clean install -Pcoverage

### Building example project

The project includes an example module demonstrating usage of the extension. If you want to skip the example
build, please run the following command line:

./mvnw clean install -DskipExamples

---
55 changes: 55 additions & 0 deletions kotlin-example/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright (c) 2010-2020. Axon Framework
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<name>Axon Framework - Kotlin Extension Example</name>
<description>Module for the Kotlin Extension Example of Axon Framework</description>

<parent>
<groupId>org.axonframework.extensions.kotlin</groupId>
<artifactId>axon-kotlin-parent</artifactId>
<version>0.3.0-SNAPSHOT</version>
</parent>

<artifactId>axon-kotlin-example</artifactId>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.axonframework.extensions.kotlin</groupId>
<artifactId>axon-kotlin-springboot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.github.microutils</groupId>
<artifactId>kotlin-logging-jvm</artifactId>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extension.kotlin.example

import mu.KLogging
import org.axonframework.config.Configurer
import org.axonframework.eventhandling.EventBus
import org.axonframework.eventhandling.EventMessage
import org.axonframework.eventhandling.interceptors.EventLoggingInterceptor
import org.axonframework.eventhandling.tokenstore.TokenStore
import org.axonframework.eventhandling.tokenstore.inmemory.InMemoryTokenStore
import org.axonframework.eventsourcing.eventstore.EmbeddedEventStore
import org.axonframework.eventsourcing.eventstore.EventStore
import org.axonframework.eventsourcing.eventstore.inmemory.InMemoryEventStorageEngine
import org.axonframework.extension.kotlin.spring.EnableAggregateWithImmutableIdentifierScan
import org.axonframework.messaging.interceptors.LoggingInterceptor
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.context.annotation.Bean

/**
* Starting point.
* @param args CLI parameters.
*/
fun main(args: Array<String>) {
SpringApplication.run(AxonKotlinExampleApplication::class.java, *args)
}

/**
* Main example application class.
*/
@SpringBootApplication
@EnableAggregateWithImmutableIdentifierScan
class AxonKotlinExampleApplication {

companion object : KLogging()

/**
* Configures to use in-memory embedded event store.
*/
@Bean
fun eventStore(): EventStore = EmbeddedEventStore.builder().storageEngine(InMemoryEventStorageEngine()).build()

/**
* Configure logging interceptor.
*/
@Autowired
fun configureEventHandlingInterceptors(eventBus: EventBus) {
eventBus.registerDispatchInterceptor(EventLoggingInterceptor())
}

/**
* Configures to use in-memory token store.
*/
@Bean
fun tokenStore(): TokenStore = InMemoryTokenStore()

}


Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extension.kotlin.example.api

import org.axonframework.extension.kotlin.example.core.BankAccountIdentifier
import org.axonframework.modelling.command.TargetAggregateIdentifier
import javax.validation.constraints.Min

/**
* Create account.
*/
data class CreateBankAccountCommand(
@TargetAggregateIdentifier
val bankAccountId: String,
@Min(value = 0, message = "Overdraft limit must not be less than zero")
val overdraftLimit: Long
)

/**
* Create advanced account.
*/
data class CreateAdvancedBankAccountCommand(
@TargetAggregateIdentifier
val bankAccountId: BankAccountIdentifier,
@Min(value = 0, message = "Overdraft limit must not be less than zero")
val overdraftLimit: Long
)


/**
* Deposit money.
*/
data class DepositMoneyCommand(
@TargetAggregateIdentifier
val bankAccountId: String,
val amountOfMoney: Long
)

/**
* Withdraw money.
*/
data class WithdrawMoneyCommand(
@TargetAggregateIdentifier
val bankAccountId: String,
val amountOfMoney: Long
)

/**
* Return money if transfer is not possible.
*/
data class ReturnMoneyOfFailedBankTransferCommand(
@TargetAggregateIdentifier
val bankAccountId: String,
val amount: Long
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extension.kotlin.example.api

import org.axonframework.extension.kotlin.example.core.BankAccountIdentifier

/**
* Account created.
*/
data class BankAccountCreatedEvent(
val id: String,
val overdraftLimit: Long
)

/**
* Advanced account created.
*/
data class AdvancedBankAccountCreatedEvent(
val id: BankAccountIdentifier,
val overdraftLimit: Long
)

/**
* Collecting event for increasing amount.
*/
sealed class MoneyAddedEvent(
open val bankAccountId: String,
open val amount: Long
)

/**
* Money deposited.
*/
data class MoneyDepositedEvent(override val bankAccountId: String, override val amount: Long) : MoneyAddedEvent(bankAccountId, amount)

/**
* Money returned.
*/
data class MoneyOfFailedBankTransferReturnedEvent(override val bankAccountId: String, override val amount: Long) : MoneyAddedEvent(bankAccountId, amount)

/**
* Money received via transfer.
*/
data class DestinationBankAccountCreditedEvent(override val bankAccountId: String, override val amount: Long, val bankTransferId: String) : MoneyAddedEvent(bankAccountId, amount)

/**
* Collecting event for decreasing amount.
*/
sealed class MoneySubtractedEvent(
open val bankAccountId: String,
open val amount: Long
)

/**
* Money withdrawn.
*/
data class MoneyWithdrawnEvent(override val bankAccountId: String, override val amount: Long) : MoneySubtractedEvent(bankAccountId, amount)

/**
* Money transferred.
*/
data class SourceBankAccountDebitedEvent(override val bankAccountId: String, override val amount: Long, val bankTransferId: String) : MoneySubtractedEvent(bankAccountId, amount)

/**
* Money transfer rejected.
*/
data class SourceBankAccountDebitRejectedEvent(val bankTransferId: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* Copyright (c) 2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extension.kotlin.example.core

import mu.KLogging
import org.axonframework.commandhandling.CommandHandler
import org.axonframework.commandhandling.gateway.CommandGateway
import org.axonframework.eventsourcing.EventSourcingHandler
import org.axonframework.extension.kotlin.example.AxonKotlinExampleApplication
import org.axonframework.extension.kotlin.example.api.*
import org.axonframework.extension.kotlin.spring.AggregateWithImmutableIdentifier
import org.axonframework.extensions.kotlin.send
import org.axonframework.modelling.command.AggregateCreationPolicy
import org.axonframework.modelling.command.AggregateIdentifier
import org.axonframework.modelling.command.AggregateLifecycle.apply
import org.axonframework.modelling.command.CreationPolicy
import org.springframework.boot.ApplicationRunner
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.stereotype.Service
import java.util.*

/**
* Bank configuration.
*/
@Configuration
class BankConfiguration(private val bankAccountService: BankAccountService) {

/**
* Application runner of bank ops.
*/
@Bean
fun accountOperationsRunner() = ApplicationRunner {
bankAccountService.accountOperations()
}
}

/**
* Bank service.
*/
@Service
class BankAccountService(private val commandGateway: CommandGateway) {

companion object : KLogging()

/**
* Bank ops.
*/
fun accountOperations() {
val accountId = UUID.randomUUID().toString()

logger.info { "\nPerforming basic operations on account $accountId" }

commandGateway.send(
command = CreateBankAccountCommand(accountId, 100),
onSuccess = { _, result: Any?, _ ->
AxonKotlinExampleApplication.logger.info { "Successfully created account with id: $result" }
commandGateway.send(
command = DepositMoneyCommand(accountId, 20),
onSuccess = { c, _: Any?, _ -> logger.info { "Successfully deposited ${c.payload.amountOfMoney}" } },
onError = { c, e, _ -> logger.error(e) { "Error depositing money on ${c.payload.bankAccountId}" } }
)
},
onError = { c, e, _ -> logger.error(e) { "Error creating account ${c.payload.bankAccountId}" } }
)

}
}

/**
* Bank account aggregate as data class.
*/
@AggregateWithImmutableIdentifier
data class BankAccount(
@AggregateIdentifier
private val id: UUID
) {

private var overdraftLimit: Long = 0
private var balanceInCents: Long = 0

/**
* Creates account.
*/
@CommandHandler
@CreationPolicy(AggregateCreationPolicy.CREATE_IF_MISSING)
fun create(command: CreateBankAccountCommand): String {
apply(BankAccountCreatedEvent(command.bankAccountId, command.overdraftLimit))
return command.bankAccountId
}

/**
* Deposits money to account.
*/
@CommandHandler
fun deposit(command: DepositMoneyCommand) {
apply(MoneyDepositedEvent(id.toString(), command.amountOfMoney))
}

/**
* Withdraw money from account.
*/
@CommandHandler
fun withdraw(command: WithdrawMoneyCommand) {
if (command.amountOfMoney <= balanceInCents + overdraftLimit) {
apply(MoneyWithdrawnEvent(id.toString(), command.amountOfMoney))
}
}

/**
* Return money from account.
*/
@CommandHandler
fun returnMoney(command: ReturnMoneyOfFailedBankTransferCommand) {
apply(MoneyOfFailedBankTransferReturnedEvent(id.toString(), command.amount))
}

/**
* Handler to initialize bank accounts attributes.
*/
@EventSourcingHandler
fun on(event: BankAccountCreatedEvent) {
overdraftLimit = event.overdraftLimit
balanceInCents = 0
}

/**
* Handler adjusting balance.
*/
@EventSourcingHandler
fun on(event: MoneyAddedEvent) {
balanceInCents += event.amount
}

/**
* Handler adjusting balance.
*/
@EventSourcingHandler
fun on(event: MoneySubtractedEvent) {
balanceInCents -= event.amount
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extension.kotlin.example.core

import mu.KLogging
import org.axonframework.commandhandling.CommandHandler
import org.axonframework.commandhandling.gateway.CommandGateway
import org.axonframework.eventsourcing.EventSourcingHandler
import org.axonframework.extension.kotlin.example.api.AdvancedBankAccountCreatedEvent
import org.axonframework.extension.kotlin.example.api.CreateAdvancedBankAccountCommand
import org.axonframework.extension.kotlin.spring.AggregateWithImmutableIdentifier
import org.axonframework.extensions.kotlin.aggregate.AggregateIdentifierConverter
import org.axonframework.extensions.kotlin.send
import org.axonframework.modelling.command.AggregateCreationPolicy
import org.axonframework.modelling.command.AggregateIdentifier
import org.axonframework.modelling.command.AggregateLifecycle.apply
import org.axonframework.modelling.command.CreationPolicy
import org.springframework.boot.ApplicationRunner
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.stereotype.Service
import java.util.*

/**
* Advanced bank config.
*/
@Configuration
class AdvancedBankConfiguration(private val advancedBankAccountService: AdvancedBankAccountService) {

/**
* Application run starting bank ops.
*/
@Bean
fun advancedAccountOperationsRunner() = ApplicationRunner {
advancedBankAccountService.accountOperations()
}

/**
* Bank identifier converter.
*/
@Bean
fun bankIdentifierConverter() = object : AggregateIdentifierConverter<BankAccountIdentifier> {
override fun apply(aggregateIdentifier: String) = BankAccountIdentifier(aggregateIdentifier.subSequence(3, aggregateIdentifier.length - 3).toString())
}

/**
* Long converter (not used), should remain to demonstrate correct converter selection.
*/
@Bean
fun longConverter() = object : AggregateIdentifierConverter<Long> {
override fun apply(aggregateIdentifier: String) = aggregateIdentifier.toLong()
}

}

/**
* Advanced bank service.
*/
@Service
class AdvancedBankAccountService(private val commandGateway: CommandGateway) {

companion object : KLogging()

/**
* Runs account ops.
*/
fun accountOperations() {

val accountIdAdvanced = BankAccountIdentifier(UUID.randomUUID().toString())
logger.info { "\nPerforming advanced operations on account $accountIdAdvanced" }

commandGateway.send(
command = CreateAdvancedBankAccountCommand(accountIdAdvanced, 100),
onSuccess = { _, result: Any?, _ ->
logger.info { "Successfully created account with id: $result" }
},
onError = { c, e, _ -> logger.error(e) { "Error creating account ${c.payload.bankAccountId}" } }
)
}
}


/**
* Value type for bank account identifier.
*/
data class BankAccountIdentifier(val id: String) {
override fun toString(): String = "<<<$id>>>"
}

/**
* Aggregate using a complex type as identifier.
*/
@AggregateWithImmutableIdentifier
data class BankAccountAdvanced(
@AggregateIdentifier
private val id: BankAccountIdentifier
) {

private var overdraftLimit: Long = 0
private var balanceInCents: Long = 0

/**
* Create command handler.
*/
@CommandHandler
@CreationPolicy(AggregateCreationPolicy.CREATE_IF_MISSING)
fun create(command: CreateAdvancedBankAccountCommand): BankAccountIdentifier {
apply(AdvancedBankAccountCreatedEvent(command.bankAccountId, command.overdraftLimit))
return command.bankAccountId
}


/**
* Handler to initialize bank accounts attributes.
*/
@EventSourcingHandler
fun on(event: AdvancedBankAccountCreatedEvent) {
overdraftLimit = event.overdraftLimit
balanceInCents = 0
}
}

3 changes: 3 additions & 0 deletions kotlin-example/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
axon:
axonserver:
enabled: false
74 changes: 74 additions & 0 deletions kotlin-springboot-autoconfigure/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright (c) 2010-2020. Axon Framework
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<name>Axon Framework - Kotlin Extension SpringBoot AutoConfigure</name>
<description>Module for the Kotlin SpringBoot AutoConfigure of Axon Framework</description>

<parent>
<groupId>org.axonframework.extensions.kotlin</groupId>
<artifactId>axon-kotlin-parent</artifactId>
<version>0.3.0-SNAPSHOT</version>
</parent>

<artifactId>axon-kotlin-springboot-autoconfigure</artifactId>

<properties>
<!-- generate KDoc for this module -->
<dokka.skip>false</dokka.skip>
</properties>

<dependencies>

<dependency>
<groupId>org.axonframework.extensions.kotlin</groupId>
<artifactId>axon-kotlin</artifactId>
<version>${project.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-modelling</artifactId>
</dependency>
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-configuration</artifactId>
</dependency>
<dependency>
<groupId>io.github.microutils</groupId>
<artifactId>kotlin-logging-jvm</artifactId>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extension.kotlin.spring

import org.axonframework.modelling.command.AggregateRoot

/**
* Marker for an aggregate with immutable identifier.
*
* @since 0.2.0
* @author Simon Zambrovski
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
@MustBeDocumented
@AggregateRoot
annotation class AggregateWithImmutableIdentifier
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extension.kotlin.spring

import mu.KLogging
import org.axonframework.config.AggregateConfigurer.defaultConfiguration
import org.axonframework.config.Configurer
import org.axonframework.eventsourcing.EventSourcingRepository
import org.axonframework.extensions.kotlin.aggregate.AggregateIdentifierConverter
import org.axonframework.extensions.kotlin.aggregate.ImmutableIdentifierAggregateFactory.Companion.usingIdentifier
import org.axonframework.extensions.kotlin.aggregate.ImmutableIdentifierAggregateFactory.Companion.usingStringIdentifier
import org.axonframework.extensions.kotlin.aggregate.ImmutableIdentifierAggregateFactory.Companion.usingUUIDIdentifier
import org.axonframework.extensions.kotlin.aggregate.EventSourcingImmutableIdentifierAggregateRepository
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.context.ApplicationContext
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.util.*

/**
* Configuration to activate the aggregate with immutable identifier detection and registration of the corresponding factories and repositories.
* @see EnableAggregateWithImmutableIdentifierScan for activation.
*
* @author Simon Zambrovski
* @since 0.2.0
*/
@Configuration
class AggregatesWithImmutableIdentifierConfiguration(
private val context: ApplicationContext
) {

companion object : KLogging()

/**
* Initializes the settings.
* @return settings.
*/
@Bean
@ConditionalOnMissingBean
fun initialize(): AggregatesWithImmutableIdentifierSettings {
val beans = context.getBeansWithAnnotation(EnableAggregateWithImmutableIdentifierScan::class.java)
require(beans.isNotEmpty()) {
"EnableAggregateWithImmutableIdentifierScan should be activated exactly once."
}
require(beans.size == 1) {
"EnableAggregateWithImmutableIdentifierScan should be activated exactly once, but was found on ${beans.size} beans:\n" + beans.map { it.key }.joinToString()
}
val basePackage = EnableAggregateWithImmutableIdentifierScan.getBasePackage(beans.entries.first().value)
return AggregatesWithImmutableIdentifierSettings(basePackage = basePackage
?: throw IllegalStateException("Required setting basePackage could not be initialized, consider to provide your own AggregatesWithImmutableIdentifierSettings.")
)
}

@Autowired
fun configureAggregates(
configurer: Configurer,
settings: AggregatesWithImmutableIdentifierSettings,
@Autowired(required = false) identifierConverters: List<AggregateIdentifierConverter<*>>?
) {
val converters = identifierConverters ?: emptyList() // fallback to empty list if none are defined

logger.info { "Discovered ${converters.size} converters for aggregate identifiers." }
logger.info { "Scanning ${settings.basePackage} for aggregates" }

AggregateWithImmutableIdentifier::class
.findAnnotatedAggregateClasses(settings.basePackage)
.map { aggregateClazz ->

val aggregateFactory = when (val idFieldClazz = aggregateClazz.extractAggregateIdentifierClass()) {
String::class -> usingStringIdentifier(aggregateClazz)
UUID::class -> usingUUIDIdentifier(aggregateClazz)
else -> usingIdentifier(aggregateClazz, idFieldClazz, converters.findIdentifierConverter(idFieldClazz))
}.also {
logger.debug { "Registering aggregate factory $it" }
}

configurer.configureAggregate(
defaultConfiguration(aggregateClazz.java)
.configureRepository { config ->
EventSourcingImmutableIdentifierAggregateRepository(
builder = EventSourcingRepository
.builder(aggregateClazz.java)
.eventStore(config.eventStore())
.aggregateFactory(aggregateFactory)
)
}
)
}
}

}

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extension.kotlin.spring

/**
* Settings class to pass values into configuration of aggregate scan.
* @param basePackage base package of scan.
*
* @author Simon Zambrovski
* @since 0.2.0
*/
data class AggregatesWithImmutableIdentifierSettings(
val basePackage: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.axonframework.extension.kotlin.spring

import org.springframework.context.annotation.Import
import org.springframework.core.annotation.AnnotationUtils

/**
* Annotation to enable aggregate scan.
* @param basePackage specifies the package to scan for aggregates, if not specified, defaults to base package of the annotated class.
*
* @author Simon Zambrovski
* @since 0.2.0
*/
@MustBeDocumented
@Target(AnnotationTarget.CLASS, AnnotationTarget.ANNOTATION_CLASS)
@Import(AggregatesWithImmutableIdentifierConfiguration::class)
annotation class EnableAggregateWithImmutableIdentifierScan(
val basePackage: String = NULL_VALUE
) {
companion object {

/**
* Null value to allow package scan on bean.
*/
const val NULL_VALUE = ""

/**
* Reads base package from annotation or, if not provided from the annotated class.
* @param bean annotated bean
* @return base package or <code>null</code> if not defined and can't be read.
*/
fun getBasePackage(bean: Any): String? {
val annotation = AnnotationUtils.findAnnotation(bean::class.java, EnableAggregateWithImmutableIdentifierScan::class.java) ?: return null
return if (annotation.basePackage == NULL_VALUE) {
bean::class.java.`package`.name
} else {
annotation.basePackage
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extension.kotlin.spring

import org.axonframework.extensions.kotlin.aggregate.AggregateIdentifierConverter
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider
import org.springframework.core.type.filter.AnnotationTypeFilter
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
import kotlin.reflect.KParameter
import kotlin.reflect.jvm.jvmErasure

/**
* Scans classpath for annotated aggregate classes.
* @param scanPackage package to scan.
* @return list of annotated classes.
*/
internal fun KClass<out Annotation>.findAnnotatedAggregateClasses(scanPackage: String): List<KClass<Any>> {
val provider = ClassPathScanningCandidateComponentProvider(false)
provider.addIncludeFilter(AnnotationTypeFilter(this.java))
return provider.findCandidateComponents(scanPackage).map {
@Suppress("UNCHECKED_CAST")
Class.forName(it.beanClassName).kotlin as KClass<Any>
}
}

/**
* Extracts the class of aggregate identifier from an aggregate.
* @return class of aggregate identifier.
* @throws IllegalArgumentException if the required constructor is not found.
*/
internal fun KClass<*>.extractAggregateIdentifierClass(): KClass<Any> {

/**
* Holder for constructor and its parameters.
* @param constructor constructor to holf info for.
*/
data class ConstructorParameterInfo(val constructor: KFunction<Any>) {
private val valueProperties by lazy { constructor.parameters.filter { it.kind == KParameter.Kind.VALUE } } // collect only "val" properties

/**
* Check if the provided constructor has only one value parameter.
*/
fun isConstructorWithOneValue() = valueProperties.size == 1

/**
* Retrieves the class of value parameter.
* @return class of value.
*/
fun getParameterClass(): KClass<*> = valueProperties[0].type.jvmErasure
}

val constructors = this.constructors.map { ConstructorParameterInfo(it) }.filter { it.isConstructorWithOneValue() } // exactly one parameter in primary constructor
require(constructors.size == 1) { "Expected exactly one constructor with aggregate identifier parameter, but found ${constructors.size}." }
@Suppress("UNCHECKED_CAST")
return constructors[0].getParameterClass() as KClass<Any>
}


/**
* Extension function to find a matching converter for provided identifier class.
* @param idClazz class of identifier to look for.
* @return a matching converter.
* @throws IllegalArgumentException if converter can not be identified (none or more than one are defined).
*/
internal fun List<AggregateIdentifierConverter<*>>.findIdentifierConverter(idClazz: KClass<out Any>): AggregateIdentifierConverter<Any> {
val converters = this.filter {
idClazz == it.getConverterIdentifierClass()
}.map {
@Suppress("UNCHECKED_CAST")
it as AggregateIdentifierConverter<Any>
}
require(converters.isNotEmpty()) {
"Could not find an AggregateIdentifierConverter for ${idClazz.qualifiedName}. Consider to register a bean implementing AggregateIdentifierConverter<${idClazz.qualifiedName}>"
}
require(converters.size == 1) {
"Found more than one AggregateIdentifierConverter for ${idClazz.qualifiedName}. This is currently not supported."
}
return converters.first()
}

/**
* Returns the concrete class of ID.
* @return class of aggregate identifier or <code>null</code> if it can't be resolved.
*/
internal fun AggregateIdentifierConverter<*>.getConverterIdentifierClass() = this::class.supertypes.first { superTypes -> superTypes.classifier == AggregateIdentifierConverter::class }.arguments[0].type?.jvmErasure

/**
* Converts a string to the same string with first lower letter.
*/
fun String?.toFirstLower() = this?.substring(0, 1)?.toLowerCase() + this?.substring(1, this?.length - 1)
43 changes: 43 additions & 0 deletions kotlin-springboot-starter/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright (c) 2010-2020. Axon Framework
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<name>Axon Framework - Kotlin Extension SpringBoot Starter</name>
<description>Module for the Kotlin Extension SpringBoot Starter of Axon Framework</description>

<parent>
<groupId>org.axonframework.extensions.kotlin</groupId>
<artifactId>axon-kotlin-parent</artifactId>
<version>0.3.0-SNAPSHOT</version>
</parent>

<artifactId>axon-kotlin-springboot-starter</artifactId>

<dependencies>
<dependency>
<groupId>org.axonframework.extensions.kotlin</groupId>
<artifactId>axon-kotlin-springboot-autoconfigure</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
</dependencies>
</project>
1 change: 0 additions & 1 deletion kotlin/pom.xml
Original file line number Diff line number Diff line change
@@ -49,5 +49,4 @@
</plugin>
</plugins>
</build>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.axonframework.extensions.kotlin

/**
* Tries to execute the given function or reports an error on failure.
* @param errorMessage message to report on error.
* @param function: function to invoke
*/
@Throws(IllegalArgumentException::class)
internal fun <T : Any?> invokeReporting(errorMessage: String, function: () -> T): T {
return try {
function.invoke()
} catch (e: Exception) {
throw IllegalArgumentException(errorMessage, e)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2010-2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.axonframework.extensions.kotlin.aggregate

import org.axonframework.common.jpa.EntityManagerProvider
import org.axonframework.config.AggregateConfigurer
import org.axonframework.config.AggregateConfigurer.jpaMappedConfiguration

/**
* Creates default aggregate configurer with a usage of a reified type information.
* @param [A] type of aggregate.
*/
inline fun <reified A: Any> defaultConfiguration(): AggregateConfigurer<A> = AggregateConfigurer.defaultConfiguration(A::class.java)

/**
* Creates JPA-mapped aggregate configurer with a usage of a reified type information.
* @param [A] type of aggregate.
*/
inline fun <reified A: Any> jpaMappedConfiguration(): AggregateConfigurer<A> = jpaMappedConfiguration(A::class.java)

/**
* Creates JPA-mapped aggregate configurer with a usage of a reified type information.
* @param entityManagerProvider entity manager provider.
* @param [A] type of aggregate.
*/
inline fun <reified A: Any> jpaMappedConfiguration(entityManagerProvider: EntityManagerProvider): AggregateConfigurer<A> = jpaMappedConfiguration(A::class.java, entityManagerProvider)
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2010-2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.axonframework.extensions.kotlin.aggregate

import java.util.*
import java.util.function.Function

/**
* Defines a converter from a string to custom identifier type.
* @param [ID] type of aggregate identifier.
*
* @author Simon Zambrovski
* @since 0.2.0
*/
interface AggregateIdentifierConverter<ID> : Function<String, ID> {

/**
* Default string converter.
*/
object DefaultString : AggregateIdentifierConverter<String> {
override fun apply(it: String): String = it
override fun toString(): String = this::class.qualifiedName!!
}

/**
* Default UUID converter.
*/
object DefaultUUID : AggregateIdentifierConverter<UUID> {
override fun apply(it: String): UUID = UUID.fromString(it)
override fun toString(): String = this::class.qualifiedName!!
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extensions.kotlin.aggregate

import org.axonframework.eventsourcing.EventSourcedAggregate
import org.axonframework.eventsourcing.EventSourcingRepository
import org.axonframework.messaging.unitofwork.CurrentUnitOfWork
import org.axonframework.modelling.command.Aggregate
import org.axonframework.modelling.command.LockAwareAggregate
import java.util.concurrent.Callable

/**
* Event souring repository which uses a aggregate factory to create new aggregate passing <code>null</code> as first event.
* @param builder repository builder with configuration.
*
* @since 0.2.0
* @author Simon Zambrovski
*/
class EventSourcingImmutableIdentifierAggregateRepository<A>(
builder: Builder<A>
) : EventSourcingRepository<A>(builder) {

override fun loadOrCreate(aggregateIdentifier: String, factoryMethod: Callable<A>): Aggregate<A> {
val factory = super.getAggregateFactory()
val uow = CurrentUnitOfWork.get()
val aggregates: MutableMap<String, LockAwareAggregate<A, EventSourcedAggregate<A>>> = managedAggregates(uow)
val aggregate = aggregates.computeIfAbsent(aggregateIdentifier) { aggregateId: String ->
try {
return@computeIfAbsent doLoadOrCreate(aggregateId) {
// call the factory and instead of newInstance on the aggregate class
factory.createAggregateRoot(aggregateId, null)
}
} catch (e: RuntimeException) {
throw e
} catch (e: Exception) {
throw RuntimeException(e)
}
}
uow.onRollback { aggregates.remove(aggregateIdentifier) }
prepareForCommit(aggregate)
return aggregate
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2010-2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.axonframework.extensions.kotlin.aggregate

import org.axonframework.eventhandling.DomainEventMessage
import org.axonframework.eventsourcing.AggregateFactory
import org.axonframework.extensions.kotlin.aggregate.AggregateIdentifierConverter.DefaultString
import org.axonframework.extensions.kotlin.aggregate.AggregateIdentifierConverter.DefaultUUID
import org.axonframework.extensions.kotlin.invokeReporting
import java.util.*
import kotlin.reflect.KClass

/**
* Factory to create aggregates [A] with immutable aggregate identifier of type [ID].
* @constructor creates aggregate factory.
* @param clazz aggregate class.
* @param idClazz aggregate identifier class.
* @param aggregateFactoryMethod factory method to create instances, defaults to default constructor of the provided [clazz].
* @param idExtractor function to convert aggregate identifier from string to [ID].
* @param [A] aggregate type.
* @param [ID] aggregate identifier type.
*
* @since 0.2.0
* @author Simon Zambrovski
*/
data class ImmutableIdentifierAggregateFactory<A : Any, ID : Any>(
val clazz: KClass<A>,
val idClazz: KClass<ID>,
val aggregateFactoryMethod: AggregateFactoryMethod<ID, A> = extractConstructorFactory(clazz, idClazz),
val idExtractor: AggregateIdentifierConverter<ID>
) : AggregateFactory<A> {

companion object {

/**
* Reified factory method for aggregate factory using string as aggregate identifier.
* @return instance of ImmutableIdentifierAggregateFactory
*/
inline fun <reified A : Any> usingStringIdentifier() = usingIdentifier<A, String>(String::class) { it }

/**
* Factory method for aggregate factory using string as aggregate identifier.
* @return instance of ImmutableIdentifierAggregateFactory
*/
fun <A : Any> usingStringIdentifier(clazz: KClass<A>) = usingIdentifier(aggregateClazz = clazz, idClazz = String::class, idExtractor = DefaultString)

/**
* Reified factory method for aggregate factory using UUID as aggregate identifier.
* @return instance of ImmutableIdentifierAggregateFactory
*/
inline fun <reified A : Any> usingUUIDIdentifier() = usingIdentifier<A, UUID>(idClazz = UUID::class, idExtractor = DefaultUUID::apply)

/**
* Factory method for aggregate factory using UUID as aggregate identifier.
* @return instance of ImmutableIdentifierAggregateFactory
*/
fun <A : Any> usingUUIDIdentifier(clazz: KClass<A>) = usingIdentifier(aggregateClazz = clazz, idClazz = UUID::class, idExtractor = DefaultUUID)

/**
* Reified factory method for aggregate factory using specified identifier type and converter function.
* @param idClazz identifier class.
* @param idExtractor extractor function for identifier from string.
* @return instance of ImmutableIdentifierAggregateFactory
*/
inline fun <reified A : Any, ID : Any> usingIdentifier(idClazz: KClass<ID>, noinline idExtractor: (String) -> ID) =
ImmutableIdentifierAggregateFactory(clazz = A::class, idClazz = idClazz, idExtractor = object : AggregateIdentifierConverter<ID> {
override fun apply(it: String): ID = idExtractor(it)
})

/**
* Factory method for aggregate factory using specified identifier type and converter.
* @param idClazz identifier class.
* @param idExtractor extractor for identifier from string.
* @return instance of ImmutableIdentifierAggregateFactory
*/
fun <A : Any, ID : Any> usingIdentifier(aggregateClazz: KClass<A>, idClazz: KClass<ID>, idExtractor: AggregateIdentifierConverter<ID>) =
ImmutableIdentifierAggregateFactory(clazz = aggregateClazz, idClazz = idClazz, idExtractor = idExtractor)

/**
* Tries to extract constructor from given class. Used as a default factory method for the aggregate.
* @param clazz aggregate class.
* @param idClazz id class.
* @return factory method to create new instances of aggregate.
*/
fun <ID : Any, A : Any> extractConstructorFactory(clazz: KClass<A>, idClazz: KClass<ID>): AggregateFactoryMethod<ID, A> = {
val constructor = invokeReporting(
"The aggregate [${clazz.java.name}] doesn't provide a constructor for the identifier type [${idClazz.java.name}]."
) { clazz.java.getConstructor(idClazz.java) }
constructor.newInstance(it)
}
}


@Throws(IllegalArgumentException::class)
override fun createAggregateRoot(aggregateIdentifier: String, message: DomainEventMessage<*>?): A {

val id: ID = invokeReporting(
"The identifier [$aggregateIdentifier] could not be converted to the type [${idClazz.java.name}], required for the ID of aggregate [${clazz.java.name}]."
) { idExtractor.apply(aggregateIdentifier) }

return aggregateFactoryMethod.invoke(id)
}

override fun getAggregateType(): Class<A> = clazz.java

}

/**
* Type alias for function creating the aggregate from id.
*/
typealias AggregateFactoryMethod<ID, A> = (ID) -> A
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
/*
* Copyright (c) 2010-2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extensions.kotlin

import io.mockk.every
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extensions.kotlin.aggregate

import org.axonframework.extensions.kotlin.TestLongAggregate
import org.axonframework.extensions.kotlin.TestStringAggregate
import org.axonframework.extensions.kotlin.TestUUIDAggregate
import org.axonframework.extensions.kotlin.aggregate.ImmutableIdentifierAggregateFactory.Companion.usingIdentifier
import java.util.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith


/**
* Test for the aggregate factory.
*
* @author Simon Zambrovski
*/
internal class AggregateWithImmutableIdentifierFactoryTest {

@Test
fun `should create string aggregate`() {
val aggregateId = UUID.randomUUID().toString()
val factory = ImmutableIdentifierAggregateFactory.usingStringIdentifier<TestStringAggregate>()
val aggregate = factory.createAggregateRoot(aggregateId, null)

assertEquals(aggregateId, aggregate.aggregateId)
}

@Test
fun `should create uuid aggregate`() {
val aggregateId = UUID.randomUUID()
val factory: ImmutableIdentifierAggregateFactory<TestUUIDAggregate, UUID> = usingIdentifier(UUID::class) { UUID.fromString(it) }
val aggregate = factory.createAggregateRoot(aggregateId.toString(), null)

assertEquals(aggregateId, aggregate.aggregateId)
}

@Test
fun `should fail create aggregate with wrong constructor type`() {
val aggregateId = UUID.randomUUID()
// pretending the TestLongAggregate to have UUID as identifier.
val factory: ImmutableIdentifierAggregateFactory<TestLongAggregate, UUID> = usingIdentifier(UUID::class) { UUID.fromString(it) }

val exception = assertFailsWith<IllegalArgumentException> {
factory.createAggregateRoot(aggregateId.toString(), null)
}

assertEquals(exception.message,
"The aggregate [${factory.aggregateType.name}] doesn't provide a constructor for the identifier type [${UUID::class.java.name}].")
}

@Test
fun `should fail create aggregate error in extractor`() {
val aggregateId = UUID.randomUUID()
// the extractor is broken.
val factory: ImmutableIdentifierAggregateFactory<TestUUIDAggregate, UUID> = usingIdentifier(UUID::class) { throw java.lang.IllegalArgumentException("") }

val exception = assertFailsWith<IllegalArgumentException> {
factory.createAggregateRoot(aggregateId.toString(), null)
}

assertEquals(exception.message,
"The identifier [$aggregateId] could not be converted to the type [${UUID::class.java.name}], required for the ID of aggregate [${factory.aggregateType.name}].")
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extensions.kotlin.aggregate

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.axonframework.commandhandling.GenericCommandMessage
import org.axonframework.eventsourcing.AggregateFactory
import org.axonframework.eventsourcing.EventSourcingRepository
import org.axonframework.eventsourcing.eventstore.DomainEventStream
import org.axonframework.eventsourcing.eventstore.EventStore
import org.axonframework.extensions.kotlin.TestStringAggregate
import org.axonframework.messaging.unitofwork.DefaultUnitOfWork
import org.axonframework.messaging.unitofwork.UnitOfWork
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.util.*
import java.util.concurrent.Callable
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.fail

/**
* Test for the aggregate repository using the factory.
*
* @author Simon Zambrovski
*/
internal class EventSourcingAggregateWithImmutableIdentifierRepositoryTest {

private val aggregateIdentifier = UUID.randomUUID().toString()
private val eventStore = mockk<EventStore>()
private val aggregateFactory = mockk<AggregateFactory<TestStringAggregate>>()
private lateinit var uow: DefaultUnitOfWork<*>

@BeforeTest
fun `init components`() {
uow = DefaultUnitOfWork.startAndGet(GenericCommandMessage<String>("some payload"))

every { aggregateFactory.aggregateType }.returns(TestStringAggregate::class.java)
every { aggregateFactory.createAggregateRoot(aggregateIdentifier, null) }.returns(TestStringAggregate(aggregateIdentifier))

// no events
every { eventStore.readEvents(aggregateIdentifier) }.returns(DomainEventStream.empty())
}

@AfterTest
fun `check uow`() {
assertEquals(UnitOfWork.Phase.STARTED, uow.phase())
}

@Test
fun `should ask factory to create the aggregate`() {


val repo = EventSourcingImmutableIdentifierAggregateRepository<TestStringAggregate>(
EventSourcingRepository
.builder(TestStringAggregate::class.java)
.eventStore(eventStore)
.aggregateFactory(aggregateFactory)
)

val factoryMethod = Callable<TestStringAggregate> {
fail("The factory method should not be called.")
}
val aggregate = repo.loadOrCreate(aggregateIdentifier = aggregateIdentifier, factoryMethod = factoryMethod)

assertEquals(aggregateIdentifier, aggregate.identifierAsString())
verify {
aggregateFactory.createAggregateRoot(aggregateIdentifier, null)
}

}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
/*
* Copyright (c) 2010-2020. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.axonframework.extensions.kotlin

import org.axonframework.modelling.command.AggregateIdentifier
import org.axonframework.modelling.command.TargetAggregateIdentifier
import java.util.*

/**
* Simple Query class to be used in tests.
@@ -11,3 +28,18 @@ internal data class ExampleQuery(val value: Number)
* Simple Command class to be used in tests.
*/
internal data class ExampleCommand(@TargetAggregateIdentifier val id: String)

/**
* Immutable aggregate with String identifier.
*/
internal data class TestStringAggregate(@AggregateIdentifier val aggregateId: String)

/**
* Immutable aggregate with UUID identifier.
*/
internal data class TestUUIDAggregate(@AggregateIdentifier val aggregateId: UUID)

/**
* Immutable aggregate with Long identifier.
*/
internal data class TestLongAggregate(@AggregateIdentifier val aggregateId: Long)
82 changes: 79 additions & 3 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -28,6 +28,8 @@
<modules>
<module>kotlin</module>
<module>kotlin-test</module>
<module>kotlin-springboot-autoconfigure</module>
<module>kotlin-springboot-starter</module>
</modules>

<properties>
@@ -42,6 +44,8 @@
<log4j.version>2.13.3</log4j.version>
<dokka.version>1.5.31</dokka.version>

<spring-boot.version>2.5.5</spring-boot.version>

<!--
Deactivate dokka by default, and make the build faster for all projects.
The dokka.skip will be set to true on relevant projects only containing Kotlin code
@@ -52,6 +56,27 @@

<dependencyManagement>
<dependencies>

<!-- Project dependencies -->
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>axon-kotlin</artifactId>
<version>${project.version}</version>
</dependency>

<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>axon-kotlin-springboot-autoconfigure</artifactId>
<version>${project.version}</version>
</dependency>

<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>axon-kotlin-springboot-starter</artifactId>
<version>${project.version}</version>
</dependency>

<!-- first: Kotlin Import -->
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-bom</artifactId>
@@ -60,17 +85,36 @@
<scope>import</scope>
</dependency>

<!-- second: SpringBoot Import (will not mess-up the kotlin.version) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<!-- AxonFramework -->
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-configuration</artifactId>
<version>${axon.version}</version>
</dependency>
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-modelling</artifactId>
<version>${axon.version}</version>
</dependency>
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-test</artifactId>
<version>${axon.version}</version>
</dependency>

<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-spring-boot-starter</artifactId>
<version>${axon.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
@@ -135,6 +179,11 @@
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit5</artifactId>
@@ -150,7 +199,6 @@
<artifactId>slf4j-simple</artifactId>
<scope>test</scope>
</dependency>

</dependencies>

<!-- License Stuff -->
@@ -207,12 +255,14 @@
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
<configuration>
<propertyName>surefireArgLine</propertyName>
</configuration>
</execution>
<execution>
<id>report</id>
@@ -247,6 +297,19 @@
</build>
</profile>

<profile>
<!-- Example module, should be skipped during release -->
<id>example</id>
<activation>
<property>
<name>!skipExamples</name>
</property>
</activation>
<modules>
<module>kotlin-example</module>
</modules>
</profile>

</profiles>

<build>
@@ -349,6 +412,7 @@
<include>**/*Test_*.java</include>
<include>**/*Tests_*.java</include>
</includes>
<argLine>${surefireArgLine}</argLine>
<systemPropertyVariables>
<slf4j.version>${slf4j.version}</slf4j.version>
<log4j.version>${log4j.version}</log4j.version>
@@ -432,6 +496,7 @@
</args>
<compilerPlugins>
<plugin>no-arg</plugin>
<plugin>spring</plugin>
<plugin>all-open</plugin>
</compilerPlugins>
<pluginOptions>
@@ -480,6 +545,17 @@
</plugin>
</plugins>
</pluginManagement>

<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

<repositories>