diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..8f760711
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,4 @@
+COMPOSE_PATH_SEPARATOR=:
+COMPOSE_FILE=postgresql-compose.yml:docker-compose.yml
+
+SENDGRID_API_KEY=YOUR_API_KEY
diff --git a/.gitignore b/.gitignore
index 15f52922..19676fe7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,7 +53,6 @@ yarn-error.log*
pnpm-debug.log*
# environment variables
-.env
.env.production
# macOS-specific files
diff --git a/README.md b/README.md
index b486f90d..9b0459bb 100644
--- a/README.md
+++ b/README.md
@@ -32,13 +32,14 @@
-![Astro Badge](https://img.shields.io/badge/Astro-BC52EE?logo=astro&logoColor=fff&style=flat)
-![Typescript Badge](https://img.shields.io/badge/Typescript-007ACC?logo=typescript&logoColor=fff&style=flat)
[![Deploy to Main Stage 💫](https://github.com/yacosta738/lyra/actions/workflows/deploy-main-stage.yml/badge.svg)](https://github.com/yacosta738/lyra/actions/workflows/deploy-main-stage.yml)
+[![codecov](https://codecov.io/gh/yacosta738/lyra/graph/badge.svg?token=4MN8TEOGD9)](https://codecov.io/gh/yacosta738/lyra)
![GitHub stars](https://img.shields.io/github/stars/yacosta738/lyra)
![GitHub issues](https://img.shields.io/github/issues/yacosta738/lyra)
![GitHub forks](https://img.shields.io/github/forks/yacosta738/lyra)
![GitHub PRs](https://img.shields.io/github/issues-pr/yacosta738/lyra)
+![Astro Badge](https://img.shields.io/badge/Astro-BC52EE?logo=astro&logoColor=fff&style=flat)
+![Typescript Badge](https://img.shields.io/badge/Typescript-007ACC?logo=typescript&logoColor=fff&style=flat)
![Tailwind CSS Badge](https://img.shields.io/badge/Tailwind%20CSS-06B6D4?logo=tailwindcss&logoColor=fff&style=flat)
![Spring Boot Badge](https://img.shields.io/badge/Spring%20Boot-6DB33F?logo=springboot&logoColor=fff&style=flat)
![Kotlin Badge](https://img.shields.io/badge/Kotlin-0095D5?logo=kotlin&logoColor=fff&style=flat)
diff --git a/apps/backend/backend.gradle.kts b/apps/backend/backend.gradle.kts
index d4aa5c17..2e3161a2 100644
--- a/apps/backend/backend.gradle.kts
+++ b/apps/backend/backend.gradle.kts
@@ -24,6 +24,23 @@ repositories {
dependencies {
// L O C A L D E P E N D E N C I E S
implementation(project(":shared:common"))
+ implementation(project(":shared:spring-boot-common"))
+
+ implementation(libs.spring.boot.starter.data.r2dbc)
+ implementation("org.springframework:spring-r2dbc")
+ implementation("org.springframework.data:spring-data-r2dbc")
+ implementation("org.postgresql:r2dbc-postgresql")
+
+ implementation(libs.spring.dotenv)
+ implementation(libs.sendgrid)
+
+ testImplementation(libs.faker)
+ testImplementation(libs.mockk)
+
+ testImplementation("org.springframework.boot:spring-boot-testcontainers")
+ testImplementation("org.testcontainers:junit-jupiter")
+ testImplementation("org.testcontainers:postgresql")
+ testImplementation("org.testcontainers:r2dbc")
}
tasks.register
("processFrontendResources") {
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/LyraApplication.kt b/apps/backend/src/main/kotlin/com/lyra/app/LyraApplication.kt
index 84709a43..68d98d96 100644
--- a/apps/backend/src/main/kotlin/com/lyra/app/LyraApplication.kt
+++ b/apps/backend/src/main/kotlin/com/lyra/app/LyraApplication.kt
@@ -1,6 +1,7 @@
package com.lyra.app
import com.lyra.common.domain.Service
+import com.lyra.spring.boot.bus.event.EventConfiguration
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.SpringBootApplication
@@ -9,7 +10,13 @@ import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.FilterType
@SpringBootApplication
-@ComponentScan(includeFilters = [ComponentScan.Filter(type = FilterType.ANNOTATION, classes = [Service::class])])
+@ComponentScan(
+ basePackages = ["com.lyra"],
+ basePackageClasses = [
+ EventConfiguration::class,
+ ],
+ includeFilters = [ComponentScan.Filter(type = FilterType.ANNOTATION, classes = [Service::class])],
+)
class LyraApplication
private val log: Logger = LoggerFactory.getLogger(LyraApplication::class.java)
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/config/db/DatabaseConfig.kt b/apps/backend/src/main/kotlin/com/lyra/app/config/db/DatabaseConfig.kt
new file mode 100644
index 00000000..0f1cb125
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/config/db/DatabaseConfig.kt
@@ -0,0 +1,61 @@
+package com.lyra.app.config.db
+
+import com.lyra.app.newsletter.domain.SubscriberStatus
+import com.lyra.app.newsletter.infrastructure.persistence.converter.SubscriberConverter
+import com.lyra.app.newsletter.infrastructure.persistence.converter.SubscriberStatusWriterConverter
+import io.r2dbc.postgresql.codec.EnumCodec
+import io.r2dbc.postgresql.codec.EnumCodec.Builder.RegistrationPriority
+import io.r2dbc.postgresql.extension.CodecRegistrar
+import io.r2dbc.spi.ConnectionFactoryOptions
+import io.r2dbc.spi.Option
+import org.springframework.boot.autoconfigure.r2dbc.ConnectionFactoryOptionsBuilderCustomizer
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.data.convert.CustomConversions
+import org.springframework.data.r2dbc.config.EnableR2dbcAuditing
+import org.springframework.data.r2dbc.convert.R2dbcCustomConversions
+import org.springframework.data.r2dbc.dialect.DialectResolver
+import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories
+import org.springframework.r2dbc.core.DatabaseClient
+import org.springframework.transaction.annotation.EnableTransactionManagement
+
+@Configuration
+@EnableTransactionManagement
+@EnableR2dbcRepositories
+@EnableR2dbcAuditing
+class DatabaseConfig {
+ /**
+ * Use the customizer to add EnumCodec to R2DBC
+ */
+ @Bean
+ fun connectionFactoryOptionsBuilderCustomizer(): ConnectionFactoryOptionsBuilderCustomizer {
+ return ConnectionFactoryOptionsBuilderCustomizer { builder: ConnectionFactoryOptions.Builder ->
+ builder.option(
+ Option.valueOf("extensions"),
+ listOf(
+ EnumCodec.builder()
+ .withEnum("subscriber_status", SubscriberStatus::class.java)
+ .withRegistrationPriority(RegistrationPriority.FIRST)
+ .build(),
+ ),
+ )
+ }
+ }
+
+ /**
+ * Register converter to make sure Spring data treat enum correctly
+ */
+ @Bean
+ fun r2dbcCustomConversions(databaseClient: DatabaseClient): R2dbcCustomConversions {
+ val dialect = DialectResolver.getDialect(databaseClient.connectionFactory)
+ val converters: MutableList = ArrayList(dialect.converters)
+ converters.addAll(R2dbcCustomConversions.STORE_CONVERTERS)
+ return R2dbcCustomConversions(
+ CustomConversions.StoreConversions.of(dialect.simpleTypeHolder, converters),
+ listOf(
+ SubscriberConverter(),
+ SubscriberStatusWriterConverter(),
+ ),
+ )
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/controllers/GlobalExceptionHandler.kt b/apps/backend/src/main/kotlin/com/lyra/app/controllers/GlobalExceptionHandler.kt
new file mode 100644
index 00000000..92a0b34a
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/controllers/GlobalExceptionHandler.kt
@@ -0,0 +1,70 @@
+package com.lyra.app.controllers
+
+import com.lyra.common.domain.error.BusinessRuleValidationException
+import java.net.URI
+import java.time.Instant
+import org.springframework.http.HttpStatus
+import org.springframework.http.ProblemDetail
+import org.springframework.web.bind.annotation.ExceptionHandler
+import org.springframework.web.bind.annotation.ResponseStatus
+import org.springframework.web.bind.annotation.RestControllerAdvice
+import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler
+
+private const val ERROR_PAGE = "https://lyra.io/errors"
+
+/**
+ * This class provides a global exception handling mechanism for the application.
+ *
+ * It extends the [ResponseEntityExceptionHandler] class to handle exceptions and return appropriate responses.
+ *
+ * @created 4/8/23
+ */
+@RestControllerAdvice
+class GlobalExceptionHandler : ResponseEntityExceptionHandler() {
+ /**
+ * Handles the [UserAuthenticationException] by creating a ProblemDetail object with the appropriate status,
+ * detail and properties.
+ *
+ * @param e The UserAuthenticationException that was thrown.
+ * @return The ProblemDetail object representing the exception.
+ */
+// @ResponseStatus(HttpStatus.UNAUTHORIZED)
+// @ExceptionHandler(UserAuthenticationException::class)
+// fun handleUserAuthenticationException(e: UserAuthenticationException): ProblemDetail {
+// val problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, e.message)
+// problemDetail.title = "User authentication failed"
+// problemDetail.setType(URI.create("$ERROR_PAGE/user-authentication-failed"))
+// problemDetail.setProperty("errorCategory", "AUTHENTICATION")
+// problemDetail.setProperty("timestamp", Instant.now())
+// return problemDetail
+// }
+
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ @ExceptionHandler(
+ IllegalArgumentException::class,
+ BusinessRuleValidationException::class,
+// UserRefreshTokenException::class
+ )
+ fun handleIllegalArgumentException(e: Exception): ProblemDetail {
+ val problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.message ?: "Bad request")
+ problemDetail.title = "Bad request"
+ problemDetail.setType(URI.create("$ERROR_PAGE/bad-request"))
+ problemDetail.setProperty("errorCategory", "BAD_REQUEST")
+ problemDetail.setProperty("timestamp", Instant.now())
+ return problemDetail
+ }
+
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ @ExceptionHandler(Exception::class)
+ fun handleException(e: Exception): ProblemDetail {
+ val problemDetail = ProblemDetail.forStatusAndDetail(
+ HttpStatus.INTERNAL_SERVER_ERROR,
+ e.message ?: "Internal server error",
+ )
+ problemDetail.title = "Internal server error"
+ problemDetail.setType(URI.create("$ERROR_PAGE/internal-server-error"))
+ problemDetail.setProperty("errorCategory", "INTERNAL_SERVER_ERROR")
+ problemDetail.setProperty("timestamp", Instant.now())
+ return problemDetail
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/CreateSubscribeNewsletterCommandHandler.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/CreateSubscribeNewsletterCommandHandler.kt
new file mode 100644
index 00000000..86a3eedd
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/CreateSubscribeNewsletterCommandHandler.kt
@@ -0,0 +1,21 @@
+package com.lyra.app.newsletter.application
+
+import com.lyra.app.newsletter.domain.FirstName
+import com.lyra.app.newsletter.domain.LastName
+import com.lyra.app.newsletter.domain.Name
+import com.lyra.app.newsletter.domain.SubscriberId
+import com.lyra.common.domain.Service
+import com.lyra.common.domain.bus.command.CommandHandler
+import com.lyra.common.domain.email.Email
+
+@Service
+class CreateSubscribeNewsletterCommandHandler(
+ private val subscriberRegistrator: SubscriberRegistrator
+) : CommandHandler {
+ override suspend fun handle(command: SubscribeNewsletterCommand) {
+ val id = SubscriberId(command.id)
+ val email = Email(command.email)
+ val name = Name(FirstName(command.firstname), command.lastname?.let { LastName(it) })
+ subscriberRegistrator.register(id, email, name)
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscribeNewsletterCommand.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscribeNewsletterCommand.kt
new file mode 100644
index 00000000..f9b0b72f
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscribeNewsletterCommand.kt
@@ -0,0 +1,14 @@
+package com.lyra.app.newsletter.application
+
+import com.lyra.common.domain.bus.command.Command
+
+/**
+ *
+ * @created 7/1/24
+ */
+data class SubscribeNewsletterCommand(
+ val id: String,
+ val email: String,
+ val firstname: String,
+ val lastname: String?
+) : Command
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscriberRegistrator.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscriberRegistrator.kt
new file mode 100644
index 00000000..fd488b9f
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscriberRegistrator.kt
@@ -0,0 +1,45 @@
+package com.lyra.app.newsletter.application
+
+import com.lyra.app.newsletter.domain.Name
+import com.lyra.app.newsletter.domain.Subscriber
+import com.lyra.app.newsletter.domain.SubscriberId
+import com.lyra.app.newsletter.domain.SubscriberRepository
+import com.lyra.app.newsletter.domain.event.SubscriberCreatedEvent
+import com.lyra.common.domain.Service
+import com.lyra.common.domain.bus.event.EventBroadcaster
+import com.lyra.common.domain.bus.event.EventPublisher
+import com.lyra.common.domain.email.Email
+import org.slf4j.LoggerFactory
+
+/**
+ *
+ * @created 6/1/24
+ */
+@Service
+class SubscriberRegistrator(
+ private val subscriberRepository: SubscriberRepository,
+ eventPublisher: EventPublisher
+) {
+ private val eventPublisher = EventBroadcaster()
+
+ init {
+ this.eventPublisher.use(eventPublisher)
+ }
+
+ suspend fun register(id: SubscriberId, email: Email, name: Name) {
+ log.info("Registering subscriber with email: $email")
+
+ val subscriber = Subscriber.create(id, email, name)
+ subscriberRepository.create(subscriber)
+ val domainEvents = subscriber.pullDomainEvents()
+ log.debug("Pulling {} events from subscriber", domainEvents.size)
+
+ domainEvents.forEach {
+ eventPublisher.publish(it as SubscriberCreatedEvent)
+ }
+ }
+
+ companion object {
+ private val log = LoggerFactory.getLogger(SubscriberRegistrator::class.java)
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscribersResponse.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscribersResponse.kt
new file mode 100644
index 00000000..574a953b
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/SubscribersResponse.kt
@@ -0,0 +1,12 @@
+package com.lyra.app.newsletter.application
+
+import com.lyra.common.domain.bus.query.Response
+
+data class SubscribersResponse(val subscribers: List) : Response
+
+data class SubscriberResponse(
+ val id: String,
+ val email: String,
+ val name: String,
+ val status: String,
+) : Response
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/ActiveSubscriberSearcher.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/ActiveSubscriberSearcher.kt
new file mode 100644
index 00000000..286fb42a
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/ActiveSubscriberSearcher.kt
@@ -0,0 +1,37 @@
+package com.lyra.app.newsletter.application.search.active
+
+import com.lyra.app.newsletter.application.SubscriberResponse
+import com.lyra.app.newsletter.application.SubscribersResponse
+import com.lyra.app.newsletter.domain.Subscriber
+import com.lyra.app.newsletter.domain.SubscriberRepository
+import com.lyra.common.domain.Service
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.toList
+import org.slf4j.LoggerFactory
+
+/**
+ *
+ * @created 10/1/24
+ */
+@Service
+class ActiveSubscriberSearcher(private val repository: SubscriberRepository) {
+
+ suspend fun search(): SubscribersResponse {
+ log.info("Searching active subscribers")
+ val subscribers: Flow = repository.searchActive()
+ return SubscribersResponse(
+ subscribers.map {
+ SubscriberResponse(
+ it.id.toString(),
+ it.email.value,
+ it.name.fullName(),
+ it.status.name,
+ )
+ }.toList(),
+ )
+ }
+ companion object {
+ private val log = LoggerFactory.getLogger(ActiveSubscriberSearcher::class.java)
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQuery.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQuery.kt
new file mode 100644
index 00000000..d2574321
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQuery.kt
@@ -0,0 +1,18 @@
+package com.lyra.app.newsletter.application.search.active
+
+import com.lyra.app.newsletter.application.SubscribersResponse
+import com.lyra.common.domain.bus.query.Query
+
+/**
+ *
+ * @created 9/1/24
+ */
+class SearchAllActiveSubscribersQuery : Query {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is SearchAllActiveSubscribersQuery) return false
+ return true
+ }
+
+ override fun hashCode(): Int = javaClass.hashCode()
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQueryHandler.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQueryHandler.kt
new file mode 100644
index 00000000..2e5f4ae2
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQueryHandler.kt
@@ -0,0 +1,23 @@
+package com.lyra.app.newsletter.application.search.active
+
+import com.lyra.app.newsletter.application.SubscribersResponse
+import com.lyra.common.domain.Service
+import com.lyra.common.domain.bus.query.QueryHandler
+import org.slf4j.LoggerFactory
+
+/**
+ *
+ * @created 9/1/24
+ */
+@Service
+class SearchAllActiveSubscribersQueryHandler(
+ private val searcher: ActiveSubscriberSearcher,
+) : QueryHandler {
+ override suspend fun handle(query: SearchAllActiveSubscribersQuery): SubscribersResponse {
+ log.info("Searching all active subscribers")
+ return searcher.search()
+ }
+ companion object {
+ private val log = LoggerFactory.getLogger(SearchAllActiveSubscribersQueryHandler::class.java)
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/AllSubscriberSearcher.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/AllSubscriberSearcher.kt
new file mode 100644
index 00000000..bb48b79f
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/AllSubscriberSearcher.kt
@@ -0,0 +1,33 @@
+package com.lyra.app.newsletter.application.search.all
+
+import com.lyra.app.newsletter.application.SubscriberResponse
+import com.lyra.app.newsletter.application.SubscribersResponse
+import com.lyra.app.newsletter.domain.Subscriber
+import com.lyra.app.newsletter.domain.SubscriberRepository
+import com.lyra.common.domain.Service
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.toList
+import org.slf4j.LoggerFactory
+
+@Service
+class AllSubscriberSearcher(private val repository: SubscriberRepository) {
+ suspend fun search(): SubscribersResponse {
+ log.info("Searching all subscribers")
+ val subscribers: Flow = repository.searchAll()
+ return SubscribersResponse(
+ subscribers.map {
+ SubscriberResponse(
+ it.id.toString(),
+ it.email.value,
+ it.name.fullName(),
+ it.status.name,
+ )
+ }.toList(),
+ )
+ }
+
+ companion object {
+ private val log = LoggerFactory.getLogger(AllSubscriberSearcher::class.java)
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQuery.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQuery.kt
new file mode 100644
index 00000000..d1338082
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQuery.kt
@@ -0,0 +1,18 @@
+package com.lyra.app.newsletter.application.search.all
+
+import com.lyra.app.newsletter.application.SubscribersResponse
+import com.lyra.common.domain.bus.query.Query
+
+/**
+ *
+ * @created 9/1/24
+ */
+class SearchAllSubscribersQuery : Query {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is SearchAllSubscribersQuery) return false
+ return true
+ }
+
+ override fun hashCode(): Int = javaClass.hashCode()
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQueryHandler.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQueryHandler.kt
new file mode 100644
index 00000000..235903b2
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQueryHandler.kt
@@ -0,0 +1,23 @@
+package com.lyra.app.newsletter.application.search.all
+
+import com.lyra.app.newsletter.application.SubscribersResponse
+import com.lyra.common.domain.Service
+import com.lyra.common.domain.bus.query.QueryHandler
+import org.slf4j.LoggerFactory
+
+/**
+ *
+ * @created 9/1/24
+ */
+@Service
+class SearchAllSubscribersQueryHandler(
+ private val searcher: AllSubscriberSearcher,
+) : QueryHandler {
+ override suspend fun handle(query: SearchAllSubscribersQuery): SubscribersResponse {
+ log.info("Searching all subscribers")
+ return searcher.search()
+ }
+ companion object {
+ private val log = LoggerFactory.getLogger(SearchAllSubscribersQueryHandler::class.java)
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/FirstName.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/FirstName.kt
new file mode 100644
index 00000000..68b7804e
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/FirstName.kt
@@ -0,0 +1,36 @@
+package com.lyra.app.newsletter.domain
+
+import com.lyra.app.newsletter.domain.exceptions.FirstNameNotValidException
+import com.lyra.common.domain.BaseValidateValueObject
+
+private const val NAME_LEN = 150
+
+/**
+ * Email value object
+ * @param firstname first name value
+ * @throws FirstNameNotValidException if first name is not valid
+ * @see BaseValidateValueObject
+ * @see BaseValueObject
+ * @see FirstNameNotValidException
+ */
+data class FirstName(val firstname: String) : BaseValidateValueObject(firstname) {
+ /**
+ * Validate first name value object with regex
+ * @param value first name value
+ * @throws FirstNameNotValidException if first name is not valid
+ */
+ override fun validate(value: String) {
+ val firstname = value.trim()
+ if (firstname.isEmpty() || firstname.length > NAME_LEN || containsInvalidCharacters(
+ firstname,
+ )
+ ) {
+ throw FirstNameNotValidException(value)
+ }
+ }
+
+ private fun containsInvalidCharacters(firstname: String): Boolean =
+ !firstname.matches(Regex("^[a-zA-ZÀ-ÿ\\s]{1,150}\$"))
+
+ override fun toString(): String = firstname
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/LastName.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/LastName.kt
new file mode 100644
index 00000000..c6087e1f
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/LastName.kt
@@ -0,0 +1,30 @@
+package com.lyra.app.newsletter.domain
+
+import com.lyra.app.newsletter.domain.exceptions.LastNameNotValidException
+import com.lyra.common.domain.BaseValidateValueObject
+
+private const val NAME_LEN = 150
+
+/**
+ * Email value object
+ * @param lastname last name value
+ * @throws LastNameNotValidException if last name is not valid
+ * @see BaseValidateValueObject
+ * @see BaseValueObject
+ * @see LastNameNotValidException
+ */
+data class LastName(val lastname: String) : BaseValidateValueObject(lastname) {
+ /**
+ * Validate last name value object
+ * @param value last name value
+ * @throws LastNameNotValidException if last name is not valid
+ */
+ override fun validate(value: String) {
+ val lastname = value.trim()
+ if (lastname.isEmpty() || lastname.length > NAME_LEN) {
+ throw LastNameNotValidException(value)
+ }
+ }
+
+ override fun toString(): String = lastname
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/Name.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/Name.kt
new file mode 100644
index 00000000..1622712d
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/Name.kt
@@ -0,0 +1,22 @@
+package com.lyra.app.newsletter.domain
+
+data class Name(val firstName: FirstName, val lastName: LastName?) : Comparable {
+
+ constructor(firstName: String, lastName: String) : this(
+ FirstName(firstName),
+ LastName(lastName),
+ )
+
+ /**
+ * Returns the full name of the user (first name + last name)
+ * @return the full name of the user
+ */
+ fun fullName(): String = "$firstName $lastName"
+
+ /**
+ * Compares this object with the specified object for order. Returns zero if this object is equal
+ * to the specified [other] object, a negative number if it's less than [other], or a positive number
+ * if it's greater than [other].
+ */
+ override operator fun compareTo(other: Name): Int = fullName().compareTo(other.fullName())
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/Subscriber.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/Subscriber.kt
new file mode 100644
index 00000000..e5e9d3bc
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/Subscriber.kt
@@ -0,0 +1,57 @@
+package com.lyra.app.newsletter.domain
+
+import com.lyra.app.newsletter.domain.event.SubscriberCreatedEvent
+import com.lyra.common.domain.BaseEntity
+import com.lyra.common.domain.email.Email
+import java.util.*
+
+class Subscriber(
+ override val id: SubscriberId,
+ val email: Email,
+ var name: Name,
+ var status: SubscriberStatus = SubscriberStatus.ENABLED
+) : BaseEntity() {
+ fun updateName(name: Name) {
+ this.name = name
+ }
+
+ fun updateStatus(status: SubscriberStatus) {
+ this.status = status
+ }
+
+ companion object {
+ fun create(
+ id: SubscriberId,
+ email: Email,
+ name: Name,
+ status: SubscriberStatus = SubscriberStatus.ENABLED
+ ): Subscriber {
+ val subscriber = Subscriber(
+ id = id,
+ email = email,
+ name = name,
+ status = status,
+ )
+ subscriber.record(
+ SubscriberCreatedEvent(
+ subscriber.id.toString(),
+ subscriber.email.email,
+ subscriber.name.fullName(),
+ subscriber.status.name,
+ ),
+ )
+ return subscriber
+ }
+ fun create(
+ email: String,
+ firstname: String,
+ lastname: String,
+ status: SubscriberStatus = SubscriberStatus.ENABLED
+ ): Subscriber = create(
+ id = SubscriberId(UUID.randomUUID().toString()),
+ email = Email(email),
+ name = Name(FirstName(firstname), LastName(lastname)),
+ status = status,
+ )
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberId.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberId.kt
new file mode 100644
index 00000000..e3a1004e
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberId.kt
@@ -0,0 +1,8 @@
+package com.lyra.app.newsletter.domain
+
+import com.lyra.common.domain.BaseId
+import java.util.*
+
+class SubscriberId(private val id: UUID) : BaseId(id) {
+ constructor(id: String) : this(UUID.fromString(id))
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberRepository.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberRepository.kt
new file mode 100644
index 00000000..4ef146c4
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberRepository.kt
@@ -0,0 +1,13 @@
+package com.lyra.app.newsletter.domain
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ *
+ * @created 6/1/24
+ */
+interface SubscriberRepository {
+ suspend fun create(subscriber: Subscriber)
+ suspend fun searchAll(): Flow
+ suspend fun searchActive(): Flow
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberStatus.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberStatus.kt
new file mode 100644
index 00000000..a1dbf40a
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriberStatus.kt
@@ -0,0 +1,5 @@
+package com.lyra.app.newsletter.domain
+
+enum class SubscriberStatus {
+ ENABLED, DISABLED, BLOCKLISTED
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriptionList.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriptionList.kt
new file mode 100644
index 00000000..1438a8c6
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriptionList.kt
@@ -0,0 +1,42 @@
+package com.lyra.app.newsletter.domain
+
+import com.lyra.common.domain.BaseEntity
+import java.util.*
+
+/**
+ * A subscription list is a list of subscribers.
+ * It can be used to send newsletters to a group of subscribers.
+ * @created 6/1/24
+ */
+data class SubscriptionList(
+ override val id: SubscriptionListId,
+ var name: String,
+ var description: String? = null,
+ val subscribers: MutableList = mutableListOf()
+) : BaseEntity() {
+ fun addSubscriber(subscriber: Subscriber) {
+ subscribers.add(subscriber)
+ }
+
+ fun removeSubscriber(subscriber: Subscriber) {
+ subscribers.remove(subscriber)
+ }
+
+ fun updateName(name: String) {
+ this.name = name
+ }
+
+ fun updateDescription(description: String) {
+ this.description = description
+ }
+
+ companion object {
+ fun create(name: String, description: String? = null): SubscriptionList {
+ return SubscriptionList(
+ id = SubscriptionListId(UUID.randomUUID()),
+ name = name,
+ description = description,
+ )
+ }
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriptionListId.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriptionListId.kt
new file mode 100644
index 00000000..b1059877
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/SubscriptionListId.kt
@@ -0,0 +1,8 @@
+package com.lyra.app.newsletter.domain
+
+import com.lyra.common.domain.BaseId
+import java.util.*
+
+class SubscriptionListId(private val id: UUID) : BaseId(id) {
+ constructor(id: String) : this(UUID.fromString(id))
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/event/SubscriberCreatedEvent.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/event/SubscriberCreatedEvent.kt
new file mode 100644
index 00000000..ca4f4412
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/event/SubscriberCreatedEvent.kt
@@ -0,0 +1,14 @@
+package com.lyra.app.newsletter.domain.event
+
+import com.lyra.common.domain.bus.event.BaseDomainEvent
+
+/**
+ *
+ * @created 6/1/24
+ */
+data class SubscriberCreatedEvent(
+ val subscriberId: String,
+ val email: String,
+ val name: String,
+ val status: String
+) : BaseDomainEvent()
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/exceptions/NameExceptions.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/exceptions/NameExceptions.kt
new file mode 100644
index 00000000..d5dbc724
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/domain/exceptions/NameExceptions.kt
@@ -0,0 +1,29 @@
+package com.lyra.app.newsletter.domain.exceptions
+
+import com.lyra.common.domain.error.BusinessRuleValidationException
+
+/**
+ * Exception thrown when the name is not valid
+ *
+ * @created 2/7/23
+ */
+sealed class InvalidArgumentNameException(
+ override val message: String,
+ override val cause: Throwable? = null
+) : BusinessRuleValidationException(message, cause)
+
+/**
+ * Exception thrown when the name is not valid
+ * @param id the name that is not valid
+ * @param cause the cause of the exception
+ */
+data class FirstNameNotValidException(val id: String, override val cause: Throwable? = null) :
+ InvalidArgumentNameException("The first name <$id> is not valid", cause)
+
+/**
+ * Exception thrown when the name is not valid
+ * @param id the name that is not valid
+ * @param cause the cause of the exception
+ */
+data class LastNameNotValidException(val id: String, override val cause: Throwable? = null) :
+ InvalidArgumentNameException("The last name <$id> is not valid", cause)
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberController.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberController.kt
new file mode 100644
index 00000000..ff2eda9b
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberController.kt
@@ -0,0 +1,46 @@
+package com.lyra.app.newsletter.infrastructure.http
+
+import com.lyra.app.newsletter.application.SubscribeNewsletterCommand
+import com.lyra.app.newsletter.infrastructure.http.request.SubscribeNewsletterRequest
+import com.lyra.common.domain.bus.Mediator
+import com.lyra.spring.boot.ApiController
+import io.swagger.v3.oas.annotations.Operation
+import io.swagger.v3.oas.annotations.responses.ApiResponse
+import io.swagger.v3.oas.annotations.responses.ApiResponses
+import jakarta.validation.Valid
+import java.net.URI
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PutMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+@RequestMapping(value = ["/api"], produces = ["application/vnd.api.v1+json"])
+class NewsletterSubscriberController(
+ mediator: Mediator,
+) : ApiController(mediator) {
+ @Operation(summary = "Subscribe to newsletter")
+ @ApiResponses(
+ ApiResponse(responseCode = "201", description = "Created"),
+ ApiResponse(responseCode = "500", description = "Internal server error"),
+ )
+ @PutMapping("/newsletter/subscribers/{id}")
+ suspend fun subscribe(
+ @PathVariable id: String,
+ @Valid @RequestBody request: SubscribeNewsletterRequest
+ ): ResponseEntity {
+
+ dispatch(
+ SubscribeNewsletterCommand(
+ id,
+ request.email,
+ request.firstname,
+ request.lastname,
+ ),
+ )
+
+ return ResponseEntity.created(URI.create("/api/newsletter/subscribers/$id")).build()
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/http/request/SubscribeNewsletterRequest.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/http/request/SubscribeNewsletterRequest.kt
new file mode 100644
index 00000000..4dc7af7b
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/http/request/SubscribeNewsletterRequest.kt
@@ -0,0 +1,13 @@
+package com.lyra.app.newsletter.infrastructure.http.request
+
+import jakarta.validation.constraints.Email
+import jakarta.validation.constraints.NotBlank
+
+data class SubscribeNewsletterRequest(
+ @field:NotBlank(message = "Email is required")
+ @field:Email(message = "Email is not valid")
+ val email: String,
+ @field:NotBlank(message = "Firstname is required")
+ var firstname: String,
+ var lastname: String? = null
+)
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/SubscriberRepositoryRepository.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/SubscriberRepositoryRepository.kt
new file mode 100644
index 00000000..da8e3aa5
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/SubscriberRepositoryRepository.kt
@@ -0,0 +1,50 @@
+package com.lyra.app.newsletter.infrastructure.persistence
+
+import com.lyra.app.newsletter.domain.Subscriber
+import com.lyra.app.newsletter.domain.SubscriberRepository
+import com.lyra.app.newsletter.domain.SubscriberStatus
+import com.lyra.app.newsletter.infrastructure.persistence.mapper.SubscriberMapper.toDomain
+import com.lyra.app.newsletter.infrastructure.persistence.mapper.SubscriberMapper.toEntity
+import com.lyra.app.newsletter.infrastructure.persistence.repository.SubscriberRegistratorR2dbcRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.reactive.asFlow
+import org.slf4j.LoggerFactory
+import org.springframework.dao.DuplicateKeyException
+import org.springframework.stereotype.Repository
+import reactor.core.publisher.Mono
+
+@Repository
+class SubscriberRepositoryRepository(
+ private val subscriberRegistratorR2dbcRepository: SubscriberRegistratorR2dbcRepository
+) : SubscriberRepository {
+ override suspend fun create(subscriber: Subscriber) {
+ val entity = subscriber.toEntity()
+
+ subscriberRegistratorR2dbcRepository
+ .save(entity)
+ .onErrorResume { throwable ->
+ if (throwable is DuplicateKeyException) {
+ log.info("Subscriber already exists in the database: ${subscriber.email.email}")
+ Mono.empty() // Ignore the exception and continue with an empty Mono
+ } else {
+ log.error("Error while saving subscriber to database: ${subscriber.email.email}", throwable)
+ Mono.error(throwable) // Propagate the exception
+ }
+ }
+ .subscribe()
+ }
+
+ override suspend fun searchAll(): Flow {
+ return subscriberRegistratorR2dbcRepository.findAll()
+ .map { it.toDomain() }.asFlow()
+ }
+
+ override suspend fun searchActive(): Flow {
+ return subscriberRegistratorR2dbcRepository.findAllByStatus(SubscriberStatus.ENABLED)
+ .map { it.toDomain() }.asFlow()
+ }
+
+ companion object {
+ private val log = LoggerFactory.getLogger(SubscriberRepositoryRepository::class.java)
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberConverter.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberConverter.kt
new file mode 100644
index 00000000..6909ed30
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberConverter.kt
@@ -0,0 +1,41 @@
+package com.lyra.app.newsletter.infrastructure.persistence.converter
+
+import com.lyra.app.newsletter.domain.SubscriberStatus
+import com.lyra.app.newsletter.infrastructure.persistence.entity.SubscriberEntity
+import io.r2dbc.spi.Row
+import java.time.LocalDateTime
+import java.util.UUID
+import org.springframework.core.convert.converter.Converter
+import org.springframework.data.convert.ReadingConverter
+
+/**
+ *
+ * @created 7/1/24
+ */
+@ReadingConverter
+class SubscriberConverter : Converter {
+ /**
+ * Convert the source object of type `S` to target type `T`.
+ * @param source the source object to convert, which must be an instance of `S` (never `null`)
+ * @return the converted object, which must be an instance of `T` (potentially `null`)
+ * @throws IllegalArgumentException if the source cannot be converted to the desired target type
+ */
+ override fun convert(source: Row): SubscriberEntity {
+ val sourceId = source.get("id", UUID::class.java)
+ val sourceEmail = source.get("email", String::class.java)
+ val sourceFirstname = source.get("firstname", String::class.java)
+ val sourceLastname = source.get("lastname", String::class.java)
+ val sourceStatus = source.get("status", SubscriberStatus::class.java)
+ val sourceCreatedAt = source.get("created_at", LocalDateTime::class.java)
+ val sourceUpdatedAt = source.get("updated_at", LocalDateTime::class.java)
+ return SubscriberEntity(
+ id = sourceId!!,
+ email = sourceEmail!!,
+ firstname = sourceFirstname!!,
+ lastname = sourceLastname!!,
+ status = sourceStatus!!,
+ createdAt = sourceCreatedAt!!,
+ updatedAt = sourceUpdatedAt!!,
+ )
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberStatusWriterConverter.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberStatusWriterConverter.kt
new file mode 100644
index 00000000..6d2e7b53
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberStatusWriterConverter.kt
@@ -0,0 +1,8 @@
+package com.lyra.app.newsletter.infrastructure.persistence.converter
+
+import com.lyra.app.newsletter.domain.SubscriberStatus
+import org.springframework.data.convert.WritingConverter
+import org.springframework.data.r2dbc.convert.EnumWriteSupport
+
+@WritingConverter
+class SubscriberStatusWriterConverter : EnumWriteSupport()
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/entity/AuditableEntity.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/entity/AuditableEntity.kt
new file mode 100644
index 00000000..db595c33
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/entity/AuditableEntity.kt
@@ -0,0 +1,12 @@
+package com.lyra.app.newsletter.infrastructure.persistence.entity
+
+import java.time.LocalDateTime
+
+/**
+ *
+ * @created 7/1/24
+ */
+abstract class AuditableEntity(
+ open var createdAt: LocalDateTime = LocalDateTime.now(),
+ open var updatedAt: LocalDateTime? = null
+)
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/entity/SubscriberEntity.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/entity/SubscriberEntity.kt
new file mode 100644
index 00000000..0ff9f298
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/entity/SubscriberEntity.kt
@@ -0,0 +1,40 @@
+package com.lyra.app.newsletter.infrastructure.persistence.entity
+
+import com.lyra.app.newsletter.domain.SubscriberStatus
+import java.time.LocalDateTime
+import java.util.*
+import org.springframework.data.relational.core.mapping.Column
+import org.springframework.data.relational.core.mapping.Table
+
+@Table("subscribers")
+data class SubscriberEntity(
+ // id is generated by the frontend and sent to the backend as a UUID
+ val id: UUID,
+ var email: String,
+ var firstname: String,
+ var lastname: String? = null,
+ @Column("status")
+ var status: SubscriberStatus,
+ override var createdAt: LocalDateTime = LocalDateTime.now(),
+ override var updatedAt: LocalDateTime? = LocalDateTime.now(),
+) : AuditableEntity() {
+
+ companion object {
+ fun create(
+ id: UUID,
+ email: String,
+ firstname: String,
+ lastname: String,
+ status: SubscriberStatus
+ ): SubscriberEntity {
+ val entity = SubscriberEntity(
+ id = id,
+ email = email,
+ firstname = firstname,
+ lastname = lastname,
+ status = status,
+ )
+ return entity
+ }
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/mapper/SubscriberMapper.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/mapper/SubscriberMapper.kt
new file mode 100644
index 00000000..d3161046
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/mapper/SubscriberMapper.kt
@@ -0,0 +1,33 @@
+package com.lyra.app.newsletter.infrastructure.persistence.mapper
+
+import com.lyra.app.newsletter.domain.FirstName
+import com.lyra.app.newsletter.domain.LastName
+import com.lyra.app.newsletter.domain.Name
+import com.lyra.app.newsletter.domain.Subscriber
+import com.lyra.app.newsletter.domain.SubscriberId
+import com.lyra.app.newsletter.infrastructure.persistence.entity.SubscriberEntity
+import com.lyra.common.domain.email.Email
+
+object SubscriberMapper {
+ fun Subscriber.toEntity(): SubscriberEntity {
+ return SubscriberEntity.create(
+ id = id.value,
+ email = email.value,
+ firstname = name.firstName.toString(),
+ lastname = name.lastName.toString(),
+ status = status,
+ )
+ }
+
+ fun SubscriberEntity.toDomain(): Subscriber {
+ return Subscriber(
+ id = SubscriberId(id),
+ email = Email(email),
+ name = Name(
+ firstName = FirstName(firstname),
+ lastName = lastname?.let { LastName(it) },
+ ),
+ status = status,
+ )
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/repository/SubscriberRegistratorR2dbcRepository.kt b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/repository/SubscriberRegistratorR2dbcRepository.kt
new file mode 100644
index 00000000..2f556e83
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/newsletter/infrastructure/persistence/repository/SubscriberRegistratorR2dbcRepository.kt
@@ -0,0 +1,13 @@
+package com.lyra.app.newsletter.infrastructure.persistence.repository
+
+import com.lyra.app.newsletter.domain.SubscriberStatus
+import com.lyra.app.newsletter.infrastructure.persistence.entity.SubscriberEntity
+import java.util.UUID
+import org.springframework.data.repository.reactive.ReactiveCrudRepository
+import org.springframework.stereotype.Repository
+import reactor.core.publisher.Flux
+
+@Repository
+interface SubscriberRegistratorR2dbcRepository : ReactiveCrudRepository {
+ fun findAllByStatus(status: SubscriberStatus): Flux
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/NewContentNewsletterSender.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/NewContentNewsletterSender.kt
new file mode 100644
index 00000000..33014ae4
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/NewContentNewsletterSender.kt
@@ -0,0 +1,53 @@
+package com.lyra.app.notifications.application
+
+import com.lyra.app.newsletter.application.SubscriberResponse
+import com.lyra.app.newsletter.application.search.active.SearchAllActiveSubscribersQuery
+import com.lyra.app.notifications.domain.EmailMessageId
+import com.lyra.app.notifications.domain.EmailSender
+import com.lyra.app.notifications.domain.NewContentNewsletter
+import com.lyra.app.notifications.domain.event.NewContentNewsletterEmailSent
+import com.lyra.common.domain.Service
+import com.lyra.common.domain.bus.Mediator
+import com.lyra.common.domain.bus.event.EventPublisher
+import org.slf4j.LoggerFactory
+
+/**
+ *
+ * @created 12/1/24
+ */
+@Service
+class NewContentNewsletterSender(
+ private val emailSender: EmailSender,
+ private val mediator: Mediator,
+ private val eventPublisher: EventPublisher
+) {
+
+ suspend fun sendNewContentNewsletter() {
+ log.info("Sending new content newsletter")
+ val subscribers = mediator.send(SearchAllActiveSubscribersQuery())
+ .also { log.info("Found ${it.subscribers.size} subscribers") }.subscribers
+
+ subscribers.forEach {
+ send(it)
+ }
+ }
+
+ private suspend fun send(subscriber: SubscriberResponse) {
+ val contentNewsletter = NewContentNewsletter.send(
+ id = EmailMessageId(subscriber.id),
+ subscriber = subscriber,
+ )
+ emailSender.send(contentNewsletter)
+ eventPublisher.publish(
+ NewContentNewsletterEmailSent(
+ id = contentNewsletter.id.value.toString(),
+ subscriberId = subscriber.id,
+ email = subscriber.email,
+ ),
+ )
+ }
+
+ companion object {
+ private val log = LoggerFactory.getLogger(NewContentNewsletterSender::class.java)
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommand.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommand.kt
new file mode 100644
index 00000000..707deaaf
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommand.kt
@@ -0,0 +1,9 @@
+package com.lyra.app.notifications.application
+
+import com.lyra.common.domain.bus.command.Command
+
+/**
+ *
+ * @created 12/1/24
+ */
+class SendNewContentNewsletterCommand : Command
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommandHandler.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommandHandler.kt
new file mode 100644
index 00000000..2b58e78a
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommandHandler.kt
@@ -0,0 +1,23 @@
+package com.lyra.app.notifications.application
+
+import com.lyra.common.domain.Service
+import com.lyra.common.domain.bus.command.CommandHandler
+import org.slf4j.LoggerFactory
+
+/**
+ *
+ * @created 12/1/24
+ */
+@Service
+class SendNewContentNewsletterCommandHandler(
+ private val sender: NewContentNewsletterSender
+) : CommandHandler {
+ override suspend fun handle(command: SendNewContentNewsletterCommand) {
+ log.info("Handling command: $command")
+ sender.sendNewContentNewsletter()
+ }
+
+ companion object {
+ private val log = LoggerFactory.getLogger(SendNewContentNewsletterCommandHandler::class.java)
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailMessage.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailMessage.kt
new file mode 100644
index 00000000..a35f71b5
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailMessage.kt
@@ -0,0 +1,43 @@
+package com.lyra.app.notifications.domain
+
+import com.lyra.common.domain.AggregateRoot
+import com.lyra.common.domain.Generated
+import com.lyra.common.domain.email.Email
+
+/**
+ *
+ * @created 9/1/24
+ */
+abstract class EmailMessage(
+ override val id: EmailMessageId,
+ val from: Email,
+ val to: Email,
+ val subject: String,
+ val body: String
+) : AggregateRoot() {
+ @Generated
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is EmailMessage) return false
+ if (!super.equals(other)) return false
+
+ if (id != other.id) return false
+ if (from != other.from) return false
+ if (to != other.to) return false
+ if (subject != other.subject) return false
+ if (body != other.body) return false
+
+ return true
+ }
+
+ @Generated
+ override fun hashCode(): Int {
+ var result = super.hashCode()
+ result = 31 * result + id.hashCode()
+ result = 31 * result + from.hashCode()
+ result = 31 * result + to.hashCode()
+ result = 31 * result + subject.hashCode()
+ result = 31 * result + body.hashCode()
+ return result
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailMessageId.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailMessageId.kt
new file mode 100644
index 00000000..472d3110
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailMessageId.kt
@@ -0,0 +1,8 @@
+package com.lyra.app.notifications.domain
+
+import com.lyra.common.domain.BaseId
+import java.util.*
+
+class EmailMessageId(private val id: UUID) : BaseId(id) {
+ constructor(id: String) : this(UUID.fromString(id))
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailSender.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailSender.kt
new file mode 100644
index 00000000..0fbfd486
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/EmailSender.kt
@@ -0,0 +1,9 @@
+package com.lyra.app.notifications.domain
+
+/**
+ *
+ * @created 9/1/24
+ */
+interface EmailSender {
+ suspend fun send(emailMessage: EmailMessage)
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/NewContentNewsletter.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/NewContentNewsletter.kt
new file mode 100644
index 00000000..985d5342
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/NewContentNewsletter.kt
@@ -0,0 +1,48 @@
+package com.lyra.app.notifications.domain
+
+import com.lyra.app.newsletter.application.SubscriberResponse
+import com.lyra.app.notifications.domain.event.NewContentNewsletterEmailSent
+import com.lyra.common.domain.Generated
+import com.lyra.common.domain.email.Email
+
+class NewContentNewsletter(
+ override val id: EmailMessageId,
+ subscriber: SubscriberResponse
+) : EmailMessage(
+ id,
+ Email("news@lyra.com"),
+ Email(subscriber.email),
+ "Welcome to Lyra",
+ subscriber.name.plus(" welcome to Lyra!"),
+) {
+ @Generated
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is NewContentNewsletter) return false
+ if (!super.equals(other)) return false
+
+ if (id != other.id) return false
+
+ return true
+ }
+
+ @Generated
+ override fun hashCode(): Int {
+ var result = super.hashCode()
+ result = 31 * result + id.hashCode()
+ return result
+ }
+
+ companion object {
+ fun send(id: EmailMessageId, subscriber: SubscriberResponse): NewContentNewsletter {
+ val emailMessage = NewContentNewsletter(id, subscriber)
+ emailMessage.record(
+ NewContentNewsletterEmailSent(
+ id.value.toString(),
+ subscriber.id, subscriber.email,
+ ),
+ )
+ return emailMessage
+ }
+ }
+}
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/event/NewContentNewsletterEmailSent.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/event/NewContentNewsletterEmailSent.kt
new file mode 100644
index 00000000..6bf395fb
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/domain/event/NewContentNewsletterEmailSent.kt
@@ -0,0 +1,13 @@
+package com.lyra.app.notifications.domain.event
+
+import com.lyra.common.domain.bus.event.BaseDomainEvent
+
+/**
+ *
+ * @created 9/1/24
+ */
+data class NewContentNewsletterEmailSent(
+ val id: String,
+ val subscriberId: String,
+ val email: String,
+) : BaseDomainEvent()
diff --git a/apps/backend/src/main/kotlin/com/lyra/app/notifications/infrastructure/SendgridEmailSender.kt b/apps/backend/src/main/kotlin/com/lyra/app/notifications/infrastructure/SendgridEmailSender.kt
new file mode 100644
index 00000000..18e54dad
--- /dev/null
+++ b/apps/backend/src/main/kotlin/com/lyra/app/notifications/infrastructure/SendgridEmailSender.kt
@@ -0,0 +1,42 @@
+package com.lyra.app.notifications.infrastructure
+
+import com.lyra.app.notifications.domain.EmailMessage
+import com.lyra.app.notifications.domain.EmailSender
+import com.lyra.common.domain.Service
+import com.sendgrid.Method
+import com.sendgrid.Request
+import com.sendgrid.SendGrid
+import com.sendgrid.helpers.mail.Mail
+import com.sendgrid.helpers.mail.objects.Content
+import com.sendgrid.helpers.mail.objects.Email
+import java.io.IOException
+import org.slf4j.LoggerFactory
+import org.springframework.beans.factory.annotation.Value
+
+@Service
+class SendgridEmailSender(
+ @Value("\${notification.email.sendgrid-api-key}")
+ private val apiKey: String,
+ private val sg: SendGrid = SendGrid(apiKey)
+) : EmailSender {
+ override suspend fun send(emailMessage: EmailMessage) {
+ log.info("Sending email to ${emailMessage.to.value}")
+ val from = Email(emailMessage.from.value)
+ val to = Email(emailMessage.to.value)
+ val content = Content("text/plain", emailMessage.body)
+ val mail = Mail(from, emailMessage.subject, to, content)
+ val request = Request()
+ try {
+ request.method = Method.POST
+ request.endpoint = "mail/send"
+ request.body = mail.build()
+ val response = sg.api(request)
+ log.info("Email sent to ${emailMessage.to.value} with status code ${response.statusCode}")
+ } catch (ex: IOException) {
+ log.error("Error sending email to ${emailMessage.to.value}", ex)
+ }
+ }
+ companion object {
+ private val log = LoggerFactory.getLogger(SendgridEmailSender::class.java)
+ }
+}
diff --git a/apps/backend/src/main/resources/application.yml b/apps/backend/src/main/resources/application.yml
index ddfb1436..b6d517d4 100644
--- a/apps/backend/src/main/resources/application.yml
+++ b/apps/backend/src/main/resources/application.yml
@@ -1,11 +1,23 @@
# Configuration file for the application
server:
port: 8080
+logging:
+ level:
+ org.springframework.r2dbc: DEBUG
+ com.lyra: DEBUG
spring:
application:
name: ${application.name}
main:
allow-bean-definition-overriding: true
+ r2dbc:
+ url: r2dbc:postgresql://localhost:5432/postgres
+ username: postgres
+ password: postgres
+ pool:
+ enabled: true
+ initial-size: 10
+ max-size: 20
application:
name: lyra
@@ -17,7 +29,9 @@ management:
web:
exposure:
include: info, health, beans, openapi, swagger-ui
-
+notification:
+ email:
+ sendgrid-api-key: ${SENDGRID_API_KEY:WILL_BE_REPLACED}
springdoc:
show-actuator: true
api-docs:
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/LyraApplicationTests.kt b/apps/backend/src/test/kotlin/com/lyra/app/LyraApplicationTests.kt
index 7a78bbfc..adb55a40 100644
--- a/apps/backend/src/test/kotlin/com/lyra/app/LyraApplicationTests.kt
+++ b/apps/backend/src/test/kotlin/com/lyra/app/LyraApplicationTests.kt
@@ -1,10 +1,15 @@
package com.lyra.app
+import com.lyra.spring.boot.bus.event.EventConfiguration
import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
class LyraApplicationTests {
+ @Suppress("UnusedPrivateProperty")
+ @Autowired
+ private lateinit var eventConfiguration: EventConfiguration
@Suppress("EmptyFunctionBlock")
@Test
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/TestDemoApplication.kt b/apps/backend/src/test/kotlin/com/lyra/app/TestDemoApplication.kt
new file mode 100644
index 00000000..f9af0158
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/TestDemoApplication.kt
@@ -0,0 +1,21 @@
+package com.lyra.app
+
+import org.springframework.boot.fromApplication
+import org.springframework.boot.test.context.TestConfiguration
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection
+import org.springframework.boot.with
+import org.springframework.context.annotation.Bean
+import org.testcontainers.containers.PostgreSQLContainer
+import org.testcontainers.utility.DockerImageName
+
+@TestConfiguration(proxyBeanMethods = false)
+class TestDemoApplication {
+
+ @Bean
+ @ServiceConnection
+ fun postgresContainer(): PostgreSQLContainer<*> = PostgreSQLContainer(DockerImageName.parse("postgres:latest"))
+}
+
+fun main(args: Array) {
+ fromApplication().with(TestDemoApplication::class).run(*args)
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/SubscriberStub.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/SubscriberStub.kt
new file mode 100644
index 00000000..f84b8d19
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/SubscriberStub.kt
@@ -0,0 +1,44 @@
+package com.lyra.app.newsletter
+
+import com.lyra.app.newsletter.domain.FirstName
+import com.lyra.app.newsletter.domain.LastName
+import com.lyra.app.newsletter.domain.Name
+import com.lyra.app.newsletter.domain.Subscriber
+import com.lyra.app.newsletter.domain.SubscriberId
+import com.lyra.app.newsletter.domain.SubscriberStatus
+import com.lyra.common.domain.email.Email
+import java.util.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import net.datafaker.Faker
+
+/**
+ *
+ * @created 11/1/24
+ */
+object SubscriberStub {
+ private val faker = Faker()
+ fun create(
+ id: String = UUID.randomUUID().toString(),
+ email: String = faker.internet().emailAddress(),
+ firstname: String = faker.name().firstName(),
+ lastname: String = faker.name().lastName(),
+ status: SubscriberStatus = SubscriberStatus.ENABLED,
+ ): Subscriber {
+ return Subscriber(
+ id = SubscriberId(id),
+ email = Email(email),
+ name = Name(FirstName(firstname), LastName(lastname)),
+ status = status,
+ )
+ }
+
+ fun dummyRandomSubscribersList(size: Int = 10): List {
+ return (1..size).map {
+ create()
+ }
+ }
+
+ fun dummyRandomSubscribersFlow(size: Int = 10): Flow =
+ dummyRandomSubscribersList(size).asFlow()
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/CreateSubscribeNewsletterCommandHandlerTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/CreateSubscribeNewsletterCommandHandlerTest.kt
new file mode 100644
index 00000000..8d9fde48
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/CreateSubscribeNewsletterCommandHandlerTest.kt
@@ -0,0 +1,52 @@
+package com.lyra.app.newsletter.application
+
+import com.lyra.app.UnitTest
+import com.lyra.app.newsletter.domain.Subscriber
+import com.lyra.app.newsletter.domain.SubscriberRepository
+import com.lyra.app.newsletter.domain.event.SubscriberCreatedEvent
+import com.lyra.common.domain.bus.event.EventPublisher
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.mockkClass
+import java.util.*
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+@UnitTest
+internal class CreateSubscribeNewsletterCommandHandlerTest {
+
+ private var eventPublisher: EventPublisher = mockk()
+
+ private var subscriberRepository = mockkClass(SubscriberRepository::class)
+
+ private var subscriberRegistrator: SubscriberRegistrator = SubscriberRegistrator(
+ subscriberRepository,
+ eventPublisher,
+ )
+
+ private var createSubscribeNewsletterCommandHandler: CreateSubscribeNewsletterCommandHandler =
+ CreateSubscribeNewsletterCommandHandler(subscriberRegistrator)
+ private val email = "john.doe@lyra.com"
+ private val firstname = "John"
+ private val lastname = "Doe"
+
+ @BeforeEach
+ fun setUp() {
+ coEvery { subscriberRepository.create(any(Subscriber::class)) } returns Unit
+ coEvery { eventPublisher.publish(any(SubscriberCreatedEvent::class)) } returns Unit
+ }
+
+ @Test
+ fun `should register a subscriber`() = runBlocking {
+ // Given
+ val command =
+ SubscribeNewsletterCommand(UUID.randomUUID().toString(), email, firstname, lastname)
+ // When
+ createSubscribeNewsletterCommandHandler.handle(command)
+ // Then
+ coVerify(exactly = 1) { subscriberRepository.create(any(Subscriber::class)) }
+ coVerify(exactly = 1) { eventPublisher.publish(any(SubscriberCreatedEvent::class)) }
+ }
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQueryHandlerTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQueryHandlerTest.kt
new file mode 100644
index 00000000..ebf47c64
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/search/active/SearchAllActiveSubscribersQueryHandlerTest.kt
@@ -0,0 +1,38 @@
+package com.lyra.app.newsletter.application.search.active
+
+import com.lyra.app.newsletter.SubscriberStub
+import com.lyra.app.newsletter.domain.SubscriberRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+internal class SearchAllActiveSubscribersQueryHandlerTest {
+ private val repository: SubscriberRepository = mockk()
+
+ private var searcher: ActiveSubscriberSearcher = ActiveSubscriberSearcher(repository)
+
+ private var searchAllActiveSubscribersQueryHandler: SearchAllActiveSubscribersQueryHandler =
+ SearchAllActiveSubscribersQueryHandler(searcher)
+
+ @BeforeEach
+ fun setUp() {
+ coEvery { repository.searchActive() } returns SubscriberStub.dummyRandomSubscribersFlow()
+ }
+
+ @Test
+ fun `should search all active subscribers`() = runBlocking {
+ // Given
+ val command = SearchAllActiveSubscribersQuery()
+ // When
+ val response = searchAllActiveSubscribersQueryHandler.handle(command)
+ // Then
+ assertTrue(response.subscribers.isNotEmpty())
+ assertEquals(10, response.subscribers.size)
+ coVerify(exactly = 1) { repository.searchActive() }
+ }
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQueryHandlerTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQueryHandlerTest.kt
new file mode 100644
index 00000000..95255475
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/application/search/all/SearchAllSubscribersQueryHandlerTest.kt
@@ -0,0 +1,39 @@
+package com.lyra.app.newsletter.application.search.all
+
+import com.lyra.app.newsletter.SubscriberStub
+import com.lyra.app.newsletter.domain.SubscriberRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockkClass
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+private const val NUM_SUBSCRIBER = 100
+
+internal class SearchAllSubscribersQueryHandlerTest {
+ private val repository = mockkClass(SubscriberRepository::class)
+ private val searcher = AllSubscriberSearcher(repository)
+ private val searchAllSubscribersQueryHandler = SearchAllSubscribersQueryHandler(searcher)
+
+ @BeforeEach
+ fun setUp() {
+ coEvery { repository.searchAll() } returns SubscriberStub.dummyRandomSubscribersFlow(
+ NUM_SUBSCRIBER,
+ )
+ }
+
+ @Test
+ fun `should search all subscribers`() = runBlocking {
+ // Given
+ val command = SearchAllSubscribersQuery()
+ // When
+ val response = searchAllSubscribersQueryHandler.handle(command)
+ // Then
+ assertTrue(response.subscribers.isNotEmpty())
+ assertEquals(NUM_SUBSCRIBER, response.subscribers.size)
+ coVerify(exactly = 1) { repository.searchAll() }
+ }
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/FirstNameTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/FirstNameTest.kt
new file mode 100644
index 00000000..84a7301c
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/FirstNameTest.kt
@@ -0,0 +1,76 @@
+package com.lyra.app.newsletter.domain
+
+import com.lyra.app.UnitTest
+import com.lyra.app.newsletter.domain.exceptions.FirstNameNotValidException
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNotEquals
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+
+@UnitTest
+internal class FirstNameTest {
+ @Test
+ fun `should create a valid first name`() {
+ val firstName = FirstName("Yuniel")
+ assertEquals("Yuniel", firstName.value)
+ }
+
+ @Test
+ fun `should throw an exception when create a first name with invalid value`() {
+ val invalidFirstNames = listOf(
+ "123abc", "abc!@#", "abc.def", "abc-def",
+ "abc_def", "abc+def", "abc*def", "abc/def", "abc=def", "abc?def",
+ "abc#def", "abc\$def", "abc%def", "abc^def", "abc&def", "abc(def",
+ "abc)def", "abc[def", "abc]def", "abc{def", "abc}def", "abc:def",
+ "abc;def", "abc\"def", "abc'def", "abcdef", "abc,def",
+ "abc~def", "abc`def", "abc|def", "abc\\def", "abc¡def", "abc¿def",
+ "abc©def", "abc®def", "abc™def", "abc€def", "abc£def", "abc¥def",
+ "abc¢def", "abc§def", "abc¶def", "abc•def", "abc†def", "abc‡def",
+ "abc°def", "abc±def", "abc¶def", "abc•def", "abc†def", "abc‡def",
+ "abc²def", "abc³def", "abc¼def", "abc½def", "yuniel-acosta", "yuniel_acosta",
+ )
+ invalidFirstNames.forEach {
+ assertThrows(FirstNameNotValidException::class.java) {
+ FirstName(it)
+ }
+ }
+ }
+
+ @Test
+ fun `should throw an exception when create a first name with empty value`() {
+ assertThrows(FirstNameNotValidException::class.java) {
+ FirstName("")
+ }
+ }
+
+ @Test
+ fun `should throw an exception when create a first name with blank value`() {
+ assertThrows(FirstNameNotValidException::class.java) {
+ FirstName(" ")
+ }
+ }
+
+ @Test
+ fun `should throw an exception when create a first name with length greater than 150`() {
+ val firstName = (1..256).joinToString("") { "a" }
+ assertThrows(FirstNameNotValidException::class.java) {
+ FirstName(firstName)
+ }
+ }
+
+ @Test
+ fun `compare first name`() {
+ val firstName1 = FirstName("Yuniel")
+ val firstName2 = FirstName("Yuniel")
+ assertEquals(firstName1, firstName2)
+ assertEquals(firstName1.hashCode(), firstName2.hashCode())
+ }
+
+ @Test
+ fun `compare first name with different values`() {
+ val firstName1 = FirstName("Yuniel")
+ val firstName2 = FirstName("David")
+ assertNotEquals(firstName1, firstName2)
+ assertNotEquals(firstName1.hashCode(), firstName2.hashCode())
+ }
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/LastNameTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/LastNameTest.kt
new file mode 100644
index 00000000..9bb0d60b
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/LastNameTest.kt
@@ -0,0 +1,80 @@
+package com.lyra.app.newsletter.domain
+
+import com.lyra.app.UnitTest
+import com.lyra.app.newsletter.domain.exceptions.LastNameNotValidException
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNotEquals
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+
+@UnitTest
+internal class LastNameTest {
+ @Test
+ fun `should create a valid last name`() {
+ val lastNames = listOf(
+ "Acosta",
+ "Acosta Ortiz",
+ "Acosta Pérez",
+ "O'Neil",
+ "D'Angelo",
+ )
+ lastNames.forEach {
+ println("Last Name: $it")
+ val lastName = LastName(it)
+ assertEquals(it, lastName.value)
+ }
+ }
+
+ @Test
+ fun `should throw an exception when create a last name with invalid value`() {
+ val invalidLastNames = listOf(
+ """
+ A voluptatum ex. Ratione adipisci eveniet expedita iste consectetur voluptatibus illum odio optio.
+ Eum facilis autem. Explicabo sequi corrupti eius quis eius adipisci.
+ """.trimIndent(),
+ )
+ invalidLastNames.forEach {
+ println("Last Name: $it")
+ assertThrows(LastNameNotValidException::class.java) {
+ LastName(it)
+ }
+ }
+ }
+
+ @Test
+ fun `should throw an exception when create a last name with empty value`() {
+ assertThrows(LastNameNotValidException::class.java) {
+ LastName("")
+ }
+ }
+
+ @Test
+ fun `should throw an exception when create a last name with blank value`() {
+ assertThrows(LastNameNotValidException::class.java) {
+ LastName(" ")
+ }
+ }
+
+ @Test
+ fun `should throw an exception when create a last name with length greater than 150`() {
+ val lastName = (1..256).joinToString("") { "a" }
+ assertThrows(LastNameNotValidException::class.java) {
+ LastName(lastName)
+ }
+ }
+
+ @Test
+ fun `compare last name`() {
+ val lastName1 = LastName("Acosta")
+ val lastName2 = LastName("Acosta")
+ assertEquals(lastName1, lastName2)
+ }
+
+ @Test
+ fun `compare last name with different values`() {
+ val lastName1 = LastName("Acosta")
+ val lastName2 = LastName("Acosta Ortiz")
+ assertNotEquals(lastName1, lastName2)
+ assertNotEquals(lastName1.hashCode(), lastName2.hashCode())
+ }
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/NameTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/NameTest.kt
new file mode 100644
index 00000000..66b18aad
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/NameTest.kt
@@ -0,0 +1,90 @@
+package com.lyra.app.newsletter.domain
+
+import com.lyra.app.UnitTest
+import com.lyra.app.newsletter.domain.exceptions.FirstNameNotValidException
+import com.lyra.app.newsletter.domain.exceptions.LastNameNotValidException
+import java.util.stream.Stream
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+
+@UnitTest
+internal class NameTest {
+
+ @Test
+ fun `should create a valid name and lastname`() {
+ val names = mapOf(
+ "Yuniel" to "Acosta Pérez",
+ "Neil" to "O'Neil",
+ "Ramón" to "González Pérez",
+ "Ñico" to "López",
+ )
+
+ names.forEach { (firstname, lastname) ->
+ println("Fullname: $firstname $lastname")
+ val name = Name(firstname, lastname)
+ assertEquals(firstname, name.firstName?.value)
+ assertEquals(lastname, name.lastName?.value)
+ assertEquals(name.fullName(), "$firstname $lastname")
+ }
+ }
+
+ @Test
+ fun `should not build without firstname`() {
+ assertThrows(FirstNameNotValidException::class.java) {
+ Name("", "Acosta Pérez")
+ }
+ }
+
+ @Test
+ fun `should not build without lastname`() {
+ assertThrows(LastNameNotValidException::class.java) {
+ Name("Yuniel", "")
+ }
+ }
+
+ @Test
+ fun `should not build with firstname greater than 150 characters`() {
+ val firstname = (1..256).joinToString("") { "a" }
+ assertThrows(FirstNameNotValidException::class.java) {
+ Name(firstname, "Acosta Pérez")
+ }
+ }
+
+ @Test
+ fun `should not build with lastname greater than 150 characters`() {
+ val lastname = (1..256).joinToString("") { "a" }
+ assertThrows(LastNameNotValidException::class.java) {
+ Name("Yuniel", lastname)
+ }
+ }
+
+ @Test
+ fun `should not build with firstname and lastname greater than 150 characters`() {
+ val firstname = (1..256).joinToString("") { "a" }
+ val lastname = (1..256).joinToString("") { "a" }
+ assertThrows(FirstNameNotValidException::class.java) {
+ Name(firstname, lastname)
+ }
+ }
+
+ @Test
+ fun `should get fullname`() {
+ val name = Name("Yuniel", "Acosta Pérez")
+ assertEquals("Yuniel Acosta Pérez", name.fullName())
+ }
+
+ @Test
+ fun shouldSortNames() {
+ val names: List = Stream
+ .of(Name("paul", "Dupond"), Name("jean", "Dupont"), Name("jean", "Dupond"))
+ .sorted()
+ .toList()
+ assertThat(names).containsExactly(
+ Name("jean", "Dupond"),
+ Name("jean", "Dupont"),
+ Name("paul", "Dupond"),
+ )
+ }
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/SubscriberTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/SubscriberTest.kt
new file mode 100644
index 00000000..7dc4f2f2
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/SubscriberTest.kt
@@ -0,0 +1,34 @@
+package com.lyra.app.newsletter.domain
+
+import com.lyra.app.UnitTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+@UnitTest
+internal class SubscriberTest {
+ private val email = "john.doe@lyra.com"
+ private val firstname = "John"
+ private val lastname = "Doe"
+
+ @Test
+ fun `should update name`() {
+ val subscriber = Subscriber.create(email, firstname, lastname)
+ val newName = Name("Jane", "Doe")
+ subscriber.updateName(newName)
+ assertEquals(newName, subscriber.name)
+ }
+
+ @Test
+ fun `should update status`() {
+ val subscriber = Subscriber.create(email, firstname, lastname)
+ val newStatus = SubscriberStatus.BLOCKLISTED
+ subscriber.updateStatus(newStatus)
+ assertEquals(newStatus, subscriber.status)
+ }
+
+ @Test
+ fun `should create a subscriber disabled`() {
+ val subscriber = Subscriber.create(email, firstname, lastname, SubscriberStatus.DISABLED)
+ assertEquals(SubscriberStatus.DISABLED, subscriber.status)
+ }
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/SubscriptionListTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/SubscriptionListTest.kt
new file mode 100644
index 00000000..98c53457
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/domain/SubscriptionListTest.kt
@@ -0,0 +1,56 @@
+package com.lyra.app.newsletter.domain
+
+import com.lyra.app.UnitTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+@UnitTest
+internal class SubscriptionListTest {
+ private val name = "My list"
+ private val email = "john.doe@lyra.com"
+ private val firstname = "John"
+ private val lastname = "Doe"
+
+ @Test
+ fun `should update name`() {
+ val subscriptionList = SubscriptionList.create(name)
+ val newName = "My new list"
+ subscriptionList.updateName(newName)
+ assertEquals(newName, subscriptionList.name)
+ }
+
+ @Test
+ fun `should update description`() {
+ val subscriptionList = SubscriptionList.create(name)
+ val newDescription = "My new description"
+ subscriptionList.updateDescription(newDescription)
+ assertEquals(newDescription, subscriptionList.description)
+ }
+
+ @Test
+ fun `should add subscriber`() {
+ val subscriptionList = SubscriptionList.create(name)
+ val subscriber = Subscriber.create(email, firstname, lastname)
+ subscriptionList.addSubscriber(subscriber)
+ assertEquals(1, subscriptionList.subscribers.size)
+ }
+
+ @Test
+ fun `should remove subscriber`() {
+ val subscriptionList = SubscriptionList.create(name)
+ val subscriber = Subscriber.create(email, firstname, lastname)
+ subscriptionList.addSubscriber(subscriber)
+ subscriptionList.removeSubscriber(subscriber)
+ assertEquals(0, subscriptionList.subscribers.size)
+ }
+
+ @Test
+ fun `should update subscriber`() {
+ val subscriptionList = SubscriptionList.create(name)
+ val subscriber = Subscriber.create(email, firstname, lastname)
+ subscriptionList.addSubscriber(subscriber)
+ val newName = Name("Jane", "Doe")
+ subscriber.updateName(newName)
+ assertEquals(newName, subscriptionList.subscribers[0].name)
+ }
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberControllerIntegrationTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberControllerIntegrationTest.kt
new file mode 100644
index 00000000..ec68268a
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberControllerIntegrationTest.kt
@@ -0,0 +1,74 @@
+package com.lyra.app.newsletter.infrastructure.http
+
+import com.lyra.app.IntegrationTest
+import java.util.*
+import net.datafaker.Faker
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
+import org.springframework.http.MediaType
+import org.springframework.test.web.reactive.server.WebTestClient
+
+private const val ENDPOINT = "/api/newsletter/subscribers"
+
+@IntegrationTest
+@AutoConfigureWebTestClient
+internal class NewsletterSubscriberControllerIntegrationTest {
+ private val faker = Faker()
+
+ @Autowired
+ private lateinit var webTestClient: WebTestClient
+
+ @BeforeEach
+ fun setUp() {
+ // Set Test container here
+ }
+
+ @Test
+ fun `should subscribe a new subscriber`() {
+ val request = generateRequest()
+ webTestClient.put()
+ .uri("$ENDPOINT/${UUID.randomUUID()}")
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(request)
+ .exchange()
+ .expectStatus().isCreated
+ .expectBody().isEmpty
+ }
+
+ @Test
+ fun `should not subscribe a new subscriber if email is invalid`() {
+ val request = generateRequest(email = "invalid-email")
+ webTestClient.put()
+ .uri("$ENDPOINT/${UUID.randomUUID()}")
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(
+ request,
+ )
+ .exchange()
+ .expectStatus().isBadRequest
+ .expectBody().jsonPath("$.type")
+ .isEqualTo("https://lyra.io/errors/bad-request")
+ .jsonPath("$.title")
+ .isEqualTo("Bad request")
+ .jsonPath("$.status")
+ .isEqualTo(400)
+ .jsonPath("$.detail")
+ .isEqualTo("The email is not valid")
+ .jsonPath("$.instance").isNotEmpty
+ }
+
+ @Suppress("MultilineRawStringIndentation")
+ private fun generateRequest(
+ email: String = faker.internet().emailAddress(),
+ firstname: String = faker.name().firstName(),
+ lastname: String = faker.name().lastName()
+ ): String = """
+ {
+ "email": "$email",
+ "firstname": "$firstname",
+ "lastname": "$lastname"
+ }
+ """.trimIndent()
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberControllerTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberControllerTest.kt
new file mode 100644
index 00000000..3120cc71
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/http/NewsletterSubscriberControllerTest.kt
@@ -0,0 +1,46 @@
+package com.lyra.app.newsletter.infrastructure.http
+
+import com.lyra.app.newsletter.application.SubscribeNewsletterCommand
+import com.lyra.app.newsletter.infrastructure.http.request.SubscribeNewsletterRequest
+import com.lyra.common.domain.bus.Mediator
+import io.mockk.coEvery
+import io.mockk.mockk
+import java.util.*
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.test.web.reactive.server.WebTestClient
+
+private const val ENDPOINT = "/api/newsletter/subscribers"
+class NewsletterSubscriberControllerTest {
+ private val mediator = mockk()
+ private val id = UUID.randomUUID().toString()
+ private val email = "john.doe@example.com"
+ private val firstname = "John"
+ private val lastname = "Doe"
+ private val command = SubscribeNewsletterCommand(id, email, firstname, lastname)
+ private val controller = NewsletterSubscriberController(mediator)
+ private val webTestClient = WebTestClient.bindToController(controller).build()
+ @BeforeEach
+ fun setUp() {
+ coEvery { mediator.send(eq(command)) } returns Unit
+ }
+
+ @Test
+ fun `should subscribe a new subscriber`() {
+ // Given
+ val request = SubscribeNewsletterRequest(
+ email = email,
+ firstname = firstname,
+ lastname = lastname,
+ )
+ // When
+ webTestClient.put()
+ .uri("$ENDPOINT/$id")
+ .bodyValue(request)
+ .exchange()
+ .expectStatus().isCreated
+ .expectBody().isEmpty
+ // Then
+ coEvery { mediator.send(eq(command)) }
+ }
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/persistence/SubscriberRepositoryRepositoryTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/persistence/SubscriberRepositoryRepositoryTest.kt
new file mode 100644
index 00000000..c2724670
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/persistence/SubscriberRepositoryRepositoryTest.kt
@@ -0,0 +1,102 @@
+package com.lyra.app.newsletter.infrastructure.persistence
+
+import com.lyra.app.UnitTest
+import com.lyra.app.newsletter.SubscriberStub
+import com.lyra.app.newsletter.domain.Subscriber
+import com.lyra.app.newsletter.infrastructure.persistence.mapper.SubscriberMapper.toEntity
+import com.lyra.app.newsletter.infrastructure.persistence.repository.SubscriberRegistratorR2dbcRepository
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.dao.DuplicateKeyException
+import reactor.core.publisher.Flux
+import reactor.core.publisher.Mono
+
+@UnitTest
+internal class SubscriberRepositoryRepositoryTest {
+ private val subscriberRegistratorR2dbcRepository: SubscriberRegistratorR2dbcRepository = mockk()
+ private val subscriberRepositoryRepository =
+ SubscriberRepositoryRepository(subscriberRegistratorR2dbcRepository)
+ private lateinit var subscribers: List
+
+ @BeforeEach
+ fun setUp() {
+ subscribers = runBlocking { SubscriberStub.dummyRandomSubscribersFlow(10).toList() }
+ val subscribersEntities = subscribers.map { it.toEntity() }.toList()
+ coEvery { subscriberRegistratorR2dbcRepository.save(any()) } returns Mono.just(
+ subscribersEntities.first(),
+ )
+ coEvery { subscriberRegistratorR2dbcRepository.findAll() } returns Flux.fromIterable(
+ subscribersEntities,
+ )
+ coEvery { subscriberRegistratorR2dbcRepository.findAllByStatus(any()) } returns Flux.fromIterable(
+ subscribersEntities,
+ )
+ }
+
+ @Test
+ fun `should save a new subscriber`() {
+ // Given
+ val subscriber = subscribers.first()
+ // When
+ runBlocking {
+ subscriberRepositoryRepository.create(subscriber)
+ }
+ // Then
+ coEvery { subscriberRegistratorR2dbcRepository.save(any()) }
+ }
+
+ @Test
+ fun `should not save a new subscriber if it already exists`() {
+ // Given
+ val subscriber = subscribers.first()
+ coEvery { subscriberRegistratorR2dbcRepository.save(any()) } returns Mono.error(
+ DuplicateKeyException("Duplicate key"),
+ )
+ // When
+ runBlocking {
+ subscriberRepositoryRepository.create(subscriber)
+ }
+ // Then
+ coEvery { subscriberRegistratorR2dbcRepository.save(any()) }
+ }
+
+ @Test
+ fun `should not save a new subscriber if an unknown exception occur`() {
+ // Given
+ val subscriber = subscribers.first()
+ coEvery { subscriberRegistratorR2dbcRepository.save(any()) } returns Mono.error(
+ Exception("Unknown exception"),
+ )
+ // When
+ runBlocking {
+ subscriberRepositoryRepository.create(subscriber)
+ }
+ // Then
+ coEvery { subscriberRegistratorR2dbcRepository.save(any()) }
+ }
+
+ @Test
+ fun `should search all subscribers`() = runBlocking {
+ // Given
+ // When
+ val response = subscriberRepositoryRepository.searchAll().toList()
+ // Then
+ coEvery { subscriberRegistratorR2dbcRepository.findAll() }
+ assertEquals(subscribers, response)
+ }
+
+ @Test
+ fun `should search all active subscribers`() = runBlocking {
+ // Given
+ // When
+ val response = subscriberRepositoryRepository.searchActive().toList()
+ // Then
+ coEvery { subscriberRegistratorR2dbcRepository.findAllByStatus(any()) }
+ assertEquals(subscribers, response)
+ }
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberConverterTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberConverterTest.kt
new file mode 100644
index 00000000..042ea2fa
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/newsletter/infrastructure/persistence/converter/SubscriberConverterTest.kt
@@ -0,0 +1,37 @@
+package com.lyra.app.newsletter.infrastructure.persistence.converter
+
+import com.lyra.app.newsletter.domain.SubscriberStatus
+import io.mockk.every
+import io.mockk.mockk
+import io.r2dbc.spi.Row
+import java.time.LocalDateTime
+import java.util.UUID
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class SubscriberConverterTest {
+
+ @Test
+ fun `should convert Row to SubscriberEntity`() {
+ // Arrange
+ val converter = SubscriberConverter()
+ val mockRow: Row = mockk {
+ every { get("id", UUID::class.java) } returns UUID.randomUUID()
+ every { get("email", String::class.java) } returns "test@example.com"
+ every { get("firstname", String::class.java) } returns "John"
+ every { get("lastname", String::class.java) } returns "Doe"
+ every { get("status", SubscriberStatus::class.java) } returns SubscriberStatus.ENABLED
+ every { get("created_at", LocalDateTime::class.java) } returns LocalDateTime.now()
+ every { get("updated_at", LocalDateTime::class.java) } returns LocalDateTime.now()
+ }
+
+ // Act
+ val result = converter.convert(mockRow)
+
+ // Assert
+ assertEquals("test@example.com", result.email)
+ assertEquals("John", result.firstname)
+ assertEquals("Doe", result.lastname)
+ assertEquals(SubscriberStatus.ENABLED, result.status)
+ }
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommandHandlerTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommandHandlerTest.kt
new file mode 100644
index 00000000..785d587b
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/notifications/application/SendNewContentNewsletterCommandHandlerTest.kt
@@ -0,0 +1,62 @@
+package com.lyra.app.notifications.application
+
+import com.lyra.app.newsletter.SubscriberStub
+import com.lyra.app.newsletter.application.SubscriberResponse
+import com.lyra.app.newsletter.application.SubscribersResponse
+import com.lyra.app.newsletter.application.search.active.SearchAllActiveSubscribersQuery
+import com.lyra.app.notifications.domain.EmailSender
+import com.lyra.app.notifications.domain.event.NewContentNewsletterEmailSent
+import com.lyra.common.domain.bus.Mediator
+import com.lyra.common.domain.bus.event.EventPublisher
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+class SendNewContentNewsletterCommandHandlerTest {
+ private var eventPublisher: EventPublisher = mockk()
+ private var emailSender: EmailSender = mockk()
+ private var mediator: Mediator = mockk()
+ private val newContentNewsletterSender = NewContentNewsletterSender(
+ emailSender,
+ mediator,
+ eventPublisher,
+ )
+ private val sendNewContentNewsletterCommandHandler = SendNewContentNewsletterCommandHandler(
+ newContentNewsletterSender,
+ )
+ @BeforeEach
+ fun setUp() {
+ coEvery { mediator.send(any(SearchAllActiveSubscribersQuery::class)) } returns subscribersResponse()
+ coEvery { eventPublisher.publish(any(NewContentNewsletterEmailSent::class)) } returns Unit
+ coEvery { emailSender.send(any()) } returns Unit
+ }
+
+ @Test
+ fun `should send new content newsletter`() = runBlocking {
+ // Given
+ val command = SendNewContentNewsletterCommand()
+ // When
+ sendNewContentNewsletterCommandHandler.handle(command)
+ // Then
+ coVerify(exactly = 1) { mediator.send(any(SearchAllActiveSubscribersQuery::class)) }
+ coVerify(exactly = 10) { emailSender.send(any()) }
+ coVerify(exactly = 10) { eventPublisher.publish(any(NewContentNewsletterEmailSent::class)) }
+ }
+
+ private fun subscribersResponse(): SubscribersResponse {
+ return SubscriberStub.dummyRandomSubscribersList().toList()
+ .map {
+ SubscriberResponse(
+ id = it.id.value.toString(),
+ email = it.email.value,
+ name = it.name.fullName(),
+ status = it.status.name,
+ )
+ }.let {
+ SubscribersResponse(it)
+ }
+ }
+}
diff --git a/apps/backend/src/test/kotlin/com/lyra/app/notifications/infrastructure/SendgridEmailSenderTest.kt b/apps/backend/src/test/kotlin/com/lyra/app/notifications/infrastructure/SendgridEmailSenderTest.kt
new file mode 100644
index 00000000..78793b93
--- /dev/null
+++ b/apps/backend/src/test/kotlin/com/lyra/app/notifications/infrastructure/SendgridEmailSenderTest.kt
@@ -0,0 +1,68 @@
+package com.lyra.app.notifications.infrastructure
+
+import com.lyra.app.notifications.domain.EmailMessage
+import com.lyra.app.notifications.domain.EmailMessageId
+import com.lyra.common.domain.email.Email
+import com.sendgrid.Request
+import com.sendgrid.Response
+import com.sendgrid.SendGrid
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import java.io.IOException
+import java.util.*
+import kotlinx.coroutines.runBlocking
+import net.datafaker.Faker
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+class SendgridEmailSenderTest {
+ private val faker = Faker()
+ private val sg: SendGrid = mockk()
+ private lateinit var sendgridEmailSender: SendgridEmailSender
+
+ @BeforeEach
+ fun setUp() {
+ val response: Response = mockk()
+ val request: Request = mockk()
+ sendgridEmailSender = SendgridEmailSender("SOME_API_KEY", sg)
+ every { sg.api(any(Request::class)) } returns response
+ every { response.statusCode } returns 200
+ every { request.method = any() } returns Unit
+ }
+
+ @Test
+ fun `send email`() = runBlocking {
+ val emailMessage = ExampleEmailMessage(
+ id = EmailMessageId(UUID.randomUUID().toString()),
+ from = Email(faker.internet().emailAddress()),
+ to = Email(faker.internet().emailAddress()),
+ subject = faker.lorem().sentence(),
+ body = faker.lorem().paragraph(),
+ )
+ sendgridEmailSender.send(emailMessage)
+ verify(exactly = 1) { sg.api(any()) }
+ }
+
+ @Test
+ fun `send email with error`() = runBlocking {
+ val emailMessage = ExampleEmailMessage(
+ id = EmailMessageId(UUID.randomUUID().toString()),
+ from = Email(faker.internet().emailAddress()),
+ to = Email(faker.internet().emailAddress()),
+ subject = faker.lorem().sentence(),
+ body = faker.lorem().paragraph(),
+ )
+ every { sg.api(any()) } throws IOException("Error sending email")
+ sendgridEmailSender.send(emailMessage)
+ verify(exactly = 1) { sg.api(any()) }
+ }
+}
+
+class ExampleEmailMessage(
+ override val id: EmailMessageId,
+ from: Email,
+ to: Email,
+ subject: String,
+ body: String
+) : EmailMessage(id, from, to, subject, body)
diff --git a/build-logic/library-convention/src/main/kotlin/com/lyra/buildlogic/library/LibraryConventionPlugin.kt b/build-logic/library-convention/src/main/kotlin/com/lyra/buildlogic/library/LibraryConventionPlugin.kt
index 6f1ed798..cdc8207c 100644
--- a/build-logic/library-convention/src/main/kotlin/com/lyra/buildlogic/library/LibraryConventionPlugin.kt
+++ b/build-logic/library-convention/src/main/kotlin/com/lyra/buildlogic/library/LibraryConventionPlugin.kt
@@ -23,5 +23,15 @@ class LibraryConventionPlugin : ConventionPlugin {
add("implementation", catalogBundle("kotlin-jvm"))
}
+
+// testing {
+// suites {
+// // Configure the built-in test suite
+// val test by getting(JvmTestSuite::class) {
+// // Use JUnit Jupiter test framework
+// useJUnitJupiter("5.9.1")
+// }
+// }
+// }
}
}
diff --git a/build-logic/spring-boot-convention/build.gradle.kts b/build-logic/spring-boot-convention/build.gradle.kts
index 17ce3423..9f2a8197 100644
--- a/build-logic/spring-boot-convention/build.gradle.kts
+++ b/build-logic/spring-boot-convention/build.gradle.kts
@@ -16,5 +16,9 @@ gradlePlugin {
id = "app.spring.boot.convention"
implementationClass = "com.lyra.buildlogic.springboot.SpringBootConventionPlugin"
}
+ register("spring-boot-library-convention") {
+ id = "app.spring.boot.library.convention"
+ implementationClass = "com.lyra.buildlogic.springboot.SpringBootLibraryConventionPlugin"
+ }
}
}
diff --git a/build-logic/spring-boot-convention/src/main/kotlin/com/lyra/buildlogic/springboot/SpringBootLibraryConventionPlugin.kt b/build-logic/spring-boot-convention/src/main/kotlin/com/lyra/buildlogic/springboot/SpringBootLibraryConventionPlugin.kt
new file mode 100644
index 00000000..ca4aaaed
--- /dev/null
+++ b/build-logic/spring-boot-convention/src/main/kotlin/com/lyra/buildlogic/springboot/SpringBootLibraryConventionPlugin.kt
@@ -0,0 +1,31 @@
+package com.lyra.buildlogic.springboot
+
+import com.lyra.buildlogic.common.ConventionPlugin
+import com.lyra.buildlogic.common.extensions.catalogBundle
+import com.lyra.buildlogic.common.extensions.catalogPlugin
+import com.lyra.buildlogic.common.extensions.commonExtensions
+import com.lyra.buildlogic.common.extensions.commonTasks
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.dependencies
+
+class SpringBootLibraryConventionPlugin : ConventionPlugin {
+ override fun Project.configure() {
+ apply(plugin = catalogPlugin("kotlin-jvm").get().pluginId)
+ apply(plugin = catalogPlugin("kotlin-spring").get().pluginId)
+
+ with(extensions) {
+ commonExtensions()
+ }
+
+ tasks.commonTasks()
+
+ dependencies {
+ add("implementation", catalogBundle("spring-boot"))
+
+ add("implementation", catalogBundle("kotlin-jvm"))
+
+ add("testImplementation", catalogBundle("spring-boot-test"))
+ }
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 0ed1d000..cf24d73e 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -36,9 +36,6 @@ frontend {
verboseModeEnabled.set(true)
}
-dependencies {
-}
-
tasks.named("installFrontend") {
val ciPlatformPresent = providers.environmentVariable("CI").isPresent
val lockFilePath = "$projectDir/pnpm-lock.yaml"
diff --git a/compose.yaml b/compose.yaml
index 39549c7a..6c08ab16 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,11 +1,15 @@
services:
db:
- image: postgres:16.1
- environment:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: postgres
- ports:
- - "5432:5432"
- volumes:
- - ./infra/postgresql/data:/var/lib/postgresql/data
+ extends:
+ file: ./infra/postgresql/postgresql-compose.yml
+ service: postgresql
+# image: postgres:16.1
+# environment:
+# POSTGRES_USER: postgres
+# POSTGRES_PASSWORD: postgres
+# POSTGRES_DB: postgres
+# ports:
+# - "5432:5432"
+# volumes:
+# - ./infra/postgresql/data:/var/lib/postgresql/data
+# - ./infra/postgresql/init-scripts:/docker-entrypoint-initdb.d
diff --git a/config/detekt.yml b/config/detekt.yml
index 4e846d5d..930482cd 100644
--- a/config/detekt.yml
+++ b/config/detekt.yml
@@ -504,7 +504,7 @@ naming:
FunctionMaxLength:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
- maximumFunctionNameLength: 35
+ maximumFunctionNameLength: 45
FunctionMinLength:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
@@ -862,7 +862,7 @@ style:
active: true
ReturnCount:
active: true
- max: 2
+ max: 3
excludedFunctions:
- 'equals'
excludeLabeled: false
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index cda25f0d..f899100a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,8 +2,8 @@
kotlin = "1.9.22"
springBoot = "3.2.1"
springdoc = "2.3.0"
-junit = "4.13.2"
-klint-plugin = "12.1.0"
+junit = "5.10.1"
+klint-plugin = "12.0.3"
detekt = "1.23.4"
owasp = "9.0.8"
asciidoctor = "4.0.1"
@@ -25,6 +25,8 @@ gradle-updates = { module = "com.github.ben-manes:gradle-versions-plugin", versi
gradle-owasp-depcheck = { module = "org.owasp:dependency-check-gradle", version.ref = "owasp" }
gradle-kover = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version = "0.7.5" }
gradle-asciidoctor = { module = "org.asciidoctor:asciidoctor-gradle-jvm", version.ref = "asciidoctor" }
+spring-dotenv = 'me.paulschwarz:spring-dotenv:4.0.0'
+sendgrid = 'com.sendgrid:sendgrid-java:4.10.1'
# Detekt
gradle-detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
@@ -38,9 +40,11 @@ spring-boot-starter-webflux = { module = "org.springframework.boot:spring-boot-s
springdoc-openapi-starter-webflux-ui = { module = "org.springdoc:springdoc-openapi-starter-webflux-ui", version.ref = "springdoc" }
springdoc-openapi-starter-webflux-api = { module = "org.springdoc:springdoc-openapi-starter-webflux-api", version.ref = "springdoc" }
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springBoot" }
-
+# Spring Boot Dev
spring-boot-devtools = { module = "org.springframework.boot:spring-boot-devtools", version.ref = "springBoot" }
spring-boot-docker-compose = { module = "org.springframework.boot:spring-boot-docker-compose", version.ref = "springBoot" }
+# Spring Boot Database
+spring-boot-starter-data-r2dbc = { module = "org.springframework.boot:spring-boot-starter-data-r2dbc", version.ref = "springBoot" }
# Kotlin Libraries
jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version = "2.16.1" }
@@ -50,9 +54,11 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref =
kotlinx-coroutines-reactor = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactor", version = "1.7.3" }
# Testing
-junit = { module = "junit:junit", version.ref = "junit" }
+junit = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "springBoot" }
reactor-test = { module = "io.projectreactor:reactor-test", version = "3.6.2" }
+mockk = { module = "io.mockk:mockk", version = "1.13.9" }
+faker = { module = "net.datafaker:datafaker", version = "2.0.2" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
@@ -67,4 +73,5 @@ spring-boot = ["spring-boot-starter", "spring-boot-starter-webflux",
"spring-boot-starter-actuator"]
spring-boot-dev = ["spring-boot-devtools", "spring-boot-docker-compose"]
spring-boot-test = ["spring-boot-starter-test", "reactor-test"]
-kotlin-jvm = ["kotlin-reflect", "kotlinx-coroutines-reactor", "jackson-module-kotlin", "reactor-kotlin-extensions", "reflections"]
+kotlin-jvm = ["kotlin-reflect", "kotlinx-coroutines-reactor", "jackson-module-kotlin",
+ "reactor-kotlin-extensions", "reflections"]
diff --git a/infra/postgresql/init-scripts/schema.sql b/infra/postgresql/init-scripts/schema.sql
new file mode 100644
index 00000000..c1be5069
--- /dev/null
+++ b/infra/postgresql/init-scripts/schema.sql
@@ -0,0 +1,22 @@
+-- Define ENUM types
+DROP TYPE IF EXISTS subscriber_status CASCADE;
+CREATE TYPE subscriber_status AS ENUM ('ENABLED', 'DISABLED', 'BLOCKLISTED');
+
+-- Create subscribers table
+DROP TABLE IF EXISTS subscribers CASCADE;
+CREATE TABLE subscribers (
+ id uuid NOT NULL,
+ email TEXT NOT NULL UNIQUE,
+ firstname TEXT NOT NULL,
+ lastname TEXT,
+ status subscriber_status NOT NULL DEFAULT 'ENABLED',
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ CONSTRAINT pk_emp_id PRIMARY KEY (id)
+);
+
+-- Create indexes
+DROP INDEX IF EXISTS idx_subs_email;
+CREATE UNIQUE INDEX idx_subs_email ON subscribers(LOWER(email));
+DROP INDEX IF EXISTS idx_subs_status;
+CREATE INDEX idx_subs_status ON subscribers(status);
diff --git a/infra/postgresql/postgresql-compose.yml b/infra/postgresql/postgresql-compose.yml
new file mode 100644
index 00000000..34825075
--- /dev/null
+++ b/infra/postgresql/postgresql-compose.yml
@@ -0,0 +1,12 @@
+services:
+ postgresql:
+ image: postgres:16.1
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: postgres
+ ports:
+ - "5432:5432"
+ volumes:
+ - ./data:/var/lib/postgresql/data
+ - ./init-scripts:/docker-entrypoint-initdb.d
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 7898b748..46da5fdd 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -76,7 +76,7 @@ fun includeProject(dir: File) {
fun calculateProjectName(dir: File): String {
val projectName = dir.relativeTo(rootDir).path.replace("/", ":")
- println("\uD83D\uDFE2 >>>>> Project name: $projectName")
+ println("\uD83D\uDFE2 Project name: $projectName")
return if (projectName.startsWith(":")) projectName.substring(1) else projectName
}
diff --git a/shared/common/build.gradle.kts b/shared/common/build.gradle.kts
index dd2816ac..1cfb6f0b 100644
--- a/shared/common/build.gradle.kts
+++ b/shared/common/build.gradle.kts
@@ -3,4 +3,19 @@ plugins {
}
dependencies {
+ testImplementation(kotlin("test"))
+ testImplementation(libs.junit)
+ testImplementation(libs.faker)
+ testImplementation(libs.mockk)
+}
+
+testing {
+ suites {
+ // Configure the built-in test suite
+ @Suppress("UnusedPrivateProperty")
+ val test by getting(JvmTestSuite::class) {
+ // Use JUnit Jupiter test framework
+ useJUnitJupiter(libs.versions.junit)
+ }
+ }
}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/AggregateRoot.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/AggregateRoot.kt
new file mode 100644
index 00000000..5811b97e
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/AggregateRoot.kt
@@ -0,0 +1,12 @@
+package com.lyra.common.domain
+
+/**
+ * An abstract class representing an aggregate root entity.
+ *
+ * @param ID the data type of the aggregate root's identifier
+ *
+ * @see BaseEntity
+ *
+ */
+@Suppress("unused")
+abstract class AggregateRoot : BaseEntity()
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/BaseEntity.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseEntity.kt
new file mode 100644
index 00000000..36da1d89
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseEntity.kt
@@ -0,0 +1,40 @@
+package com.lyra.common.domain
+
+import com.lyra.common.domain.bus.event.DomainEvent
+
+/**
+ * A base class for entities with a generic identifier.
+ *
+ * @param ID the type of the identifier
+ * @property id The unique identifier of the entity.
+ */
+abstract class BaseEntity {
+ abstract val id: ID
+ private val domainEvents: MutableList = mutableListOf()
+
+ fun record(event: DomainEvent) = domainEvents.add(event)
+ fun pullDomainEvents(): List {
+ val events = domainEvents.toList()
+ clearDomainEvents()
+ return events
+ }
+
+ private fun clearDomainEvents() = domainEvents.clear()
+ @Generated
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is BaseEntity<*>) return false
+
+ if (id != other.id) return false
+ if (domainEvents != other.domainEvents) return false
+
+ return true
+ }
+
+ @Generated
+ override fun hashCode(): Int {
+ var result = id?.hashCode() ?: 0
+ result = 31 * result + domainEvents.hashCode()
+ return result
+ }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/BaseId.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseId.kt
new file mode 100644
index 00000000..52a413bf
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseId.kt
@@ -0,0 +1,28 @@
+package com.lyra.common.domain
+
+/**
+ * Represents a base class for creating ID objects.
+ *
+ * @param value The value of the ID.
+ * @param T The type of the ID value.
+ *
+ */
+@Suppress("unused")
+abstract class BaseId protected constructor(val value: T) {
+ init {
+ require(value != null) { "The id cannot be null" }
+ }
+
+ @Generated
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is BaseId<*>) return false
+
+ return value == other.value
+ }
+
+ @Generated
+ override fun hashCode(): Int = value?.hashCode() ?: 0
+ @Generated
+ override fun toString(): String = value.toString()
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/BaseValidateValueObject.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseValidateValueObject.kt
new file mode 100644
index 00000000..8880766f
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseValidateValueObject.kt
@@ -0,0 +1,19 @@
+package com.lyra.common.domain
+
+/**
+ * Base class for value objects that are validated when created
+ *
+ * @created 2/7/23
+ */
+abstract class BaseValidateValueObject protected constructor(value: T) :
+ BaseValueObject(value) {
+ init {
+ this.validate(value)
+ }
+
+ /**
+ * Validates the value of the value object
+ * @param value the value to validate
+ */
+ abstract fun validate(value: T)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/BaseValueObject.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseValueObject.kt
new file mode 100644
index 00000000..32668560
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/BaseValueObject.kt
@@ -0,0 +1,42 @@
+package com.lyra.common.domain
+
+import java.io.Serializable
+
+/**
+ * Base class for value objects that are validated when created
+ *
+ * @created 2/7/23
+ */
+@Suppress("SerialVersionUIDInSerializableClass")
+abstract class BaseValueObject protected constructor(val value: T) : Serializable {
+
+ /**
+ * Compares two value objects by their value
+ */
+ @Generated
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is BaseValueObject<*>) return false
+
+ if (value != other.value) return false
+
+ return true
+ }
+
+ /**
+ * Returns the hash code of the value object
+ */
+ @Generated
+ override fun hashCode(): Int = value?.hashCode() ?: 0
+
+ /**
+ * Returns the string representation of the value object
+ */
+ @Generated
+ override fun toString(): String = value.toString()
+
+ @Suppress("serialVersionUID")
+ companion object {
+ private const val serialVersionUID = -68L
+ }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Container.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Container.kt
new file mode 100644
index 00000000..01b2215c
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Container.kt
@@ -0,0 +1,55 @@
+package com.lyra.common.domain.bus
+
+import com.lyra.common.domain.bus.command.Command
+import com.lyra.common.domain.bus.command.CommandHandler
+import com.lyra.common.domain.bus.command.CommandProvider
+import com.lyra.common.domain.bus.command.CommandWithResult
+import com.lyra.common.domain.bus.command.CommandWithResultHandler
+import com.lyra.common.domain.bus.command.CommandWithResultProvider
+import com.lyra.common.domain.bus.notification.Notification
+import com.lyra.common.domain.bus.notification.NotificationHandler
+import com.lyra.common.domain.bus.notification.NotificationProvider
+import com.lyra.common.domain.bus.pipeline.PipelineBehavior
+import com.lyra.common.domain.bus.pipeline.PipelineProvider
+import com.lyra.common.domain.bus.query.Query
+import com.lyra.common.domain.bus.query.QueryHandler
+import com.lyra.common.domain.bus.query.QueryProvider
+
+@Suppress("UNCHECKED_CAST")
+internal class Container(dependencyProvider: DependencyProvider) : Registrar() {
+ val commandMap = HashMap, CommandProvider>>()
+ val queryMap = HashMap, QueryProvider>>()
+ val notificationMap =
+ HashMap, MutableList>>>()
+ val pipelineSet = HashSet>()
+ val commandWithResultMap = HashMap, CommandWithResultProvider<*>>()
+
+ init {
+
+ registerFor, *>, Query<*>>(dependencyProvider) { key, value ->
+ queryMap[key] = QueryProvider(dependencyProvider, value as Class>)
+ }
+
+ registerFor, Command>(dependencyProvider) { key, value ->
+ commandMap[key] = CommandProvider(dependencyProvider, value)
+ }
+
+ registerFor, *>, CommandWithResult<*>>(
+ dependencyProvider,
+ ) { key, value ->
+ commandWithResultMap[key] = CommandWithResultProvider(
+ dependencyProvider,
+ value as Class>,
+ )
+ }
+
+ registerFor, Notification>(dependencyProvider) { key, value ->
+ notificationMap.getOrPut(key) { mutableListOf() }
+ .add(NotificationProvider(dependencyProvider, value as Class>))
+ }
+
+ registerFor(dependencyProvider) { handler ->
+ pipelineSet.add(PipelineProvider(dependencyProvider, handler))
+ }
+ }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/DependencyProvider.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/DependencyProvider.kt
new file mode 100644
index 00000000..b9e4ff2b
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/DependencyProvider.kt
@@ -0,0 +1,7 @@
+package com.lyra.common.domain.bus
+
+interface DependencyProvider {
+ fun getSingleInstanceOf(clazz: Class): T
+
+ fun getSubTypesOf(clazz: Class): Collection>
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/HandlerNotFoundException.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/HandlerNotFoundException.kt
new file mode 100644
index 00000000..c2d7b3a0
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/HandlerNotFoundException.kt
@@ -0,0 +1,3 @@
+package com.lyra.common.domain.bus
+
+class HandlerNotFoundException(message: String) : Exception(message)
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Mediator.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Mediator.kt
new file mode 100644
index 00000000..5c01e445
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Mediator.kt
@@ -0,0 +1,25 @@
+package com.lyra.common.domain.bus
+
+import com.lyra.common.domain.bus.command.Command
+import com.lyra.common.domain.bus.command.CommandWithResult
+import com.lyra.common.domain.bus.notification.Notification
+import com.lyra.common.domain.bus.query.Query
+
+interface Mediator {
+
+ suspend fun , TResponse> send(query: TQuery): TResponse
+
+ suspend fun send(command: TCommand)
+
+ suspend fun , TResult> send(command: TCommand): TResult
+
+ /**
+ * Publishes the given notification to appropriate notification handlers
+ *
+ * @since 1.0.9
+ * @param T any [Notification] subclass to publish
+ */
+ suspend fun publish(notification: T)
+
+ suspend fun publish(notification: T, publishStrategy: PublishStrategy)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/MediatorBuilder.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/MediatorBuilder.kt
new file mode 100644
index 00000000..8d0f668c
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/MediatorBuilder.kt
@@ -0,0 +1,27 @@
+package com.lyra.common.domain.bus
+
+class MediatorBuilder(
+ private val dependencyProvider: DependencyProvider,
+) {
+ internal var defaultPublishStrategy: PublishStrategy = StopOnExceptionPublishStrategy()
+ private set
+
+ /**
+ * Overrides default notification publishing strategy.
+ * Default strategy is [StopOnExceptionPublishStrategy]
+ *
+ * @since 1.0.9
+ * @see [PublishStrategy]
+ * @see [ContinueOnExceptionPublishStrategy]
+ * @see [StopOnExceptionPublishStrategy]
+ * @see [ParallelNoWaitPublishStrategy]
+ * @see [ParallelWhenAllPublishStrategy]
+ */
+ fun withPublishStrategy(publishStrategy: PublishStrategy): MediatorBuilder {
+ this.defaultPublishStrategy = publishStrategy
+ return this
+ }
+
+ fun build(registry: Registry = RegistryImpl(dependencyProvider)): Mediator =
+ MediatorImpl(registry, defaultPublishStrategy)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/MediatorImpl.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/MediatorImpl.kt
new file mode 100644
index 00000000..410a0d67
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/MediatorImpl.kt
@@ -0,0 +1,64 @@
+package com.lyra.common.domain.bus
+
+import com.lyra.common.domain.bus.command.Command
+import com.lyra.common.domain.bus.command.CommandWithResult
+import com.lyra.common.domain.bus.notification.Notification
+import com.lyra.common.domain.bus.pipeline.PipelineBehavior
+import com.lyra.common.domain.bus.query.Query
+
+class MediatorImpl(
+ private val registry: Registry,
+ private val defaultPublishStrategy: PublishStrategy = StopOnExceptionPublishStrategy(),
+) : Mediator {
+
+ override suspend fun , TResponse> send(query: TQuery): TResponse =
+ processPipeline(
+ registry.getPipelineBehaviors(),
+ query,
+ ) {
+ registry.resolveQueryHandler(query.javaClass).handle(query)
+ }
+
+ override suspend fun send(command: TCommand) = processPipeline(
+ registry.getPipelineBehaviors(),
+ command,
+ ) {
+ registry.resolveCommandHandler(command.javaClass).handle(command)
+ }
+
+ override suspend fun , TResult> send(command: TCommand): TResult =
+ processPipeline(
+ registry.getPipelineBehaviors(),
+ command,
+ ) {
+ registry.resolveCommandWithResultHandler(command.javaClass).handle(command)
+ }
+
+ override suspend fun publish(notification: T) =
+ publish(notification, defaultPublishStrategy)
+
+ override suspend fun publish(
+ notification: T,
+ publishStrategy: PublishStrategy,
+ ) = processPipeline(
+ registry.getPipelineBehaviors(),
+ notification,
+ ) {
+ publishStrategy.publish(
+ notification,
+ registry.resolveNotificationHandlers(notification.javaClass),
+ )
+ }
+
+ private suspend fun processPipeline(
+ pipelineBehaviors: Collection,
+ request: TRequest,
+ handler: RequestHandlerDelegate,
+ ): TResponse = pipelineBehaviors
+ .reversed()
+ .fold(handler) { next, pipeline ->
+ {
+ pipeline.handle(request) { next(it) }
+ }
+ }(request)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/PublishStrategies.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/PublishStrategies.kt
new file mode 100644
index 00000000..42f7e69e
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/PublishStrategies.kt
@@ -0,0 +1,83 @@
+package com.lyra.common.domain.bus
+
+import com.lyra.common.domain.bus.notification.Notification
+import com.lyra.common.domain.bus.notification.NotificationHandler
+import com.lyra.common.domain.error.AggregateException
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class ContinueOnExceptionPublishStrategy : PublishStrategy {
+
+ override suspend fun publish(
+ notification: T,
+ notificationHandlers: Collection>,
+ dispatcher: CoroutineDispatcher,
+ ) {
+ coroutineScope {
+ withContext(dispatcher) {
+ val exceptions = mutableListOf()
+ @Suppress("TooGenericExceptionCaught")
+ notificationHandlers.forEach {
+ try {
+ it.handle(notification)
+ } catch (e: Exception) {
+ exceptions.add(e)
+ }
+ }
+ if (exceptions.isNotEmpty()) {
+ throw AggregateException(exceptions)
+ }
+ }
+ }
+ }
+}
+
+class StopOnExceptionPublishStrategy : PublishStrategy {
+
+ override suspend fun publish(
+ notification: T,
+ notificationHandlers: Collection>,
+ dispatcher: CoroutineDispatcher,
+ ) {
+ coroutineScope {
+ withContext(dispatcher) {
+ notificationHandlers.forEach { it.handle(notification) }
+ }
+ }
+ }
+}
+
+class ParallelNoWaitPublishStrategy : PublishStrategy {
+
+ override suspend fun publish(
+ notification: T,
+ notificationHandlers: Collection>,
+ dispatcher: CoroutineDispatcher,
+ ) {
+ coroutineScope {
+ withContext(dispatcher) {
+ notificationHandlers.forEach { launch { it.handle(notification) } }
+ }
+ }
+ }
+}
+
+class ParallelWhenAllPublishStrategy : PublishStrategy {
+
+ override suspend fun publish(
+ notification: T,
+ notificationHandlers: Collection>,
+ dispatcher: CoroutineDispatcher,
+ ) {
+ coroutineScope {
+ withContext(dispatcher) {
+ val deferredResults = notificationHandlers.map { async { it.handle(notification) } }
+ deferredResults.awaitAll()
+ }
+ }
+ }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/PublishStrategy.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/PublishStrategy.kt
new file mode 100644
index 00000000..f5785d0a
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/PublishStrategy.kt
@@ -0,0 +1,15 @@
+package com.lyra.common.domain.bus
+
+import com.lyra.common.domain.bus.notification.Notification
+import com.lyra.common.domain.bus.notification.NotificationHandler
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+
+interface PublishStrategy {
+
+ suspend fun publish(
+ notification: T,
+ notificationHandlers: Collection>,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO,
+ )
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Registrar.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Registrar.kt
new file mode 100644
index 00000000..6dea44ad
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Registrar.kt
@@ -0,0 +1,76 @@
+package com.lyra.common.domain.bus
+
+import java.lang.reflect.ParameterizedType
+import java.lang.reflect.TypeVariable
+
+@Suppress("UNCHECKED_CAST")
+abstract class Registrar {
+
+ protected inline fun registerFor(
+ dependencyProvider: DependencyProvider,
+ registrar: (key: Class, value: Class) -> Unit,
+ ) = dependencyProvider.getSubTypesOf(THandler::class.java).forEach {
+ registerFor(it) { key, value ->
+ registrar(key as Class, value as Class)
+ }
+ }
+
+ protected inline fun registerFor(
+ handler: Class<*>,
+ registrar: (key: Class<*>, value: Class<*>) -> Unit,
+ ) {
+ val interfaceOrBaseClass = THandler::class.java
+ if (!interfaceOrBaseClass.isAssignableFrom(handler)) return
+
+ handler.genericInterfaces
+ .filterIsInstance()
+ .map { extractParameter(it) }
+ .forEach { registrar(it, handler) }
+
+ when (handler.genericSuperclass) {
+ is ParameterizedType -> {
+ val inheritedHandler = (handler.genericSuperclass as ParameterizedType).rawType as Class<*>
+ inheritedHandler.genericInterfaces
+ .filterIsInstance()
+ .map { extractParameter(handler.genericSuperclass as ParameterizedType) }
+ .forEach { registrar(it, handler) }
+ }
+
+ is Class<*> -> {
+ val inheritedHandler = handler.genericSuperclass as Class<*>
+ if (interfaceOrBaseClass.isAssignableFrom(inheritedHandler)) {
+ inheritedHandler.genericInterfaces
+ .filterIsInstance()
+ .map { extractParameter(it) }
+ .forEach { registrar(it, handler) }
+ }
+ }
+ }
+ }
+
+ protected inline fun registerFor(
+ dependencyProvider: DependencyProvider,
+ registrar: (value: Class) -> Unit,
+ ) = dependencyProvider.getSubTypesOf(T::class.java).forEach { handler ->
+ registerFor(handler) { value -> registrar(value as Class) }
+ }
+
+ protected inline fun registerFor(
+ handler: Class<*>,
+ registrar: (value: Class<*>) -> Unit,
+ ) {
+ val interfaceOrBaseClass = T::class.java
+ if (!interfaceOrBaseClass.isAssignableFrom(handler)) return
+ registrar(handler)
+ }
+
+ protected fun extractParameter(genericInterface: ParameterizedType): Class<*> =
+ when (val typeArgument = genericInterface.actualTypeArguments[0]) {
+ is ParameterizedType -> typeArgument.rawType as Class<*>
+ is TypeVariable<*> -> extractParameter(
+ (genericInterface.rawType as Class<*>)
+ .genericInterfaces[0] as ParameterizedType,
+ )
+ else -> typeArgument as Class<*>
+ }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Registry.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Registry.kt
new file mode 100644
index 00000000..ad01cdcf
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/Registry.kt
@@ -0,0 +1,30 @@
+package com.lyra.common.domain.bus
+
+import com.lyra.common.domain.bus.command.Command
+import com.lyra.common.domain.bus.command.CommandHandler
+import com.lyra.common.domain.bus.command.CommandWithResult
+import com.lyra.common.domain.bus.command.CommandWithResultHandler
+import com.lyra.common.domain.bus.notification.Notification
+import com.lyra.common.domain.bus.notification.NotificationHandler
+import com.lyra.common.domain.bus.pipeline.PipelineBehavior
+import com.lyra.common.domain.bus.query.Query
+import com.lyra.common.domain.bus.query.QueryHandler
+
+interface Registry {
+
+ fun resolveCommandHandler(classOfCommand: Class): CommandHandler
+
+ fun , TResult> resolveCommandWithResultHandler(
+ classOfCommand: Class,
+ ): CommandWithResultHandler
+
+ fun , TResult> resolveQueryHandler(
+ classOfQuery: Class,
+ ): QueryHandler
+
+ fun resolveNotificationHandlers(
+ classOfNotification: Class,
+ ): Collection>
+
+ fun getPipelineBehaviors(): Collection
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/RegistryImpl.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/RegistryImpl.kt
new file mode 100644
index 00000000..0944fa8b
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/RegistryImpl.kt
@@ -0,0 +1,53 @@
+@file:Suppress("UNCHECKED_CAST")
+
+package com.lyra.common.domain.bus
+
+import com.lyra.common.domain.bus.command.Command
+import com.lyra.common.domain.bus.command.CommandHandler
+import com.lyra.common.domain.bus.command.CommandWithResult
+import com.lyra.common.domain.bus.command.CommandWithResultHandler
+import com.lyra.common.domain.bus.notification.Notification
+import com.lyra.common.domain.bus.notification.NotificationHandler
+import com.lyra.common.domain.bus.pipeline.PipelineBehavior
+import com.lyra.common.domain.bus.query.Query
+import com.lyra.common.domain.bus.query.QueryHandler
+
+class RegistryImpl(
+ dependencyProvider: DependencyProvider,
+) : Registry {
+
+ private val registry = Container(dependencyProvider)
+
+ override fun resolveCommandHandler(
+ classOfCommand: Class,
+ ): CommandHandler {
+ val handler = registry.commandMap[classOfCommand]?.get()
+ ?: throw HandlerNotFoundException("handler could not be found for ${classOfCommand.name}")
+ return handler as CommandHandler
+ }
+
+ override fun , TResult> resolveCommandWithResultHandler(
+ classOfCommand: Class,
+ ): CommandWithResultHandler {
+ val handler = registry.commandWithResultMap[classOfCommand]?.get()
+ ?: throw HandlerNotFoundException("handler could not be found for ${classOfCommand.name}")
+ return handler as CommandWithResultHandler
+ }
+
+ override fun resolveNotificationHandlers(
+ classOfNotification: Class,
+ ): Collection> =
+ registry.notificationMap.filter { (k, _) -> k.isAssignableFrom(classOfNotification) }
+ .flatMap { (_, v) -> v.map { it.get() as NotificationHandler } }
+
+ override fun , TResult> resolveQueryHandler(
+ classOfQuery: Class,
+ ): QueryHandler {
+ val handler = registry.queryMap[classOfQuery]?.get()
+ ?: throw HandlerNotFoundException("handler could not be found for ${classOfQuery.name}")
+ return handler as QueryHandler
+ }
+
+ override fun getPipelineBehaviors(): Collection =
+ registry.pipelineSet.map { it.get() }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/RequestHandlerDelegate.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/RequestHandlerDelegate.kt
new file mode 100644
index 00000000..883d08b2
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/RequestHandlerDelegate.kt
@@ -0,0 +1,6 @@
+package com.lyra.common.domain.bus
+
+/**
+ * RequestHandlerDelegate represents to Command, Query or Notification handler
+ */
+typealias RequestHandlerDelegate = suspend (TRequest) -> TResponse
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/Command.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/Command.kt
new file mode 100644
index 00000000..7c88c9ab
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/Command.kt
@@ -0,0 +1,7 @@
+package com.lyra.common.domain.bus.command
+
+/**
+ *
+ * @created 7/1/24
+ */
+interface Command
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandHandler.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandHandler.kt
new file mode 100644
index 00000000..ce6b604b
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandHandler.kt
@@ -0,0 +1,5 @@
+package com.lyra.common.domain.bus.command
+
+interface CommandHandler {
+ suspend fun handle(command: T)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandHandlerExecutionError.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandHandlerExecutionError.kt
new file mode 100644
index 00000000..20951ecf
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandHandlerExecutionError.kt
@@ -0,0 +1,3 @@
+package com.lyra.common.domain.bus.command
+
+class CommandHandlerExecutionError(cause: Throwable) : RuntimeException(cause)
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandProvider.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandProvider.kt
new file mode 100644
index 00000000..51aed473
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandProvider.kt
@@ -0,0 +1,30 @@
+package com.lyra.common.domain.bus.command
+
+import com.lyra.common.domain.bus.DependencyProvider
+
+/**
+ * CommandProvider creates a command handler with enabled spring injection.
+ *
+ * @param type of handler
+ */
+internal class CommandProvider>(
+ private val dependencyProvider: DependencyProvider,
+ private val type: Class,
+) {
+
+ fun get(): H = dependencyProvider.getSingleInstanceOf(type)
+}
+
+/**
+ * CommandProvider creates a command handler with enabled spring injection.
+ *
+ * @since 1.0.16
+ * @param type of handler
+ */
+internal class CommandWithResultProvider>(
+ private val dependencyProvider: DependencyProvider,
+ private val type: Class,
+) {
+
+ fun get(): H = dependencyProvider.getSingleInstanceOf(type)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandWithResult.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandWithResult.kt
new file mode 100644
index 00000000..8a44da1f
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandWithResult.kt
@@ -0,0 +1,9 @@
+package com.lyra.common.domain.bus.command
+
+/**
+ * Marker interface for a command with result
+ *
+ * @since 1.0.16
+ * @see CommandHandler
+ */
+interface CommandWithResult
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandWithResultHandler.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandWithResultHandler.kt
new file mode 100644
index 00000000..6d4b9c70
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/command/CommandWithResultHandler.kt
@@ -0,0 +1,17 @@
+package com.lyra.common.domain.bus.command
+
+/**
+ * Interface to be implemented for a non-blocking command with result handler
+ *
+ * @since 1.0.16
+ * @param TCommand any [Command] subclass to handle
+ * @see Command
+ */
+interface CommandWithResultHandler, TResult> {
+ /**
+ * Handles a command
+ *
+ * @param command the command to handle
+ */
+ suspend fun handle(command: TCommand): TResult
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/BaseDomainEvent.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/BaseDomainEvent.kt
new file mode 100644
index 00000000..c8dfc780
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/BaseDomainEvent.kt
@@ -0,0 +1,33 @@
+package com.lyra.common.domain.bus.event
+
+import com.lyra.common.domain.Generated
+import java.time.LocalDateTime
+
+/**
+ * Represents a base domain event.
+ *
+ * All domain events in the system should extend this class.
+ *
+ * @property occuredOn The date and time when the event occured. Defaults to the current date and time if not specified.
+ *
+ *
+ */
+open class BaseDomainEvent(private val occuredOn: LocalDateTime = LocalDateTime.now()) : DomainEvent {
+ private var eventVersion = 1
+
+ /**
+ * Returns the version of the event.
+ *
+ * @return The version of the event.
+ */
+ @Generated
+ override fun eventVersion(): Int = eventVersion
+
+ /**
+ * Returns the LocalDateTime when this event occurred.
+ *
+ * @return the LocalDateTime when this event occurred
+ */
+ @Generated
+ override fun occurredOn(): LocalDateTime = occuredOn
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/DomainEvent.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/DomainEvent.kt
new file mode 100644
index 00000000..796c0853
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/DomainEvent.kt
@@ -0,0 +1,23 @@
+package com.lyra.common.domain.bus.event
+
+import java.time.LocalDateTime
+
+/**
+ * Represents a domain event.
+ *
+ */
+interface DomainEvent {
+ /**
+ * Returns the version of the event.
+ *
+ * @return The version of the event.
+ */
+ fun eventVersion(): Int
+
+ /**
+ * Returns the LocalDateTime when this event occurred.
+ *
+ * @return the LocalDateTime when this event occurred
+ */
+ fun occurredOn(): LocalDateTime?
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventBroadcaster.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventBroadcaster.kt
new file mode 100644
index 00000000..6004b51a
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventBroadcaster.kt
@@ -0,0 +1,31 @@
+package com.lyra.common.domain.bus.event
+
+/**
+ * Class representing an EventBroadcaster.
+ *
+ * @param E the type of event being broadcasted
+ *
+ */
+class EventBroadcaster : EventPublisher {
+ private val eventPublishers = mutableListOf>()
+
+ /**
+ * Adds an EventPublisher to the list of event publishers.
+ *
+ * @param eventPublisher The EventPublisher to be added.
+ */
+ fun use(eventPublisher: EventPublisher) {
+ eventPublishers.add(eventPublisher)
+ }
+
+ /**
+ * Publishes the given event to all event publishers.
+ *
+ * @param event the event to be published
+ */
+ override suspend fun publish(event: E) {
+ eventPublishers.forEach {
+ it.publish(event)
+ }
+ }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventConsumer.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventConsumer.kt
new file mode 100644
index 00000000..fe7d80d5
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventConsumer.kt
@@ -0,0 +1,16 @@
+package com.lyra.common.domain.bus.event
+
+/**
+ * Functional interface for consuming events of type [E].
+ *
+ * @param E the type of event to be consumed.
+ *
+ */
+fun interface EventConsumer {
+ /**
+ * Consume method for handling events.
+ *
+ * @param event The event to be consumed.
+ */
+ suspend fun consume(event: E)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventFilter.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventFilter.kt
new file mode 100644
index 00000000..a2999276
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventFilter.kt
@@ -0,0 +1,21 @@
+package com.lyra.common.domain.bus.event
+
+/**
+ * Represents an event filter.
+ *
+ * An event filter is a functional interface that defines a filter for events of a specific type.
+ * The EventFilter interface provides a single method `filter(event: E): Boolean` that accepts an event of type E
+ * and returns a boolean value indicating whether the event passes the filter.
+ *
+ * @param E the type of events that can be filtered
+ *
+ */
+fun interface EventFilter {
+ /**
+ * Filters the given event based on certain conditions.
+ *
+ * @param event The event to be filtered.
+ * @return True if the event passes the filtering conditions, false otherwise.
+ */
+ suspend fun filter(event: E): Boolean
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventMultiplexer.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventMultiplexer.kt
new file mode 100644
index 00000000..683e764b
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventMultiplexer.kt
@@ -0,0 +1,36 @@
+package com.lyra.common.domain.bus.event
+
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.filter
+
+/**
+ * EventMultiplexer class is responsible for multiplexing events to multiple consumers based on provided filters.
+ * It implements the EventConsumer interface and consumes events of a specific type.
+ *
+ * @param E The type of events this multiplexer consumes.
+ *
+ */
+class EventMultiplexer : EventConsumer {
+ private val consumers = mutableListOf, EventConsumer>>()
+
+ /**
+ * Adds a consumer to the list of consumers.
+ *
+ * @param filter The filter to be used for this consumer.
+ * @param consumer The consumer to be added.
+ */
+ fun on(filter: EventFilter, consumer: EventConsumer) {
+ consumers.add(filter to consumer)
+ }
+
+ /**
+ * Consumes the given event by passing it to all consumers that pass the filter.
+ *
+ * @param event The event to be consumed.
+ */
+ override suspend fun consume(event: E) {
+ consumers.asFlow()
+ .filter { (filter, _) -> filter.filter(event) }
+ .collect { (_, consumer) -> consumer.consume(event) }
+ }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventPublisher.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventPublisher.kt
new file mode 100644
index 00000000..1504fd18
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/EventPublisher.kt
@@ -0,0 +1,23 @@
+package com.lyra.common.domain.bus.event
+
+/**
+ * Represents an event publisher that is capable of publishing events.
+ *
+ * @param E the type of event to be published
+ *
+ */
+interface EventPublisher {
+ /**
+ * Publishes the given event.
+ *
+ * @param event The event to be published.
+ */
+ suspend fun publish(event: E)
+
+ /**
+ * Publishes multiple events.
+ *
+ * @param events The events to be published.
+ */
+ suspend fun publish(events: List) = events.forEach { publish(it) }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/Subscribe.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/Subscribe.kt
new file mode 100644
index 00000000..9675c37f
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/Subscribe.kt
@@ -0,0 +1,18 @@
+package com.lyra.common.domain.bus.event
+
+import kotlin.reflect.KClass
+
+/**
+ * Annotation used to mark a method as a subscriber for events.
+ *
+ * This annotation should be applied to methods that should be invoked when events of a certain type are
+ * published. The annotated method should have exactly one parameter to receive the event object.
+ *
+ * @param filterBy Specifies the type of events that the annotated method should subscribe to.
+ * Only events of the specified type or its subclasses will be delivered to the method.
+ * If no type is specified, the method will be subscribed to all events.
+ *
+ */
+annotation class Subscribe(
+ val filterBy: KClass<*>
+)
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/TypeMatchEventFilter.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/TypeMatchEventFilter.kt
new file mode 100644
index 00000000..03ab650f
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/event/TypeMatchEventFilter.kt
@@ -0,0 +1,22 @@
+package com.lyra.common.domain.bus.event
+
+import kotlin.reflect.KClass
+
+/**
+ * A type match event filter that determines if an event is an instance of a specific class.
+ *
+ * @param T the type of events to filter
+ * @property clazz the class to match against events
+ * @constructor Creates a new instance of [TypeMatchEventFilter] with the given class.
+ * @see EventFilter
+ * @see EventPublisher
+ * @see EventConsumer
+ * @see EventMultiplexer
+ *
+ *
+ */
+class TypeMatchEventFilter(
+ private val clazz: KClass
+) : EventFilter {
+ override suspend fun filter(event: T): Boolean = clazz.isInstance(event)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/Notification.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/Notification.kt
new file mode 100644
index 00000000..11fa8836
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/Notification.kt
@@ -0,0 +1,8 @@
+package com.lyra.common.domain.bus.notification
+/**
+ * Marker interface for a notification
+ *
+ * @since 1.0.9
+ * @see NotificationHandler
+ */
+interface Notification
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/NotificationHandler.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/NotificationHandler.kt
new file mode 100644
index 00000000..5e870ba7
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/NotificationHandler.kt
@@ -0,0 +1,17 @@
+package com.lyra.common.domain.bus.notification
+/**
+ * Interface to be implemented for a non-blocking notification handler
+ *
+ * @since 1.0.9
+ * @param T any [Notification] subclass to handle
+ * @see Notification
+ */
+interface NotificationHandler where T : Notification {
+
+ /**
+ * Handles a notification
+ *
+ * @param notification the notification to handle
+ */
+ suspend fun handle(notification: T)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/NotificationProvider.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/NotificationProvider.kt
new file mode 100644
index 00000000..c05c70a4
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/notification/NotificationProvider.kt
@@ -0,0 +1,11 @@
+package com.lyra.common.domain.bus.notification
+
+import com.lyra.common.domain.bus.DependencyProvider
+
+internal class NotificationProvider>(
+ private val dependencyProvider: DependencyProvider,
+ private val type: Class,
+) {
+
+ fun get(): H = dependencyProvider.getSingleInstanceOf(type)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/pipeline/PipelineBehavior.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/pipeline/PipelineBehavior.kt
new file mode 100644
index 00000000..323c8151
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/pipeline/PipelineBehavior.kt
@@ -0,0 +1,21 @@
+package com.lyra.common.domain.bus.pipeline
+
+import com.lyra.common.domain.bus.RequestHandlerDelegate
+
+/**
+ * Interface to be implemented for a non-blocking pipeline behavior
+ *
+ * @since 1.0.12
+ */
+interface PipelineBehavior {
+ /**
+ * Process to invoke before handling any query, command or notification
+ *
+ * @param request the request to handle
+ * @param next the represents the CommandHandler handle function
+ */
+ suspend fun handle(
+ request: TRequest,
+ next: RequestHandlerDelegate
+ ): TResponse
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/pipeline/PipelineProvider.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/pipeline/PipelineProvider.kt
new file mode 100644
index 00000000..1b13f0c1
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/pipeline/PipelineProvider.kt
@@ -0,0 +1,16 @@
+package com.lyra.common.domain.bus.pipeline
+
+import com.lyra.common.domain.bus.DependencyProvider
+
+/**
+ * PipelineProvider creates a pipeline behavior with enabled spring injection.
+ *
+ * @param type of pipeline behavior
+ */
+internal class PipelineProvider(
+ private val dependencyProvider: DependencyProvider,
+ private val type: Class,
+) {
+
+ fun get(): H = dependencyProvider.getSingleInstanceOf(type)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/Query.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/Query.kt
new file mode 100644
index 00000000..5f871852
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/Query.kt
@@ -0,0 +1,7 @@
+package com.lyra.common.domain.bus.query
+
+/**
+ *
+ * @created 7/1/24
+ */
+interface Query
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryHandler.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryHandler.kt
new file mode 100644
index 00000000..94d74967
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryHandler.kt
@@ -0,0 +1,17 @@
+package com.lyra.common.domain.bus.query
+
+/**
+ * Interface to be implemented for a non-blocking query handler
+ *
+ * @since 1.0.0
+ * @param TQuery any [Query] subclass to handle
+ * @see Query
+ */
+interface QueryHandler, TResponse> {
+ /**
+ * Handles a query
+ *
+ * @param query the query to handle
+ */
+ suspend fun handle(query: TQuery): TResponse
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryHandlerExecutionError.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryHandlerExecutionError.kt
new file mode 100644
index 00000000..35eff9d6
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryHandlerExecutionError.kt
@@ -0,0 +1,3 @@
+package com.lyra.common.domain.bus.query
+
+class QueryHandlerExecutionError(cause: Throwable) : RuntimeException(cause)
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryProvider.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryProvider.kt
new file mode 100644
index 00000000..d4c0f007
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/QueryProvider.kt
@@ -0,0 +1,16 @@
+package com.lyra.common.domain.bus.query
+
+import com.lyra.common.domain.bus.DependencyProvider
+
+/**
+ * QueryProvider creates a query handler with enabled spring injection.
+ *
+ * @param type of handler
+ */
+internal class QueryProvider>(
+ private val dependencyProvider: DependencyProvider,
+ private val type: Class,
+) {
+
+ fun get(): H = dependencyProvider.getSingleInstanceOf(type)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/Response.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/Response.kt
new file mode 100644
index 00000000..d1300c5e
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/bus/query/Response.kt
@@ -0,0 +1,7 @@
+package com.lyra.common.domain.bus.query
+
+/**
+ *
+ * @created 7/1/24
+ */
+interface Response
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/Criteria.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/Criteria.kt
new file mode 100644
index 00000000..f8db2f27
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/Criteria.kt
@@ -0,0 +1,90 @@
+package com.lyra.common.domain.criteria
+
+import java.util.regex.Pattern
+
+sealed class Criteria {
+ object Empty : Criteria() {
+ override fun toString(): String = "()"
+ }
+
+ data class And(val value: List) : Criteria() {
+ override fun toString(): String = "(${value.joinToString(" AND ") { it.toString() }})"
+ }
+
+ data class Or(val value: List) : Criteria() {
+ override fun toString(): String = "(${value.joinToString(" OR ") { it.toString() }})"
+ }
+
+ data class Equals(val key: String, val value: Any) : Criteria() {
+ override fun toString(): String = "$key = $value"
+ }
+
+ data class NotEquals(val key: String, val value: Any) : Criteria() {
+ override fun toString(): String = "$key != $value"
+ }
+
+ data class Between(val key: String, val value: ClosedRange<*>) : Criteria() {
+ override fun toString(): String = "($key >= ${value.start} && $key <= ${value.endInclusive})"
+ }
+
+ data class NotBetween(val key: String, val value: ClosedRange<*>) : Criteria() {
+ override fun toString(): String = "($key < ${value.start} || $key > ${value.endInclusive})"
+ }
+
+ data class LessThan(val key: String, val value: Any) : Criteria() {
+ override fun toString(): String = "$key < $value"
+ }
+
+ data class LessThanEquals(val key: String, val value: Any) : Criteria() {
+ override fun toString(): String = "$key <= $value"
+ }
+
+ data class GreaterThan(val key: String, val value: Any) : Criteria() {
+ override fun toString(): String = "$key > $value"
+ }
+
+ data class GreaterThanEquals(val key: String, val value: Any) : Criteria() {
+ override fun toString(): String = "$key >= $value"
+ }
+
+ data class IsNull(val key: String) : Criteria() {
+ override fun toString(): String = "$key = null"
+ }
+
+ data class IsNotNull(val key: String) : Criteria() {
+ override fun toString(): String = "$key != null"
+ }
+
+ data class Like(val key: String, val value: String) : Criteria() {
+ override fun toString(): String = "$key LIKE $value"
+ }
+
+ data class NotLike(val key: String, val value: String) : Criteria() {
+ override fun toString(): String = "$key NOT LIKE $value"
+ }
+
+ data class Regexp(val key: String, val value: Pattern) : Criteria() {
+ override fun toString(): String = "$key REGEXP $value"
+ }
+
+ data class NotRegexp(val key: String, val value: Pattern) : Criteria() {
+ override fun toString(): String = "$key NOT REGEXP $value"
+ }
+
+ data class In(val key: String, val value: List) : Criteria() {
+ override fun toString(): String =
+ "$key IN [${value.joinToString { it?.toString() ?: "null" }}]"
+ }
+
+ data class NotIn(val key: String, val value: List) : Criteria() {
+ override fun toString(): String =
+ "$key NOT IN [${value.joinToString { it?.toString() ?: "null" }}]"
+ }
+ data class IsTrue(val key: String) : Criteria() {
+ override fun toString(): String = "$key IS TRUE"
+ }
+
+ data class IsFalse(val key: String) : Criteria() {
+ override fun toString(): String = "$key IS FALSE"
+ }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/CriteriaParser.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/CriteriaParser.kt
new file mode 100644
index 00000000..190d1da5
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/CriteriaParser.kt
@@ -0,0 +1,9 @@
+package com.lyra.common.domain.criteria
+
+/**
+ *
+ * @created 10/1/24
+ */
+fun interface CriteriaParser {
+ fun parse(criteria: Criteria): Out
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/CriteriaStep.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/CriteriaStep.kt
new file mode 100644
index 00000000..d4ba941f
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/CriteriaStep.kt
@@ -0,0 +1,56 @@
+package com.lyra.common.domain.criteria
+
+/**
+ *
+ * @created 10/1/24
+ */
+
+import java.util.regex.Pattern
+import kotlin.reflect.KProperty1
+
+@Suppress("TooManyFunctions")
+data class CriteriaStep(
+ val key: String
+) {
+ @Suppress("FunctionNaming")
+ fun `is`(value: Any) = Criteria.Equals(key, value)
+ fun not(value: Any) = Criteria.NotEquals(key, value)
+
+ fun between(value: ClosedRange<*>) = Criteria.Between(key, value)
+ fun notBetween(value: ClosedRange<*>) = Criteria.NotBetween(key, value)
+
+ fun lessThan(value: Any) = Criteria.LessThan(key, value)
+ fun lessThanOrEquals(value: Any) = Criteria.LessThanEquals(key, value)
+
+ fun greaterThan(value: Any) = Criteria.GreaterThan(key, value)
+ fun greaterThanOrEquals(value: Any) = Criteria.GreaterThanEquals(key, value)
+
+ fun isNull() = Criteria.IsNull(key)
+ fun isNotNull() = Criteria.IsNotNull(key)
+
+ fun like(value: String) = Criteria.Like(key, value)
+ fun notLike(value: String) = Criteria.NotLike(key, value)
+
+ fun regexp(value: Pattern) = Criteria.Regexp(key, value)
+ fun notRegexp(value: Pattern) = Criteria.NotRegexp(key, value)
+ @Suppress("FunctionNaming")
+ fun `in`(vararg value: Any) = Criteria.In(key, value.toList())
+ fun notIn(vararg value: Any) = Criteria.NotIn(key, value.toList())
+ @Suppress("FunctionNaming")
+ fun `in`(value: List) = Criteria.In(key, value)
+ fun notIn(value: List) = Criteria.NotIn(key, value)
+
+ fun isTrue() = Criteria.IsTrue(key)
+ fun isFalse() = Criteria.IsFalse(key)
+}
+
+fun where(property: KProperty1) = CriteriaStep(property.name)
+fun where(key: String) = CriteriaStep(key)
+
+fun Criteria.and(value: Criteria) = Criteria.And(mutableListOf(this).apply { add(value) })
+fun Criteria.and(value: Collection) =
+ Criteria.And(mutableListOf(this).apply { addAll(value) })
+
+fun Criteria.or(value: Criteria) = Criteria.Or(mutableListOf(this).apply { add(value) })
+fun Criteria.or(value: Collection) =
+ Criteria.Or(mutableListOf(this).apply { addAll(value) })
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/RuntimeCriteriaParser.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/RuntimeCriteriaParser.kt
new file mode 100644
index 00000000..0cacf73e
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/criteria/RuntimeCriteriaParser.kt
@@ -0,0 +1,270 @@
+package com.lyra.common.domain.criteria
+
+import com.lyra.common.domain.regexp.SqlLikeTranspiler
+import java.util.regex.Pattern
+import kotlin.reflect.KClass
+import kotlin.reflect.full.memberProperties
+
+/**
+ *
+ * @created 10/1/24
+ */
+@Suppress("MethodOverloading")
+class RuntimeCriteriaParser(
+ clazz: KClass
+) : CriteriaParser<((T) -> Boolean)?> {
+ private val properties = clazz.memberProperties.associateBy { it.name }
+
+ @Suppress("CyclomaticComplexMethod", "CognitiveComplexMethod")
+ override fun parse(criteria: Criteria): ((T) -> Boolean)? {
+ return when (criteria) {
+ is Criteria.Empty -> null
+ is Criteria.And -> parse(criteria)
+ is Criteria.Or -> parse(criteria)
+ is Criteria.Equals -> parse(criteria)
+ is Criteria.NotEquals -> parse(criteria)
+ is Criteria.Between -> parse(criteria)
+ is Criteria.NotBetween -> parse(criteria)
+ is Criteria.LessThan -> parse(criteria)
+ is Criteria.LessThanEquals -> parse(criteria)
+ is Criteria.GreaterThan -> parse(criteria)
+ is Criteria.GreaterThanEquals -> parse(criteria)
+ is Criteria.IsNull -> parse(criteria)
+ is Criteria.IsNotNull -> parse(criteria)
+ is Criteria.Like -> parse(criteria)
+ is Criteria.NotLike -> parse(criteria)
+ is Criteria.Regexp -> parse(criteria)
+ is Criteria.NotRegexp -> parse(criteria)
+ is Criteria.In -> parse(criteria)
+ is Criteria.NotIn -> parse(criteria)
+ is Criteria.IsTrue -> parse(criteria)
+ is Criteria.IsFalse -> parse(criteria)
+ }
+ }
+
+ private fun parse(criteria: Criteria.And): ((T) -> Boolean)? {
+ if (criteria.value.isEmpty()) {
+ return null
+ }
+ return criteria.value.mapNotNull { parse(it) }
+ .reduce { acc, cur -> { acc(it) && cur(it) } }
+ }
+
+ private fun parse(criteria: Criteria.Or): ((T) -> Boolean)? {
+ if (criteria.value.isEmpty()) {
+ return null
+ }
+ return criteria.value.mapNotNull { parse(it) }
+ .reduce { acc, cur -> { acc(it) || cur(it) } }
+ }
+
+ private fun parse(criteria: Criteria.Equals): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.let { property ->
+ property.get(it) == criteria.value
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.NotEquals): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.let { property ->
+ property.get(it) != criteria.value
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.Between): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.let { property ->
+ property.get(it)?.let { value ->
+ if (value is Comparable<*>) {
+ @Suppress("UNCHECKED_CAST")
+ value as Comparable
+ value >= criteria.value.start && value <= criteria.value.endInclusive
+ } else {
+ false
+ }
+ } ?: false
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.NotBetween): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.let { property ->
+ property.get(it)?.let { value ->
+ if (value is Comparable<*>) {
+ @Suppress("UNCHECKED_CAST")
+ value as Comparable
+ value < criteria.value.start || value > criteria.value.endInclusive
+ } else {
+ false
+ }
+ } ?: true
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.LessThan): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.let { property ->
+ property.get(it)?.let { value ->
+ if (value is Comparable<*>) {
+ @Suppress("UNCHECKED_CAST")
+ value as Comparable
+ value < criteria.value
+ } else {
+ false
+ }
+ } ?: false
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.LessThanEquals): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.let { property ->
+ property.get(it)?.let { value ->
+ if (value is Comparable<*>) {
+ @Suppress("UNCHECKED_CAST")
+ value as Comparable
+ value <= criteria.value
+ } else {
+ false
+ }
+ } ?: false
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.GreaterThan): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.let { property ->
+ property.get(it)?.let { value ->
+ if (value is Comparable<*>) {
+ @Suppress("UNCHECKED_CAST")
+ value as Comparable
+ value > criteria.value
+ } else {
+ false
+ }
+ } ?: false
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.GreaterThanEquals): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.let { property ->
+ property.get(it)?.let { value ->
+ if (value is Comparable<*>) {
+ @Suppress("UNCHECKED_CAST")
+ value as Comparable
+ value >= criteria.value
+ } else {
+ false
+ }
+ } ?: false
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.IsNull): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.let { property ->
+ property.get(it) == null
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.IsNotNull): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.let { property ->
+ property.get(it) != null
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.Like): (T) -> Boolean {
+ val pattern = Pattern.compile(SqlLikeTranspiler.toRegEx(criteria.value))
+ return {
+ properties[criteria.key]?.get(it)?.let { value ->
+ if (value is CharSequence) {
+ pattern.matcher(value).find()
+ } else {
+ false
+ }
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.NotLike): (T) -> Boolean {
+ val pattern = Pattern.compile(SqlLikeTranspiler.toRegEx(criteria.value))
+ return {
+ properties[criteria.key]?.get(it)?.let { value ->
+ if (value is CharSequence) {
+ !pattern.matcher(value).find()
+ } else {
+ false
+ }
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.Regexp): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.get(it)?.let { value ->
+ if (value is CharSequence) {
+ criteria.value.matcher(value).find()
+ } else {
+ false
+ }
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.NotRegexp): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.get(it)?.let { value ->
+ if (value is CharSequence) {
+ !criteria.value.matcher(value).find()
+ } else {
+ false
+ }
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.In): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.get(it)?.let { value ->
+ criteria.value.contains(value)
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.NotIn): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.get(it)?.let { value ->
+ !criteria.value.contains(value)
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.IsTrue): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.get(it)?.let { value ->
+ value == true
+ } ?: false
+ }
+ }
+
+ private fun parse(criteria: Criteria.IsFalse): (T) -> Boolean {
+ return {
+ properties[criteria.key]?.get(it)?.let { value ->
+ value == false
+ } ?: false
+ }
+ }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/email/Email.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/email/Email.kt
new file mode 100644
index 00000000..046c21bc
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/email/Email.kt
@@ -0,0 +1,33 @@
+package com.lyra.common.domain.email
+
+import com.lyra.common.domain.BaseValidateValueObject
+import com.lyra.common.domain.error.EmailNotValidException
+
+private const val EMAIL_LEN = 255
+
+/**
+ * Email value object
+ * @param email value
+ * @throws [EmailNotValidException] if email is not valid
+ * @see [BaseValidateValueObject] validate value object
+ * @see [BaseValueObject] base value object
+ * @see [EmailNotValidException] email not valid exception
+ */
+data class Email(val email: String) : BaseValidateValueObject(email) {
+
+ /**
+ * Validate email value object with regex
+ * @param value email value
+ * @throws EmailNotValidException if email is not valid
+ */
+ override fun validate(value: String) {
+ if (value.length > EMAIL_LEN || !value.matches(REGEX.toRegex())) {
+ throw EmailNotValidException(value)
+ }
+ }
+ companion object {
+ @Suppress("MaxLineLength")
+ private const val REGEX =
+ "^[a-zA-Z0-9.!#\$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\$"
+ }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/error/AggregateException.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/error/AggregateException.kt
new file mode 100644
index 00000000..e1c1a131
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/error/AggregateException.kt
@@ -0,0 +1,10 @@
+package com.lyra.common.domain.error
+
+/**
+ * A container for one or more exceptions that occurred during multiple task execution.
+ *
+ * @since 1.0.9
+ */
+class AggregateException(val exceptions: Collection) : RuntimeException() {
+ constructor(exceptions: Array) : this(exceptions.toList())
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/error/BusinessRuleValidationException.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/error/BusinessRuleValidationException.kt
new file mode 100644
index 00000000..d14aef66
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/error/BusinessRuleValidationException.kt
@@ -0,0 +1,17 @@
+package com.lyra.common.domain.error
+
+import java.lang.Exception
+
+/**
+ * An abstract class representing a business rule validation exception.
+ *
+ * This class is used to represent exceptions that occur during the validation of business rules.
+ * It extends the Throwable class and provides a message and an optional cause for the exception.
+ *
+ * @property message The detail message of the exception.
+ * @property cause The cause of the exception, if any.
+ */
+abstract class BusinessRuleValidationException(
+ override val message: String,
+ override val cause: Throwable? = null
+) : Exception(message, cause)
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/error/EmailExceptions.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/error/EmailExceptions.kt
new file mode 100644
index 00000000..e09e80ed
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/error/EmailExceptions.kt
@@ -0,0 +1,19 @@
+package com.lyra.common.domain.error
+
+/**
+ * Invalid argument email exception is thrown when an email is not valid.
+ *
+ * @created 2/7/23
+ */
+sealed class InvalidArgumentEmailException(
+ override val message: String,
+ override val cause: Throwable? = null
+) : BusinessRuleValidationException(message, cause)
+
+/**
+ * Email not valid exception is thrown when an email is not valid.
+ * @param id The email id.
+ * @param cause The cause of the exception.
+ */
+data class EmailNotValidException(val id: String, override val cause: Throwable? = null) :
+ InvalidArgumentEmailException("The email <$id> is not valid", cause)
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/SqlLikeTranspiler.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/SqlLikeTranspiler.kt
new file mode 100644
index 00000000..21ab56e2
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/SqlLikeTranspiler.kt
@@ -0,0 +1,22 @@
+package com.lyra.common.domain.regexp
+
+/**
+ *
+ * @created 10/1/24
+ */
+object SqlLikeTranspiler {
+ private val TOKENIZER = Tokenizer()
+ .add("^\\[([^]]*)]") { EscapeToken(it) }
+ .add("^(%)") { WildcardToken(it) }
+ .add("^(_)") { WildcharToken(it) }
+ .add("^([^\\[\\]%_]+)") { StringToken(it) }
+
+ fun toRegEx(pattern: String): String {
+ val sb = StringBuilder().append("^")
+ val tokens = TOKENIZER.tokenize(pattern)
+ for (token in tokens) {
+ sb.append(token.convert())
+ }
+ return sb.append("$").toString()
+ }
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/Token.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/Token.kt
new file mode 100644
index 00000000..9450653c
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/Token.kt
@@ -0,0 +1,23 @@
+package com.lyra.common.domain.regexp
+
+import java.util.regex.Pattern
+
+abstract class Token(val value: String) {
+ abstract fun convert(): String?
+}
+
+class EscapeToken(value: String) : Token(value) {
+ override fun convert(): String = Pattern.quote(value)
+}
+
+class WildcardToken(value: String) : Token(value) {
+ override fun convert(): String = ".*"
+}
+
+class WildcharToken(value: String) : Token(value) {
+ override fun convert(): String = "."
+}
+
+class StringToken(value: String) : Token(value) {
+ override fun convert(): String = Pattern.quote(value)
+}
diff --git a/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/Tokenizer.kt b/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/Tokenizer.kt
new file mode 100644
index 00000000..5720c607
--- /dev/null
+++ b/shared/common/src/main/kotlin/com/lyra/common/domain/regexp/Tokenizer.kt
@@ -0,0 +1,41 @@
+package com.lyra.common.domain.regexp
+
+import java.text.ParseException
+import java.util.regex.Pattern
+
+/**
+ *
+ * @created 10/1/24
+ */
+class Tokenizer {
+ private val patterns = mutableListOf Token>>()
+
+ fun add(regex: String, creator: (String) -> Token): Tokenizer {
+ patterns.add(Pattern.compile(regex) to creator)
+ return this
+ }
+
+ fun tokenize(clause: String): Collection {
+ val tokens = mutableListOf()
+ var copy = String(clause.toCharArray())
+ var position = 0
+ while (copy != "") {
+ var found = false
+ for ((pattern, fn) in patterns) {
+ val m = pattern.matcher(copy)
+ if (m.find()) {
+ found = true
+ val token = m.group(1)
+ tokens.add(fn(token))
+ copy = m.replaceFirst("")
+ position += token.length
+ break
+ }
+ }
+ if (!found) {
+ throw ParseException("Unexpected sequence found in input string.", ++position)
+ }
+ }
+ return tokens
+ }
+}
diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/bus/CommandHandlerTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/CommandHandlerTest.kt
new file mode 100644
index 00000000..38cee132
--- /dev/null
+++ b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/CommandHandlerTest.kt
@@ -0,0 +1,151 @@
+package com.lyra.common.domain.bus
+
+import com.lyra.common.domain.bus.command.Command
+import com.lyra.common.domain.bus.command.CommandHandler
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotNull
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+
+class CommandHandlerTest {
+
+ @Test
+ fun async_commandHandler_should_be_fired() = runBlocking {
+ class MyAsyncCommand : Command
+
+ class MyCommandHandler : CommandHandler {
+ var invocationCount = 0
+ override suspend fun handle(command: MyAsyncCommand) {
+ invocationCount++
+ }
+ }
+
+ val handler = MyCommandHandler()
+ val handlers: HashMap, Any> = hashMapOf(Pair(MyCommandHandler::class.java, handler))
+ val provider = ManualDependencyProvider(handlers)
+ val bus: Mediator = MediatorBuilder(provider).build()
+ bus.send(MyAsyncCommand())
+
+ assertEquals(1, handler.invocationCount)
+ }
+
+ @Test
+ fun `should throw exception if given async command has not been registered before`() {
+ class NonExistCommand : Command
+
+ val handlers: HashMap, Any> = hashMapOf()
+ val provider = ManualDependencyProvider(handlers)
+ val bus: Mediator = MediatorBuilder(provider).build()
+
+ val exception = assertFailsWith(HandlerNotFoundException::class) {
+ runBlocking {
+ bus.send(NonExistCommand())
+ }
+ }
+
+ assertNotNull(exception)
+ assertEquals("handler could not be found for ${NonExistCommand::class.java.typeName}", exception.message)
+ }
+
+ @Test
+ fun inheritance_should_work() = runBlocking {
+ class MyCommandForInheritance : Command
+ abstract class MyCommandHandlerFor : CommandHandler
+
+ class MyInheritedCommandHandler : MyCommandHandlerFor() {
+ var invocationCount = 0
+ override suspend fun handle(command: MyCommandForInheritance) {
+ invocationCount++
+ }
+ }
+
+ val handler = MyInheritedCommandHandler()
+ val handlers: HashMap, Any> = hashMapOf(Pair(MyInheritedCommandHandler::class.java, handler))
+ val provider = ManualDependencyProvider(handlers)
+ val bus: Mediator = MediatorBuilder(provider).build()
+ bus.send(MyCommandForInheritance())
+
+ assertEquals(1, handler.invocationCount)
+ }
+
+ @Test
+ fun inheritance_but_not_parameterized_should_work() = runBlocking {
+ class MyCommandForInheritance : Command
+
+ abstract class MyCommandHandlerBaseForSpecificCommand : CommandHandler
+
+ class MyInheritedCommandHandlerForSpecificCommand : MyCommandHandlerBaseForSpecificCommand() {
+ var invocationCount = 0
+ override suspend fun handle(command: MyCommandForInheritance) {
+ invocationCount++
+ }
+ }
+
+ val handler = MyInheritedCommandHandlerForSpecificCommand()
+ val handlers: HashMap, Any> =
+ hashMapOf(Pair(MyInheritedCommandHandlerForSpecificCommand::class.java, handler))
+ val provider = ManualDependencyProvider(handlers)
+ val bus: Mediator = MediatorBuilder(provider).build()
+ bus.send(MyCommandForInheritance())
+
+ assertEquals(1, handler.invocationCount)
+ }
+
+ @Nested
+ inner class ParameterizedTests {
+
+ @Test
+ fun async_command_should_be_fired() = runBlocking {
+ class ParameterizedCommand(val param: T) : Command
+
+ class ParameterizedCommandHandler : CommandHandler> {
+ var invocationCount = 0
+ override suspend fun handle(command: ParameterizedCommand) {
+ invocationCount++
+ }
+ }
+
+ // given
+ val handler = ParameterizedCommandHandler>()
+ val handlers: HashMap, Any> =
+ hashMapOf(Pair(ParameterizedCommandHandler::class.java, handler))
+ val provider = ManualDependencyProvider(handlers)
+ val bus: Mediator = MediatorBuilder(provider).build()
+
+ // when
+ bus.send(ParameterizedCommand("MyParam"))
+
+ // then
+ assertEquals(1, handler.invocationCount)
+ }
+
+ @Test
+ fun async_commandHandler_with_inheritance_should_be_fired() = runBlocking {
+ class ParameterizedCommand(val param: T) : Command
+
+ abstract class ParameterizedCommandHandlerBase : CommandHandler>
+
+ class ParameterizedCommandHandler : ParameterizedCommandHandlerBase() {
+ var invocationCount = 0
+ override suspend fun handle(command: ParameterizedCommand) {
+ invocationCount++
+ }
+ }
+
+ // given
+ val handler = ParameterizedCommandHandler>()
+ val handlers: HashMap, Any> =
+ hashMapOf(Pair(ParameterizedCommandHandler::class.java, handler))
+ val provider = ManualDependencyProvider(handlers)
+ val bus: Mediator = MediatorBuilder(provider).build()
+
+ // when
+ bus.send(ParameterizedCommand("MyParam"))
+
+ // then
+ assertEquals(1, handler.invocationCount)
+ }
+ }
+}
diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/bus/CommandWithResultHandlerTest.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/CommandWithResultHandlerTest.kt
new file mode 100644
index 00000000..ca981d41
--- /dev/null
+++ b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/CommandWithResultHandlerTest.kt
@@ -0,0 +1,159 @@
+package com.lyra.common.domain.bus
+
+import com.lyra.common.domain.bus.command.Command
+import com.lyra.common.domain.bus.command.CommandWithResult
+import com.lyra.common.domain.bus.command.CommandWithResultHandler
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+
+private var counter = 0
+private var asyncTestCounter = 0
+
+class CommandWithResultHandlerTest {
+
+ init {
+ counter = 0
+ asyncTestCounter = 0
+ }
+
+ @Test
+ fun `async commandHandler should be fired`() = runBlocking {
+ val handler = AsyncMyCommandRHandler()
+ val handlers: HashMap, Any> =
+ hashMapOf(Pair(AsyncMyCommandRHandler::class.java, handler))
+ val provider = ManualDependencyProvider(handlers)
+ val bus: Mediator = MediatorBuilder(provider).build()
+ bus.send(MyAsyncCommandR())
+
+ assertTrue {
+ asyncTestCounter == 1
+ }
+ }
+
+ @Test
+ fun `should throw exception if given async command has not been registered before`() {
+ val provider = ManualDependencyProvider(hashMapOf())
+ val bus: Mediator = MediatorBuilder(provider).build()
+ val exception = assertFailsWith(HandlerNotFoundException::class) {
+ runBlocking {
+ bus.send(NonExistCommandR())
+ }
+ }
+
+ assertNotNull(exception)
+ assertEquals(
+ "handler could not be found for com.lyra.common.domain.bus.NonExistCommandR",
+ exception.message,
+ )
+ }
+
+ @Test
+ fun inheritance_should_work() = runBlocking {
+ var invocationCount = 0
+
+ class MyAsyncCommand : CommandWithResult
+
+ class AsyncMyCommandHandler : CommandWithResultHandler {
+ override suspend fun handle(command: MyAsyncCommand): Result {
+ invocationCount++
+ return Result()
+ }
+ }
+
+ val handler = AsyncMyCommandHandler()
+ val handlers: HashMap, Any> =
+ hashMapOf(Pair(AsyncMyCommandHandler::class.java, handler))
+ val provider = ManualDependencyProvider(handlers)
+ val bus: Mediator = MediatorBuilder(provider).build()
+ bus.send(MyAsyncCommand())
+
+ assertEquals(1, invocationCount)
+ }
+
+ @Nested
+ inner class ParamaterizedTests {
+ init {
+ counter = 0
+ asyncTestCounter = 0
+ }
+
+ inner class ParameterizedCommandWithResult(val param: TParam) :
+ CommandWithResult
+
+ inner class ParatemerizedAsyncCommandWithResultHandler :
+ CommandWithResultHandler, String> {
+ override suspend fun handle(command: ParameterizedCommandWithResult): String {
+ counter++
+ return command.param.toString()
+ }
+ }
+
+ @Test
+ fun `async commandWithResult should be fired and return result`() = runBlocking {
+ // given
+ val handler =
+ ParatemerizedAsyncCommandWithResultHandler>()
+ val handlers: HashMap, Any> =
+ hashMapOf(Pair(ParatemerizedAsyncCommandWithResultHandler::class.java, handler))
+ val provider = ManualDependencyProvider(handlers)
+ val bus: Mediator = MediatorBuilder(provider).build()
+
+ // when
+ val result = bus.send(ParameterizedCommandWithResult(61L))
+
+ // then
+ assertTrue { counter == 1 }
+ assertEquals(result, "61")
+ }
+
+ @Test
+ fun inheritance_should_work() = runBlocking {
+ var invocationCount = 0
+
+ class ParameterizedCommandWithResult(val param: TParam) : CommandWithResult
+
+ abstract class ParameterizedCommandWithResultHandlerBase> :
+ CommandWithResultHandler
+
+ class Handler :
+ ParameterizedCommandWithResultHandlerBase>() {
+ override suspend fun handle(command: ParameterizedCommandWithResult): String {
+ invocationCount++
+ return command.param.toString()
+ }
+ }
+
+ val handler = Handler>()
+ val handlers: HashMap, Any> = hashMapOf(Pair(Handler::class.java, handler))
+ val provider = ManualDependencyProvider(handlers)
+ val bus: Mediator = MediatorBuilder(provider).build()
+
+ // when
+ val result = bus.send(ParameterizedCommandWithResult("invoked"))
+
+ // then
+ assertEquals(1, invocationCount)
+ assertEquals(result, "invoked")
+ }
+ }
+}
+
+private class Result
+private class NonExistCommandR : Command
+
+private class MyAsyncCommandR : CommandWithResult
+
+private class AsyncMyCommandRHandler : CommandWithResultHandler {
+ override suspend fun handle(command: MyAsyncCommandR): Result {
+ delay(500)
+ asyncTestCounter++
+
+ return Result()
+ }
+}
diff --git a/shared/common/src/test/kotlin/com/lyra/common/domain/bus/ManualDependencyProvider.kt b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/ManualDependencyProvider.kt
new file mode 100644
index 00000000..ec5db90e
--- /dev/null
+++ b/shared/common/src/test/kotlin/com/lyra/common/domain/bus/ManualDependencyProvider.kt
@@ -0,0 +1,48 @@
+@file:Suppress("UNCHECKED_CAST")
+package com.lyra.common.domain.bus
+
+import java.lang.reflect.ParameterizedType
+
+class ManualDependencyProvider(
+ private val handlerMap: HashMap, Any>,
+) : DependencyProvider {
+ override fun getSingleInstanceOf(clazz: Class