├── .java-version ├── settings.gradle.kts ├── .gitignore ├── use-case.png ├── team-mgmt-service.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src ├── main │ ├── kotlin │ │ └── com │ │ │ └── teammgmt │ │ │ ├── infrastructure │ │ │ ├── outbox │ │ │ │ ├── TransactionalOutbox.kt │ │ │ │ ├── KafkaOutboxEventProducer.kt │ │ │ │ ├── OutboxEvent.kt │ │ │ │ ├── PollingPublisher.kt │ │ │ │ └── PostgresOutboxEventRepository.kt │ │ │ ├── adapters │ │ │ │ ├── outbound │ │ │ │ │ ├── client │ │ │ │ │ │ ├── HttpCallNonSucceededException.kt │ │ │ │ │ │ └── PeopleServiceHttpClient.kt │ │ │ │ │ ├── event │ │ │ │ │ │ ├── InMemoryDomainEventPublisher.kt │ │ │ │ │ │ ├── MetricsSubscriber.kt │ │ │ │ │ │ ├── LoggingSubscriber.kt │ │ │ │ │ │ ├── IntegrationTeamEvents.kt │ │ │ │ │ │ └── OutboxSubscriber.kt │ │ │ │ │ └── db │ │ │ │ │ │ ├── PostgresPeopleReplicationRepository.kt │ │ │ │ │ │ └── PostgresTeamRepository.kt │ │ │ │ └── inbound │ │ │ │ │ ├── http │ │ │ │ │ ├── HttpErrorResponses.kt │ │ │ │ │ └── TeamsHttpController.kt │ │ │ │ │ └── stream │ │ │ │ │ └── KafkaPeopleEventsConsumer.kt │ │ │ └── configuration │ │ │ │ ├── ApplicationServicesConfiguration.kt │ │ │ │ ├── KafkaConfiguration.kt │ │ │ │ ├── OutboxConfiguration.kt │ │ │ │ └── InfrastructureConfiguration.kt │ │ │ ├── domain │ │ │ └── model │ │ │ │ ├── ArrowExtensions.kt │ │ │ │ ├── TeamRepository.kt │ │ │ │ ├── PeopleFinder.kt │ │ │ │ ├── DomainError.kt │ │ │ │ ├── DomainEvents.kt │ │ │ │ └── Team.kt │ │ │ ├── App.kt │ │ │ └── application │ │ │ └── service │ │ │ ├── CreateTeamService.kt │ │ │ ├── RemoveTeamMemberService.kt │ │ │ └── AddTeamMemberService.kt │ └── resources │ │ ├── log4j2.xml │ │ ├── db │ │ └── migration │ │ │ └── V001__init.sql │ │ └── application.yaml └── test │ └── kotlin │ └── com │ └── teammgmt │ ├── domain │ └── model │ │ ├── FullNameShould.kt │ │ └── TeamShould.kt │ ├── Kafka.kt │ ├── infrastructure │ ├── adapters │ │ ├── outbound │ │ │ ├── event │ │ │ │ ├── InMemoryDomainEventPublisherShould.kt │ │ │ │ ├── MetricsSubscriberShould.kt │ │ │ │ ├── LoggingSubscriberShould.kt │ │ │ │ └── OutboxSubscriberShould.kt │ │ │ ├── client │ │ │ │ └── PeopleServiceHttpClientShould.kt │ │ │ └── db │ │ │ │ ├── PostgresPeopleReplicationRepositoryShould.kt │ │ │ │ └── PostgresTeamRepositoryShould.kt │ │ └── inbound │ │ │ ├── http │ │ │ ├── HttpErrorResponsesShould.kt │ │ │ └── TeamsHttpControllerShould.kt │ │ │ └── stream │ │ │ └── KafkaPeopleEventsConsumerShould.kt │ └── outbox │ │ ├── KafkaOutboxEventProducerShould.kt │ │ ├── PostgresOutboxEventRepositoryShould.kt │ │ └── PollingPublisherShould.kt │ ├── acceptance │ ├── CreateTeamShould.kt │ ├── AddTeamMemberToTeamShould.kt │ ├── DeleteTeamMemberFromTeamShould.kt │ └── BaseAcceptanceTest.kt │ ├── Postgres.kt │ ├── fixtures │ ├── PeopleServiceStubs.kt │ ├── Builders.kt │ └── KafkaExtensions.kt │ └── application │ └── service │ ├── CreateTeamServiceShould.kt │ ├── RemoveTeamMemberServiceShould.kt │ └── AddTeamMemberServiceShould.kt ├── gradlew.bat ├── README.md ├── .editorconfig └── gradlew /.java-version: -------------------------------------------------------------------------------- 1 | 11.0 2 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "team-mgmt-service" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle 2 | .gradle/ 3 | build/ 4 | .idea/ 5 | *.iml 6 | out/ 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /use-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/allousas/team-management-microservice/HEAD/use-case.png -------------------------------------------------------------------------------- /team-mgmt-service.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/allousas/team-management-microservice/HEAD/team-mgmt-service.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/allousas/team-management-microservice/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/outbox/TransactionalOutbox.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.outbox 2 | 3 | interface TransactionalOutbox { 4 | 5 | fun storeForPublishing(event: OutboxEvent) 6 | 7 | fun findReadyForPublishing(batchSize: Int): List 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/domain/model/ArrowExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.domain.model 2 | 3 | import arrow.core.Either 4 | 5 | fun Either.peek(consume: (B) -> Unit) = when (this) { 6 | is Either.Left -> this 7 | is Either.Right -> consume(this.value).let { this } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/domain/model/TeamRepository.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.domain.model 2 | 3 | import arrow.core.Either 4 | 5 | interface TeamRepository { 6 | 7 | fun find(teamId: TeamId): Either 8 | fun save(team: Team): Either 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/App.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | 6 | @SpringBootApplication 7 | class App 8 | 9 | fun main(args: Array) { 10 | runApplication(*args) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/domain/model/PeopleFinder.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.domain.model 2 | 3 | import arrow.core.Either 4 | import java.util.UUID 5 | 6 | interface PeopleFinder { 7 | fun find(personId: PersonId): Either 8 | } 9 | 10 | data class PersonId(val value: UUID) 11 | 12 | data class Person(val personId: PersonId, val fullName: FullName) 13 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/adapters/outbound/client/HttpCallNonSucceededException.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.client 2 | 3 | data class HttpCallNonSucceededException( 4 | val httpClient: String, 5 | val errorBody: String?, 6 | val httpStatus: Int 7 | ) : RuntimeException("Http call with '$httpClient' failed with status '$httpStatus' and body '$errorBody' ") 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/domain/model/DomainError.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.domain.model 2 | 3 | sealed class DomainError 4 | sealed class TeamValidationError : DomainError() { 5 | object TooLongName : TeamValidationError() 6 | } 7 | object TeamNameAlreadyTaken : DomainError() 8 | object TeamNotFound : DomainError() 9 | object TeamMemberNotFound : DomainError() 10 | object AlreadyPartOfTheTeam : DomainError() 11 | object PersonNotFound : DomainError() 12 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/domain/model/FullNameShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.domain.model 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.junit.jupiter.api.Test 5 | 6 | class FullNameShould { 7 | 8 | @Test 9 | fun `create a full name from a first and last name`() { 10 | assertThat(FullName.create(firstName = "John", lastName = "Doe")) 11 | .isEqualTo(FullName.reconstitute("John Doe")) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/adapters/outbound/event/InMemoryDomainEventPublisher.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.event 2 | 3 | import com.teammgmt.domain.model.DomainEvent 4 | import com.teammgmt.domain.model.DomainEventPublisher 5 | import com.teammgmt.domain.model.DomainEventSubscriber 6 | 7 | class InMemoryDomainEventPublisher(val subscribers: List) : DomainEventPublisher { 8 | 9 | override fun publish(event: DomainEvent) = subscribers.forEach { it.handle(event) } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/adapters/outbound/event/MetricsSubscriber.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.event 2 | 3 | import com.teammgmt.domain.model.DomainEvent 4 | import com.teammgmt.domain.model.DomainEventSubscriber 5 | import io.micrometer.core.instrument.MeterRegistry 6 | import io.micrometer.core.instrument.Tag 7 | 8 | class MetricsSubscriber(private val metrics: MeterRegistry) : DomainEventSubscriber { 9 | 10 | override fun handle(event: DomainEvent) { 11 | val tags = listOf(Tag.of("type", event::class.simpleName!!)) 12 | metrics.counter("domain.event", tags).increment() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/domain/model/DomainEvents.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.domain.model 2 | 3 | sealed class DomainEvent { 4 | abstract val team: Team 5 | } 6 | 7 | data class TeamCreated(override val team: Team) : DomainEvent() 8 | data class TeamMemberJoined(override val team: Team, val personId: PersonId) : DomainEvent() 9 | data class TeamMemberLeft(override val team: Team, val personId: PersonId) : DomainEvent() 10 | // data class TeamDeleted(override val team: Team) : DomainEvent() 11 | 12 | interface DomainEventPublisher { 13 | fun publish(event: DomainEvent) 14 | } 15 | 16 | interface DomainEventSubscriber { 17 | fun handle(event: DomainEvent) 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/outbox/KafkaOutboxEventProducer.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.outbox 2 | 3 | import org.apache.kafka.clients.producer.ProducerRecord 4 | import org.springframework.kafka.core.KafkaTemplate 5 | 6 | class KafkaOutboxEventProducer(private val kafkaTemplate: KafkaTemplate) { 7 | 8 | fun send(batch: List) = 9 | batch.map(::toKafkaRecord) 10 | .forEach(kafkaTemplate::send) 11 | .also { if (batch.isNotEmpty()) kafkaTemplate.flush() } 12 | 13 | private fun toKafkaRecord(outboxMessage: OutboxEvent) = 14 | ProducerRecord(outboxMessage.stream, outboxMessage.aggregateId.toString(), outboxMessage.payload) 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/outbox/OutboxEvent.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.outbox 2 | 3 | import java.util.UUID 4 | 5 | class OutboxEvent(val aggregateId: UUID, val stream: String, val payload: ByteArray) { 6 | 7 | override fun equals(other: Any?): Boolean { 8 | if (this === other) return true 9 | if (javaClass != other?.javaClass) return false 10 | 11 | other as OutboxEvent 12 | 13 | if (aggregateId != other.aggregateId) return false 14 | if (stream != other.stream) return false 15 | if (!payload.contentEquals(other.payload)) return false 16 | 17 | return true 18 | } 19 | 20 | override fun hashCode(): Int { 21 | var result = aggregateId.hashCode() 22 | result = 31 * result + stream.hashCode() 23 | result = 31 * result + payload.contentHashCode() 24 | return result 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V001__init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE public.team 2 | ( 3 | id UUID NOT NULL, 4 | name TEXT NOT NULL, 5 | created TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), 6 | members TEXT[] NOT NULL, 7 | CONSTRAINT unique_team_name UNIQUE (name), 8 | CONSTRAINT pk_team PRIMARY KEY (id) 9 | ); 10 | 11 | CREATE TABLE public.outbox 12 | ( 13 | id UUID PRIMARY KEY, 14 | aggregate_id UUID NOT NULL, 15 | event_payload BYTEA NOT NULL, 16 | stream TEXT NOT NULL, 17 | created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() 18 | ); 19 | 20 | 21 | CREATE TABLE public.people_replication 22 | ( 23 | id UUID NOT NULL, 24 | status TEXT NOT NULL, 25 | first_name TEXT NOT NULL, 26 | last_name TEXT NOT NULL, 27 | joined_at TIMESTAMPTZ NOT NULL, 28 | CONSTRAINT pk_people_replication PRIMARY KEY (id) 29 | ); 30 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/Kafka.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt 2 | 3 | import org.apache.kafka.clients.admin.AdminClient 4 | import org.apache.kafka.clients.admin.AdminClientConfig.* 5 | import org.apache.kafka.clients.admin.NewTopic 6 | import org.testcontainers.containers.KafkaContainer 7 | import org.testcontainers.utility.DockerImageName 8 | 9 | class Kafka { 10 | val container = KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka")) 11 | .also { it.start() } 12 | .also { 13 | System.setProperty("spring.kafka.producer.bootstrap-servers", it.bootstrapServers) 14 | System.setProperty("spring.kafka.consumer.bootstrap-servers", it.bootstrapServers) 15 | }.also { createTopics(it) } 16 | 17 | private fun createTopics(kafka: KafkaContainer) = 18 | listOf(NewTopic("public.team.v1", 1, 1)) 19 | .let { 20 | AdminClient 21 | .create(mapOf(Pair(BOOTSTRAP_SERVERS_CONFIG, kafka.bootstrapServers))) 22 | .createTopics(it) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/adapters/outbound/event/InMemoryDomainEventPublisherShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.event 2 | 3 | import com.teammgmt.domain.model.DomainEventSubscriber 4 | import com.teammgmt.domain.model.TeamCreated 5 | import com.teammgmt.fixtures.buildTeam 6 | import io.mockk.mockk 7 | import io.mockk.verify 8 | import org.junit.jupiter.api.Test 9 | 10 | class InMemoryDomainEventPublisherShould { 11 | 12 | @Test 13 | fun `forward domain event to subscribers`() { 14 | val event = TeamCreated(buildTeam()) 15 | val firstSubscriber = mockk(relaxed = true) 16 | val secondSubscriber = mockk(relaxed = true) 17 | val thirdSubscriber = mockk(relaxed = true) 18 | val publisher = InMemoryDomainEventPublisher( 19 | listOf(firstSubscriber, secondSubscriber, thirdSubscriber) 20 | ) 21 | 22 | publisher.publish(event) 23 | 24 | verify { 25 | firstSubscriber.handle(event) 26 | secondSubscriber.handle(event) 27 | thirdSubscriber.handle(event) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/adapters/outbound/event/LoggingSubscriber.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.event 2 | 3 | import com.teammgmt.domain.model.DomainEvent 4 | import com.teammgmt.domain.model.DomainEventSubscriber 5 | import com.teammgmt.domain.model.TeamCreated 6 | import com.teammgmt.domain.model.TeamMemberJoined 7 | import com.teammgmt.domain.model.TeamMemberLeft 8 | import org.slf4j.Logger 9 | import org.slf4j.LoggerFactory 10 | import java.lang.invoke.MethodHandles 11 | 12 | class LoggingSubscriber( 13 | private val logger: Logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()) 14 | ) : DomainEventSubscriber { 15 | 16 | override fun handle(event: DomainEvent) { 17 | logger.info(event.logMessage()) 18 | } 19 | 20 | private fun DomainEvent.logMessage(): String { 21 | val commonMessage = "domain-event: '${this::class.simpleName}', team-id: '${this.team.teamId.value}'" 22 | val specificMessage = when (this) { 23 | is TeamMemberJoined -> ", person-id: ${this.personId}" 24 | is TeamMemberLeft -> ", person-id: ${this.personId}" 25 | is TeamCreated -> "" 26 | } 27 | return "$commonMessage$specificMessage" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/adapters/inbound/http/HttpErrorResponses.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.inbound.http 2 | 3 | import com.teammgmt.domain.model.AlreadyPartOfTheTeam 4 | import com.teammgmt.domain.model.DomainError 5 | import com.teammgmt.domain.model.PersonNotFound 6 | import com.teammgmt.domain.model.TeamMemberNotFound 7 | import com.teammgmt.domain.model.TeamNameAlreadyTaken 8 | import com.teammgmt.domain.model.TeamNotFound 9 | import com.teammgmt.domain.model.TeamValidationError.TooLongName 10 | import org.springframework.http.HttpStatus.CONFLICT 11 | import org.springframework.http.HttpStatus.NOT_FOUND 12 | import org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY 13 | import org.springframework.http.ResponseEntity 14 | 15 | val conflict = ResponseEntity(CONFLICT) 16 | val notFound = ResponseEntity(NOT_FOUND) 17 | val unprocessableEntity = ResponseEntity(UNPROCESSABLE_ENTITY) 18 | 19 | // TODO: Improve, map errors better, add meaningful error payloads 20 | fun DomainError.asHttpResponse(): ResponseEntity = when (this) { 21 | AlreadyPartOfTheTeam -> conflict 22 | TeamNameAlreadyTaken, TooLongName -> unprocessableEntity 23 | PersonNotFound, TeamMemberNotFound, TeamNotFound -> notFound 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/application/service/CreateTeamService.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.application.service 2 | 3 | import arrow.core.Either 4 | import arrow.core.flatMap 5 | import com.teammgmt.domain.model.DomainError 6 | import com.teammgmt.domain.model.DomainEventPublisher 7 | import com.teammgmt.domain.model.Team 8 | import com.teammgmt.domain.model.TeamCreated 9 | import com.teammgmt.domain.model.TeamRepository 10 | import com.teammgmt.domain.model.peek 11 | import org.springframework.transaction.annotation.Transactional 12 | import java.util.UUID 13 | 14 | open class CreateTeamService( 15 | private val teamRepository: TeamRepository, 16 | private val domainEventPublisher: DomainEventPublisher, 17 | private val generateId: () -> UUID = { UUID.randomUUID() } 18 | ) { 19 | 20 | @Transactional 21 | open operator fun invoke(request: CreateTeamRequest): Either = 22 | Team.create(generateId(), request.teamName) 23 | .flatMap(teamRepository::save) 24 | .peek { domainEventPublisher.publish(TeamCreated(it)) } 25 | .map { CreateTeamResponse(it.teamId.value) } 26 | } 27 | 28 | data class CreateTeamRequest(val teamName: String) 29 | 30 | data class CreateTeamResponse(val newTeamId: UUID) 31 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/application/service/RemoveTeamMemberService.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.application.service 2 | 3 | import arrow.core.Either 4 | import arrow.core.flatMap 5 | import com.teammgmt.domain.model.DomainError 6 | import com.teammgmt.domain.model.DomainEventPublisher 7 | import com.teammgmt.domain.model.PersonId 8 | import com.teammgmt.domain.model.TeamId 9 | import com.teammgmt.domain.model.TeamMemberLeft 10 | import com.teammgmt.domain.model.TeamRepository 11 | import org.springframework.transaction.annotation.Transactional 12 | import java.util.UUID 13 | 14 | open class RemoveTeamMemberService( 15 | private val teamRepository: TeamRepository, 16 | private val domainEventPublisher: DomainEventPublisher 17 | ) { 18 | @Transactional 19 | open operator fun invoke(request: RemoveTeamMemberRequest): Either = 20 | teamRepository.find(TeamId(request.teamId)) 21 | .flatMap { team -> team.leave(PersonId(request.teamMemberId)) } 22 | .flatMap(teamRepository::save) 23 | .map { TeamMemberLeft(it, PersonId(request.teamMemberId)) } 24 | .map(domainEventPublisher::publish) 25 | } 26 | 27 | data class RemoveTeamMemberRequest( 28 | val teamId: UUID, 29 | val teamMemberId: UUID 30 | ) 31 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/configuration/ApplicationServicesConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.configuration 2 | 3 | import com.teammgmt.application.service.AddTeamMemberService 4 | import com.teammgmt.application.service.CreateTeamService 5 | import com.teammgmt.application.service.RemoveTeamMemberService 6 | import com.teammgmt.domain.model.DomainEventPublisher 7 | import com.teammgmt.domain.model.PeopleFinder 8 | import com.teammgmt.domain.model.TeamRepository 9 | import org.springframework.context.annotation.Bean 10 | import org.springframework.context.annotation.Configuration 11 | 12 | @Configuration 13 | class ApplicationServicesConfiguration { 14 | 15 | @Bean 16 | fun addTeamMember( 17 | teamRepository: TeamRepository, 18 | peopleFinder: PeopleFinder, 19 | domainEventPublisher: DomainEventPublisher 20 | ) = AddTeamMemberService(teamRepository, peopleFinder, domainEventPublisher) 21 | 22 | @Bean 23 | fun createTeam(teamRepository: TeamRepository, domainEventPublisher: DomainEventPublisher) = 24 | CreateTeamService(teamRepository, domainEventPublisher) 25 | 26 | @Bean 27 | fun removeTeamMember(teamRepository: TeamRepository, domainEventPublisher: DomainEventPublisher) = 28 | RemoveTeamMemberService(teamRepository, domainEventPublisher) 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/application/service/AddTeamMemberService.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.application.service 2 | 3 | import arrow.core.Either 4 | import arrow.core.flatMap 5 | import arrow.core.zip 6 | import com.teammgmt.domain.model.DomainError 7 | import com.teammgmt.domain.model.DomainEventPublisher 8 | import com.teammgmt.domain.model.PeopleFinder 9 | import com.teammgmt.domain.model.PersonId 10 | import com.teammgmt.domain.model.TeamId 11 | import com.teammgmt.domain.model.TeamMemberJoined 12 | import com.teammgmt.domain.model.TeamRepository 13 | import org.springframework.transaction.annotation.Transactional 14 | import java.util.UUID 15 | 16 | open class AddTeamMemberService( 17 | private val teamRepository: TeamRepository, 18 | private val peopleFinder: PeopleFinder, 19 | private val domainEventPublisher: DomainEventPublisher 20 | ) { 21 | 22 | @Transactional 23 | open operator fun invoke(request: AddTeamMemberRequest): Either = 24 | peopleFinder.find(PersonId(request.newMemberId)) 25 | .zip(teamRepository.find(TeamId(request.teamId))) 26 | .flatMap { (person, team) -> team.join(person) } 27 | .flatMap(teamRepository::save) 28 | .map { TeamMemberJoined(it, PersonId(request.newMemberId)) } 29 | .map(domainEventPublisher::publish) 30 | } 31 | 32 | data class AddTeamMemberRequest( 33 | val teamId: UUID, 34 | val newMemberId: UUID 35 | ) 36 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/configuration/KafkaConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.configuration 2 | 3 | import org.springframework.beans.factory.annotation.Value 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.kafka.core.KafkaTemplate 7 | import org.springframework.kafka.listener.DeadLetterPublishingRecoverer 8 | import org.springframework.kafka.listener.SeekToCurrentErrorHandler 9 | import org.springframework.util.backoff.ExponentialBackOff 10 | 11 | @Configuration 12 | class KafkaConfiguration { 13 | 14 | @Bean 15 | fun deadLetterPublishingRecoverer(bytesTemplate: KafkaTemplate) = 16 | DeadLetterPublishingRecoverer(bytesTemplate) 17 | 18 | @Bean 19 | fun seekToCurrentErrorHandler( 20 | @Value("\${spring.kafka.error-handling.exponential-backoff.initial-interval}") initialInterval: Long, 21 | @Value("\${spring.kafka.error-handling.exponential-backoff.multiplier:1.5}") multiplier: Double, 22 | @Value("\${spring.kafka.error-handling.exponential-backoff.max-elapsed-time:30000}") maxElapsedTime: Long, 23 | deadLetterPublishingRecoverer: DeadLetterPublishingRecoverer, 24 | ): SeekToCurrentErrorHandler { 25 | val exponentialBackOff = ExponentialBackOff(initialInterval, multiplier) 26 | .apply { this.maxElapsedTime = maxElapsedTime } 27 | return SeekToCurrentErrorHandler(deadLetterPublishingRecoverer, exponentialBackOff) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/acceptance/CreateTeamShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.acceptance 2 | 3 | import com.fasterxml.jackson.module.kotlin.readValue 4 | import com.teammgmt.fixtures.consumeAndAssert 5 | import com.teammgmt.infrastructure.adapters.outbound.event.IntegrationEvent.TeamCreatedEvent 6 | import io.restassured.RestAssured.given 7 | import io.restassured.http.ContentType 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.junit.jupiter.api.Tag 10 | import org.junit.jupiter.api.Test 11 | import org.junit.jupiter.api.assertDoesNotThrow 12 | import org.springframework.boot.test.context.SpringBootTest 13 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT 14 | 15 | @Tag("acceptance") 16 | @SpringBootTest(webEnvironment = RANDOM_PORT) 17 | class CreateTeamShould : BaseAcceptanceTest() { 18 | 19 | @Test 20 | fun `create a team successfully`() { 21 | val response = given() 22 | .contentType(ContentType.JSON) 23 | .body("""{ "name": "Hungry Hippos" }""") 24 | .port(servicePort) 25 | .`when`() 26 | .post("/teams") 27 | .then() 28 | 29 | assertThat(response.extract().statusCode()).isEqualTo(201) 30 | val userId: String = response.extract().path("id") 31 | kafkaConsumer.consumeAndAssert(stream = "public.team.v1") { record -> 32 | assertThat(record.key()).isEqualTo(userId) 33 | assertDoesNotThrow { mapper.readValue(record.value()) } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/Postgres.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt 2 | 3 | import com.teammgmt.domain.model.Team 4 | import com.zaxxer.hikari.HikariDataSource 5 | import org.flywaydb.core.Flyway 6 | import org.flywaydb.core.api.configuration.FluentConfiguration 7 | import org.springframework.jdbc.core.JdbcTemplate 8 | import org.testcontainers.containers.Network 9 | import org.testcontainers.containers.PostgreSQLContainer 10 | import javax.sql.DataSource 11 | 12 | class Postgres { 13 | 14 | val container: KtPostgreSQLContainer = KtPostgreSQLContainer() 15 | .withNetwork(Network.newNetwork()) 16 | .withNetworkAliases("localhost") 17 | .withUsername("teammgmt") 18 | .withPassword("teammgmt") 19 | .withDatabaseName("teammgmt") 20 | .also { 21 | it.start() 22 | } 23 | 24 | val datasource: DataSource = HikariDataSource().apply { 25 | driverClassName = org.postgresql.Driver::class.qualifiedName 26 | jdbcUrl = container.jdbcUrl 27 | username = container.username 28 | password = container.password 29 | }.also { Flyway(FluentConfiguration().dataSource(it)).migrate() } 30 | 31 | private val jdbcTemplate = JdbcTemplate(datasource) 32 | 33 | fun addTeam(team: Team) = 34 | jdbcTemplate.update( 35 | """ INSERT INTO team (id, name, members) VALUES (?,?,?) """, 36 | team.teamId.value, 37 | team.teamName.value, 38 | team.members.map { it.personId.value.toString() }.toTypedArray() 39 | ) 40 | } 41 | 42 | class KtPostgreSQLContainer : PostgreSQLContainer("postgres:13.4") 43 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/acceptance/AddTeamMemberToTeamShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.acceptance 2 | 3 | import com.fasterxml.jackson.module.kotlin.readValue 4 | import com.teammgmt.fixtures.consumeAndAssertMultiple 5 | import com.teammgmt.infrastructure.adapters.outbound.event.IntegrationEvent 6 | import io.restassured.RestAssured.given 7 | import kotlinx.coroutines.runBlocking 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.junit.jupiter.api.Tag 10 | import org.junit.jupiter.api.Test 11 | import org.junit.jupiter.api.assertDoesNotThrow 12 | import org.springframework.boot.test.context.SpringBootTest 13 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT 14 | 15 | @Tag("acceptance") 16 | @SpringBootTest(webEnvironment = RANDOM_PORT) 17 | class AddTeamMemberToTeamShould : BaseAcceptanceTest() { 18 | 19 | @Test 20 | fun `add a team member to a team successfully`() { 21 | runBlocking { 22 | val teamId = givenATeamExists() 23 | val personId = givenAPersonExists() 24 | 25 | val response = given() 26 | .port(servicePort) 27 | .`when`() 28 | .put("/teams/$teamId/person/$personId") 29 | .then() 30 | 31 | assertThat(response.extract().statusCode()).isEqualTo(204) 32 | kafkaConsumer.consumeAndAssertMultiple(stream = "public.team.v1", numberOfMessages = 2) { records -> 33 | assertThat(records[1].key()).isEqualTo(teamId.toString()) 34 | assertDoesNotThrow { mapper.readValue(records[1].value()) } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/adapters/inbound/http/HttpErrorResponsesShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.inbound.http 2 | 3 | import com.teammgmt.domain.model.AlreadyPartOfTheTeam 4 | import com.teammgmt.domain.model.PersonNotFound 5 | import com.teammgmt.domain.model.TeamMemberNotFound 6 | import com.teammgmt.domain.model.TeamNameAlreadyTaken 7 | import com.teammgmt.domain.model.TeamNotFound 8 | import com.teammgmt.domain.model.TeamValidationError.TooLongName 9 | import org.assertj.core.api.Assertions.assertThat 10 | import org.junit.jupiter.api.DynamicTest 11 | import org.junit.jupiter.api.DynamicTest.dynamicTest 12 | import org.junit.jupiter.api.TestFactory 13 | import org.springframework.http.HttpStatus.* 14 | import org.springframework.http.ResponseEntity 15 | 16 | class HttpErrorResponsesShould { 17 | 18 | private val conflict = ResponseEntity(CONFLICT) 19 | 20 | private val unprocessableEntity = ResponseEntity(UNPROCESSABLE_ENTITY) 21 | 22 | private val notFound = ResponseEntity(NOT_FOUND) 23 | 24 | @TestFactory 25 | fun `build http answers for domain errors`(): List = 26 | listOf( 27 | AlreadyPartOfTheTeam to conflict, 28 | TooLongName to unprocessableEntity, 29 | TeamNameAlreadyTaken to unprocessableEntity, 30 | TeamNotFound to notFound, 31 | TeamMemberNotFound to notFound, 32 | PersonNotFound to notFound 33 | ).map { (error, expectedHttpResponse) -> 34 | dynamicTest("$error is mapped to ${expectedHttpResponse.statusCode}") { 35 | assertThat(error.asHttpResponse()).isEqualTo(expectedHttpResponse) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/fixtures/PeopleServiceStubs.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.fixtures 2 | 3 | import com.github.tomakehurst.wiremock.WireMockServer 4 | import com.github.tomakehurst.wiremock.client.WireMock.get 5 | import com.github.tomakehurst.wiremock.client.WireMock.status 6 | import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo 7 | import org.springframework.http.HttpStatus 8 | import java.util.UUID 9 | 10 | fun WireMockServer.stubHttpEnpointForFindPersonNonSucceeded( 11 | personId: UUID = DEFAULT_PERSON_ID, 12 | responseCode: Int = 400, 13 | responseErrorBody: String = """{"status":400,"detail":"Some problem"}""" 14 | ) = 15 | this.stubFor( 16 | get(urlEqualTo("/people/$personId")) 17 | .willReturn(status(responseCode).withBody(responseErrorBody)) 18 | ) 19 | 20 | fun WireMockServer.stubHttpEnpointForFindPersonNotFound(personId: UUID = DEFAULT_PERSON_ID) = 21 | this.stubHttpEnpointForFindPersonNonSucceeded( 22 | personId, 404, """ {"status":404,"detail":"Account not found: $personId"} """ 23 | ) 24 | 25 | fun WireMockServer.stubHttpEnpointForFindPersonSucceeded(personId: UUID = DEFAULT_PERSON_ID) = 26 | this.stubFor( 27 | get(urlEqualTo("/people/$personId")) 28 | .willReturn( 29 | status(HttpStatus.OK.value()) 30 | .withHeader("Content-Type", "application/json") 31 | .withBody( 32 | """ 33 | { 34 | "id": "$personId", 35 | "firstName": "Jane", 36 | "lastName": "Doe" 37 | } 38 | """ 39 | ) 40 | ) 41 | ) 42 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/outbox/PollingPublisher.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.outbox 2 | 3 | import io.micrometer.core.instrument.MeterRegistry 4 | import org.slf4j.Logger 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.scheduling.TaskScheduler 7 | import org.springframework.transaction.annotation.Transactional 8 | import java.lang.invoke.MethodHandles 9 | import org.springframework.transaction.PlatformTransactionManager 10 | import org.springframework.transaction.support.TransactionTemplate 11 | 12 | private const val FAIL_COUNTER = "outbox.publishing.fail" 13 | 14 | class PollingPublisher( 15 | private val transactionalOutbox: TransactionalOutbox, 16 | private val kafkaOutboxEventProducer: KafkaOutboxEventProducer, 17 | private val batchSize: Int = 50, 18 | pollingIntervalMs: Long = 50L, 19 | scheduler: TaskScheduler, 20 | transactionManager: PlatformTransactionManager, 21 | private val meterRegistry: MeterRegistry, 22 | private val logger: Logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()) 23 | ) { 24 | 25 | init { 26 | scheduler.scheduleWithFixedDelay(this::publish, pollingIntervalMs) 27 | } 28 | 29 | private val transactionTemplate = TransactionTemplate(transactionManager) 30 | 31 | open fun publish() = 32 | try { 33 | transactionTemplate.execute { 34 | transactionalOutbox 35 | .findReadyForPublishing(batchSize) 36 | .also(kafkaOutboxEventProducer::send) 37 | } 38 | } catch (exception: Exception) { 39 | meterRegistry.counter(FAIL_COUNTER).increment() 40 | logger.error("Message batch publishing failed, will be retried", exception) 41 | throw exception 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/application/service/CreateTeamServiceShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.application.service 2 | 3 | import arrow.core.left 4 | import arrow.core.right 5 | import com.teammgmt.domain.model.DomainEventPublisher 6 | import com.teammgmt.domain.model.TeamCreated 7 | import com.teammgmt.domain.model.TeamNameAlreadyTaken 8 | import com.teammgmt.domain.model.TeamRepository 9 | import com.teammgmt.fixtures.buildTeam 10 | import io.mockk.every 11 | import io.mockk.mockk 12 | import io.mockk.verify 13 | import org.assertj.core.api.Assertions.assertThat 14 | import org.junit.jupiter.api.Test 15 | import java.util.UUID 16 | 17 | class CreateTeamServiceShould { 18 | 19 | private val teamRepository = mockk() 20 | 21 | private val generateId = mockk<() -> UUID>() 22 | 23 | private val domainEventPublisher = mockk(relaxed = true) 24 | 25 | private val createTeam = CreateTeamService(teamRepository, domainEventPublisher, generateId) 26 | 27 | @Test 28 | fun `create a team successfully`() { 29 | val team = buildTeam() 30 | every { generateId() } returns team.teamId.value 31 | every { teamRepository.save(team) } returns team.right() 32 | 33 | val result = createTeam(CreateTeamRequest(team.teamName.value)) 34 | 35 | assertThat(result).isEqualTo(CreateTeamResponse(team.teamId.value).right()) 36 | verify { domainEventPublisher.publish(TeamCreated(team)) } 37 | } 38 | 39 | @Test 40 | fun `fail when saving fails`() { 41 | val team = buildTeam() 42 | every { generateId() } returns team.teamId.value 43 | every { teamRepository.save(team) } returns TeamNameAlreadyTaken.left() 44 | 45 | val result = createTeam(CreateTeamRequest(team.teamName.value)) 46 | 47 | assertThat(result).isEqualTo(TeamNameAlreadyTaken.left()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/outbox/PostgresOutboxEventRepository.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.outbox 2 | 3 | import org.springframework.dao.EmptyResultDataAccessException 4 | import org.springframework.jdbc.core.JdbcTemplate 5 | import org.springframework.transaction.annotation.Propagation.MANDATORY 6 | import org.springframework.transaction.annotation.Transactional 7 | import java.time.Clock 8 | import java.time.LocalDateTime 9 | import java.util.UUID 10 | 11 | open class PostgresOutboxEventRepository( 12 | private val jdbcTemplate: JdbcTemplate, 13 | private val generateId: () -> UUID = { UUID.randomUUID() }, 14 | private val clock: Clock = Clock.systemUTC() 15 | ) : TransactionalOutbox { 16 | 17 | @Transactional(propagation = MANDATORY) 18 | override fun storeForPublishing(event: OutboxEvent) { 19 | jdbcTemplate.update( 20 | "INSERT INTO outbox (id, aggregate_id, event_payload, stream, created) VALUES (?,?,?,?,?)", 21 | generateId(), 22 | event.aggregateId, 23 | event.payload, 24 | event.stream, 25 | LocalDateTime.now(clock) 26 | ) 27 | } 28 | 29 | @Transactional(propagation = MANDATORY) 30 | override fun findReadyForPublishing(batchSize: Int): List = try { 31 | jdbcTemplate.query( 32 | """ 33 | DELETE FROM outbox 34 | WHERE aggregate_id IN ( SELECT aggregate_id FROM outbox ORDER BY created ASC LIMIT $batchSize FOR UPDATE ) 35 | RETURNING * 36 | """ 37 | ) { rs, _ -> 38 | OutboxEvent( 39 | aggregateId = UUID.fromString(rs.getString("aggregate_id")), 40 | payload = rs.getBytes("event_payload"), 41 | stream = rs.getString("stream") 42 | ) 43 | } 44 | } catch (exception: EmptyResultDataAccessException) { 45 | emptyList() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/configuration/OutboxConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.configuration 2 | 3 | import com.teammgmt.infrastructure.outbox.KafkaOutboxEventProducer 4 | import com.teammgmt.infrastructure.outbox.PollingPublisher 5 | import com.teammgmt.infrastructure.outbox.PostgresOutboxEventRepository 6 | import com.teammgmt.infrastructure.outbox.TransactionalOutbox 7 | import io.micrometer.core.instrument.MeterRegistry 8 | import org.springframework.context.annotation.Bean 9 | import org.springframework.context.annotation.Configuration 10 | import org.springframework.jdbc.core.JdbcTemplate 11 | import org.springframework.kafka.core.KafkaTemplate 12 | import org.springframework.scheduling.TaskScheduler 13 | import org.springframework.scheduling.annotation.EnableScheduling 14 | import org.springframework.transaction.PlatformTransactionManager 15 | 16 | @EnableScheduling 17 | @Configuration 18 | class OutboxConfiguration { 19 | 20 | @Bean 21 | fun transactionalOutbox(jdbcTemplate: JdbcTemplate): TransactionalOutbox = 22 | PostgresOutboxEventRepository(jdbcTemplate) 23 | 24 | @Bean 25 | fun kafkaOutboxEventProducer(kafkaTemplate: KafkaTemplate) = KafkaOutboxEventProducer( 26 | kafkaTemplate 27 | ) 28 | 29 | @Bean 30 | fun pollingPublisher( 31 | transactionalOutbox: TransactionalOutbox, 32 | kafkaOutboxEventProducer: KafkaOutboxEventProducer, 33 | platformTransactionManager: PlatformTransactionManager, 34 | taskScheduler: TaskScheduler, 35 | meterRegistry: MeterRegistry 36 | ) = PollingPublisher( 37 | transactionalOutbox = transactionalOutbox, 38 | kafkaOutboxEventProducer = kafkaOutboxEventProducer, 39 | batchSize = 10, 40 | transactionManager = platformTransactionManager, 41 | pollingIntervalMs = 1000, 42 | scheduler = taskScheduler, 43 | meterRegistry = meterRegistry 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/adapters/outbound/event/IntegrationTeamEvents.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.event 2 | 3 | import com.fasterxml.jackson.annotation.JsonSubTypes 4 | import com.fasterxml.jackson.annotation.JsonTypeInfo 5 | import com.teammgmt.infrastructure.adapters.outbound.event.IntegrationEvent.* 6 | import java.time.LocalDateTime 7 | import java.util.UUID 8 | 9 | /* 10 | Integration event: A committed event that ocurred in the past within an bounded context which may be interesting to other 11 | domains, applications or third party services. 12 | */ 13 | 14 | @JsonTypeInfo( 15 | use = JsonTypeInfo.Id.NAME, 16 | include = JsonTypeInfo.As.PROPERTY, 17 | property = "type" 18 | ) 19 | @JsonSubTypes( 20 | JsonSubTypes.Type(value = TeamCreatedEvent::class, name = "TeamCreatedEvent"), 21 | JsonSubTypes.Type(value = TeamMemberJoined::class, name = "TeamMemberJoined"), 22 | JsonSubTypes.Type(value = TeamMemberLeft::class, name = "TeamMemberLeft") 23 | ) 24 | sealed class IntegrationEvent(val eventType: String) { 25 | 26 | abstract val occurredOn: LocalDateTime 27 | abstract val eventId: UUID 28 | 29 | data class TeamCreatedEvent( 30 | val team: TeamDto, 31 | override val occurredOn: LocalDateTime, 32 | override val eventId: UUID 33 | ) : IntegrationEvent("TeamCreatedEvent") 34 | 35 | data class TeamMemberJoined( 36 | val team: TeamDto, 37 | val teamMember: UUID, 38 | override val occurredOn: LocalDateTime, 39 | override val eventId: UUID 40 | ) : IntegrationEvent("TeamMemberJoinedEvent") 41 | 42 | data class TeamMemberLeft( 43 | val team: TeamDto, 44 | val teamMember: UUID, 45 | override val occurredOn: LocalDateTime, 46 | override val eventId: UUID 47 | ) : IntegrationEvent("TeamMemberLeftEvent") 48 | } 49 | data class TeamDto(val id: UUID, val name: String, val members: List) 50 | 51 | data class TeamMemberDto(val id: UUID) 52 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/adapters/outbound/client/PeopleServiceHttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.client 2 | 3 | import arrow.core.Either 4 | import arrow.core.left 5 | import arrow.core.right 6 | import com.teammgmt.domain.model.FullName 7 | import com.teammgmt.domain.model.PeopleFinder 8 | import com.teammgmt.domain.model.Person 9 | import com.teammgmt.domain.model.PersonId 10 | import com.teammgmt.domain.model.PersonNotFound 11 | import retrofit2.Call 12 | import retrofit2.Response 13 | import retrofit2.http.GET 14 | import retrofit2.http.Path 15 | import java.util.UUID 16 | 17 | @Deprecated(message = "Now using replication by events instead") 18 | class PeopleServiceHttpClient(private val peopleServiceApi: PeopleServiceApi) : PeopleFinder { 19 | 20 | override fun find(personId: PersonId): Either = 21 | peopleServiceApi.find(personId.value) 22 | .execute() 23 | .let(::extractBody) 24 | .map(::mapToDomain) 25 | 26 | private fun extractBody(response: Response): Either = 27 | when { 28 | response.isSuccessful -> response.body()!!.right() 29 | response.code() == 404 -> PersonNotFound.left() 30 | else -> throw HttpCallNonSucceededException( 31 | httpClient = this@PeopleServiceHttpClient::class.simpleName!!, 32 | errorBody = response.errorBody()?.charStream()?.readText()?.trimIndent(), 33 | httpStatus = response.code() 34 | ) 35 | } 36 | 37 | private fun mapToDomain(response: PersonApiResponse) = Person( 38 | personId = PersonId(response.id), 39 | fullName = FullName.create(response.firstName, response.lastName) 40 | ) 41 | } 42 | 43 | interface PeopleServiceApi { 44 | 45 | @GET("/people/{id}") 46 | fun find(@Path("id") accountId: UUID): Call 47 | } 48 | 49 | data class PersonApiResponse(val id: UUID, val firstName: String, val lastName: String) 50 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/outbox/KafkaOutboxEventProducerShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.outbox 2 | 3 | import com.teammgmt.Kafka 4 | import com.teammgmt.fixtures.buildKafkaConsumer 5 | import com.teammgmt.fixtures.buildKafkaProducer 6 | import com.teammgmt.fixtures.buildOutboxEvent 7 | import com.teammgmt.fixtures.consumeAndAssert 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.junit.jupiter.api.Tag 10 | import org.junit.jupiter.api.Test 11 | import org.springframework.kafka.core.KafkaTemplate 12 | 13 | @Tag("integration") 14 | class KafkaOutboxEventProducerShould { 15 | 16 | companion object { 17 | 18 | val kafka = Kafka() 19 | } 20 | 21 | private val firstKafkaConsumer = buildKafkaConsumer(kafka.container.bootstrapServers, "consumer-1") 22 | .also { it.subscribe(listOf("topic")) } 23 | 24 | private val secondKafkaConsumer = buildKafkaConsumer(kafka.container.bootstrapServers, "consumer-2") 25 | .also { it.subscribe(listOf("topic-2")) } 26 | 27 | private val kafkaTemplate = KafkaTemplate { 28 | buildKafkaProducer(kafka.container.bootstrapServers) 29 | } 30 | 31 | private val kafkaOutboxEventProducer = KafkaOutboxEventProducer(kafkaTemplate) 32 | 33 | @Test 34 | fun `send a batch of outbox messages successfully to different streams`() { 35 | val oneOutboxEvent = buildOutboxEvent(stream = "topic") 36 | val anotherOutboxEvent = buildOutboxEvent(stream = "topic-2") 37 | 38 | kafkaOutboxEventProducer.send(listOf(oneOutboxEvent, anotherOutboxEvent)) 39 | 40 | firstKafkaConsumer.consumeAndAssert(stream = "topic") { record -> 41 | assertThat(record.key()).isEqualTo(oneOutboxEvent.aggregateId.toString()) 42 | assertThat(record.value()).isEqualTo(oneOutboxEvent.payload) 43 | } 44 | secondKafkaConsumer.consumeAndAssert(stream = "topic-2") { record -> 45 | assertThat(record.key()).isEqualTo(anotherOutboxEvent.aggregateId.toString()) 46 | assertThat(record.value()).isEqualTo(anotherOutboxEvent.payload) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/domain/model/Team.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.domain.model 2 | 3 | import arrow.core.Either 4 | import arrow.core.left 5 | import arrow.core.right 6 | import com.teammgmt.domain.model.TeamValidationError.TooLongName 7 | import java.util.UUID 8 | 9 | data class TeamId(val value: UUID) 10 | 11 | data class TeamName private constructor(val value: String) { 12 | companion object { 13 | 14 | fun create(value: String): Either = 15 | if (value.length > 50) TooLongName.left() else TeamName(value).right() 16 | 17 | fun reconstitute(value: String) = TeamName(value) 18 | } 19 | } 20 | 21 | inline class FullName private constructor(val value: String) { 22 | companion object { 23 | 24 | fun create(firstName: String, lastName: String): FullName = FullName("$firstName $lastName") 25 | 26 | fun reconstitute(value: String) = FullName(value) 27 | } 28 | } 29 | 30 | data class TeamMember(val personId: PersonId) // TODO: Add role 31 | 32 | data class Team(val teamId: TeamId, val teamName: TeamName, val members: Set) { 33 | 34 | companion object { 35 | 36 | fun create(teamId: UUID, teamName: String): Either = 37 | TeamName.create(teamName) 38 | .map { Team(TeamId(teamId), it, emptySet()) } 39 | } 40 | 41 | fun join(person: Person): Either = 42 | this.members 43 | .find { teamMember -> teamMember.personId == person.personId } 44 | ?.let { AlreadyPartOfTheTeam.left() } 45 | ?: this.copy(members = this.members + TeamMember(person.personId)).right() 46 | 47 | fun leave(personId: PersonId): Either = 48 | this.members 49 | .find { teamMember -> teamMember.personId == personId } 50 | ?.let { this.removePerson(personId).right() } 51 | ?: TeamMemberNotFound.left() 52 | 53 | private fun removePerson(personId: PersonId): Team = 54 | this.copy(members = this.members.filter { teamMember -> teamMember.personId != personId }.toSet()) 55 | } 56 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/adapters/outbound/event/MetricsSubscriberShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.event 2 | 3 | import com.teammgmt.domain.model.TeamCreated 4 | import com.teammgmt.domain.model.TeamMember 5 | import com.teammgmt.domain.model.TeamMemberJoined 6 | import com.teammgmt.domain.model.TeamMemberLeft 7 | import com.teammgmt.fixtures.buildPerson 8 | import com.teammgmt.fixtures.buildTeam 9 | import io.micrometer.core.instrument.Tag 10 | import io.micrometer.core.instrument.simple.SimpleMeterRegistry 11 | import org.assertj.core.api.Assertions.assertThat 12 | import org.junit.jupiter.api.Test 13 | 14 | class MetricsSubscriberShould { 15 | 16 | val metrics = SimpleMeterRegistry() 17 | 18 | val metricsSubscriber = MetricsSubscriber(metrics) 19 | 20 | @Test 21 | fun `publish a metric when a team is created`() { 22 | val teamCreated = TeamCreated(buildTeam()) 23 | 24 | metricsSubscriber.handle(teamCreated) 25 | 26 | assertThat(metrics.counter("domain.event", listOf(Tag.of("type", "TeamCreated"))).count()) 27 | .isEqualTo(1.0) 28 | } 29 | 30 | @Test 31 | fun `publish a metric when a person joins a team`() { 32 | val person = buildPerson() 33 | val team = buildTeam(members = setOf(TeamMember(person.personId))) 34 | val teamMemberJoined = TeamMemberJoined(team = team, personId = person.personId) 35 | 36 | metricsSubscriber.handle(teamMemberJoined) 37 | 38 | assertThat(metrics.counter("domain.event", listOf(Tag.of("type", "TeamMemberJoined"))).count()) 39 | .isEqualTo(1.0) 40 | } 41 | 42 | @Test 43 | fun `publish a metric when a person leave a team`() { 44 | val person = buildPerson() 45 | val team = buildTeam(members = setOf(TeamMember(person.personId))) 46 | val teamMemberLeft = TeamMemberLeft(team = team, personId = person.personId) 47 | 48 | metricsSubscriber.handle(teamMemberLeft) 49 | 50 | assertThat(metrics.counter("domain.event", listOf(Tag.of("type", "TeamMemberLeft"))).count()) 51 | .isEqualTo(1.0) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/adapters/outbound/event/OutboxSubscriber.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.event 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import com.teammgmt.domain.model.DomainEvent 5 | import com.teammgmt.domain.model.DomainEventSubscriber 6 | import com.teammgmt.domain.model.Team 7 | import com.teammgmt.domain.model.TeamCreated 8 | import com.teammgmt.domain.model.TeamMember 9 | import com.teammgmt.domain.model.TeamMemberJoined 10 | import com.teammgmt.domain.model.TeamMemberLeft 11 | import com.teammgmt.infrastructure.adapters.outbound.event.IntegrationEvent.* 12 | import com.teammgmt.infrastructure.outbox.OutboxEvent 13 | import com.teammgmt.infrastructure.outbox.TransactionalOutbox 14 | import java.time.Clock 15 | import java.time.LocalDateTime.now 16 | import java.util.UUID 17 | 18 | class OutboxSubscriber( 19 | private val transactionalOutbox: TransactionalOutbox, 20 | private val teamEventStreamName: String, 21 | private val mapper: ObjectMapper, 22 | private val clock: Clock = Clock.systemDefaultZone(), 23 | private val generateId: () -> UUID = { UUID.randomUUID() } 24 | ) : DomainEventSubscriber { 25 | 26 | override fun handle(event: DomainEvent) { 27 | when (event) { 28 | is TeamCreated -> TeamCreatedEvent(event.team.asDto(), now(clock), generateId()) 29 | is TeamMemberJoined -> TeamMemberJoined(event.team.asDto(), event.personId.value, now(clock), generateId()) 30 | is TeamMemberLeft -> TeamMemberLeft(event.team.asDto(), event.personId.value, now(clock), generateId()) 31 | } 32 | .let { 33 | transactionalOutbox.storeForPublishing( 34 | OutboxEvent( 35 | stream = teamEventStreamName, 36 | payload = mapper.writeValueAsBytes(it), 37 | aggregateId = event.team.teamId.value 38 | ) 39 | ) 40 | } 41 | } 42 | 43 | private fun Team.asDto() = TeamDto(teamId.value, teamName.value, members.map { it.asDto() }) 44 | 45 | private fun TeamMember.asDto() = TeamMemberDto(personId.value) 46 | } 47 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/adapters/outbound/event/LoggingSubscriberShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.event 2 | 3 | import com.teammgmt.domain.model.TeamCreated 4 | import com.teammgmt.domain.model.TeamMember 5 | import com.teammgmt.domain.model.TeamMemberJoined 6 | import com.teammgmt.domain.model.TeamMemberLeft 7 | import com.teammgmt.fixtures.buildPerson 8 | import com.teammgmt.fixtures.buildTeam 9 | import io.mockk.spyk 10 | import io.mockk.verify 11 | import org.junit.jupiter.api.Test 12 | import org.slf4j.helpers.NOPLogger 13 | 14 | class LoggingSubscriberShould { 15 | 16 | private val logger = spyk(NOPLogger.NOP_LOGGER) 17 | 18 | private val loggingDomainEventSubscriber = LoggingSubscriber(logger) 19 | 20 | @Test 21 | fun `log a team created event`() { 22 | val teamCreated = TeamCreated(buildTeam()) 23 | 24 | loggingDomainEventSubscriber.handle(teamCreated) 25 | 26 | verify { 27 | logger.info( 28 | "domain-event: 'TeamCreated', team-id: '${teamCreated.team.teamId.value}'" 29 | ) 30 | } 31 | } 32 | 33 | @Test 34 | fun `log a team member joined event`() { 35 | val person = buildPerson() 36 | val team = buildTeam(members = setOf(TeamMember(person.personId))) 37 | val teamMemberJoined = TeamMemberJoined(team = team, personId = person.personId) 38 | 39 | loggingDomainEventSubscriber.handle(teamMemberJoined) 40 | 41 | verify { 42 | logger.info( 43 | "domain-event: 'TeamMemberJoined', team-id: '${teamMemberJoined.team.teamId.value}', person-id: ${person.personId}" 44 | ) 45 | } 46 | } 47 | 48 | @Test 49 | fun `log a team member left event`() { 50 | val person = buildPerson() 51 | val team = buildTeam(members = setOf(TeamMember(person.personId))) 52 | val teamMemberLeft = TeamMemberLeft(team = team, personId = person.personId) 53 | 54 | loggingDomainEventSubscriber.handle(teamMemberLeft) 55 | 56 | verify { 57 | logger.info( 58 | "domain-event: 'TeamMemberLeft', team-id: '${teamMemberLeft.team.teamId.value}', person-id: ${person.personId}" 59 | ) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/fixtures/Builders.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.fixtures 2 | 3 | import com.github.javafaker.Faker 4 | import com.teammgmt.domain.model.FullName 5 | import com.teammgmt.domain.model.Person 6 | import com.teammgmt.domain.model.PersonId 7 | import com.teammgmt.domain.model.Team 8 | import com.teammgmt.domain.model.TeamId 9 | import com.teammgmt.domain.model.TeamMember 10 | import com.teammgmt.domain.model.TeamName 11 | import com.teammgmt.infrastructure.adapters.outbound.db.PersonReplicationInfo 12 | import com.teammgmt.infrastructure.adapters.outbound.db.PersonStatus 13 | import com.teammgmt.infrastructure.outbox.OutboxEvent 14 | import java.time.LocalDateTime 15 | import java.util.UUID 16 | 17 | private val faker = Faker() 18 | 19 | val DEFAULT_TEAM_ID = UUID.randomUUID() 20 | val DEFAULT_TEAM_NAME = faker.team().name() 21 | val DEFAULT_TEAM_MEMBERS = emptySet() 22 | val DEFAULT_PERSON_ID = UUID.randomUUID() 23 | val DEFAULT_PERSON_FULL_NAME = faker.name().fullName() 24 | val DEFAULT_PERSON_FIRST_NAME = faker.name().firstName() 25 | val DEFAULT_PERSON_LAST_NAME = faker.name().lastName() 26 | 27 | fun buildTeam( 28 | teamId: UUID = DEFAULT_TEAM_ID, 29 | teamName: String = DEFAULT_TEAM_NAME, 30 | members: Set = DEFAULT_TEAM_MEMBERS 31 | ) = Team( 32 | teamId = TeamId(teamId), 33 | teamName = TeamName.reconstitute(teamName), 34 | members = members 35 | ) 36 | 37 | fun buildPerson( 38 | personId: UUID = DEFAULT_PERSON_ID, 39 | fullName: String = DEFAULT_PERSON_FULL_NAME 40 | ) = Person( 41 | personId = PersonId(personId), 42 | fullName = FullName.reconstitute(fullName) 43 | ) 44 | 45 | fun buildPersonReplicationInfo( 46 | personId: UUID = DEFAULT_PERSON_ID, 47 | firstName: String = DEFAULT_PERSON_FIRST_NAME, 48 | lastName: String = DEFAULT_PERSON_LAST_NAME, 49 | joinedtAt: LocalDateTime = LocalDateTime.now(), 50 | status: PersonStatus = PersonStatus.ACTIVE 51 | ) = PersonReplicationInfo( 52 | personId = personId, 53 | firstName = firstName, 54 | lastname = lastName, 55 | joinedAt = joinedtAt, 56 | status = status 57 | ) 58 | 59 | fun buildOutboxEvent( 60 | key: UUID = UUID.randomUUID(), 61 | eventPayload: ByteArray = faker.backToTheFuture().character().toByteArray(), 62 | stream: String = faker.superhero().name() 63 | ) = OutboxEvent( 64 | aggregateId = key, 65 | payload = eventPayload, 66 | stream = stream 67 | ) 68 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/adapters/inbound/http/TeamsHttpController.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.inbound.http 2 | 3 | import com.teammgmt.application.service.AddTeamMemberRequest 4 | import com.teammgmt.application.service.AddTeamMemberService 5 | import com.teammgmt.application.service.CreateTeamRequest 6 | import com.teammgmt.application.service.CreateTeamService 7 | import com.teammgmt.application.service.RemoveTeamMemberRequest 8 | import com.teammgmt.application.service.RemoveTeamMemberService 9 | import org.springframework.http.HttpStatus.CREATED 10 | import org.springframework.http.HttpStatus.NO_CONTENT 11 | import org.springframework.http.ResponseEntity 12 | import org.springframework.web.bind.annotation.DeleteMapping 13 | import org.springframework.web.bind.annotation.PathVariable 14 | import org.springframework.web.bind.annotation.PostMapping 15 | import org.springframework.web.bind.annotation.PutMapping 16 | import org.springframework.web.bind.annotation.RequestBody 17 | import org.springframework.web.bind.annotation.RestController 18 | import java.util.UUID 19 | 20 | private val createdWith = { body: CreateTeamHttpResponse -> ResponseEntity(body, CREATED) } 21 | 22 | private val noContent = ResponseEntity(NO_CONTENT) 23 | 24 | @RestController 25 | class TeamsHttpController( 26 | private val createTeam: CreateTeamService, 27 | private val addTeamMember: AddTeamMemberService, 28 | private val removeTeamMember: RemoveTeamMemberService 29 | ) { 30 | 31 | @PostMapping("/teams") 32 | fun createInternalPayment(@RequestBody httpRequest: CreateTeamHttpRequest) = 33 | createTeam(CreateTeamRequest(teamName = httpRequest.name)) 34 | .fold(ifLeft = { it.asHttpResponse() }, ifRight = { createdWith(CreateTeamHttpResponse(it.newTeamId)) }) 35 | 36 | @PutMapping("/teams/{teamId}/person/{personId}") 37 | fun joinTeam(@PathVariable("teamId") teamId: UUID, @PathVariable("personId") personId: UUID) = 38 | addTeamMember(AddTeamMemberRequest(teamId, personId)) 39 | .fold(ifLeft = { it.asHttpResponse() }, ifRight = { noContent }) 40 | 41 | @DeleteMapping("/teams/{teamId}/person/{personId}") 42 | fun leaveTeam(@PathVariable("teamId") teamId: UUID, @PathVariable("personId") personId: UUID) = 43 | removeTeamMember(RemoveTeamMemberRequest(teamId, personId)) 44 | .fold(ifLeft = { it.asHttpResponse() }, ifRight = { noContent }) 45 | } 46 | 47 | data class CreateTeamHttpRequest(val name: String) 48 | 49 | data class CreateTeamHttpResponse(val id: UUID) 50 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/application/service/RemoveTeamMemberServiceShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.application.service 2 | 3 | import arrow.core.left 4 | import arrow.core.right 5 | import com.teammgmt.domain.model.DomainEventPublisher 6 | import com.teammgmt.domain.model.TeamMember 7 | import com.teammgmt.domain.model.TeamMemberLeft 8 | import com.teammgmt.domain.model.TeamMemberNotFound 9 | import com.teammgmt.domain.model.TeamNotFound 10 | import com.teammgmt.domain.model.TeamRepository 11 | import com.teammgmt.fixtures.buildPerson 12 | import com.teammgmt.fixtures.buildTeam 13 | import io.mockk.every 14 | import io.mockk.mockk 15 | import io.mockk.verify 16 | import org.assertj.core.api.Assertions.assertThat 17 | import org.junit.jupiter.api.Test 18 | 19 | class RemoveTeamMemberServiceShould { 20 | 21 | private val teamRepository = mockk() 22 | 23 | private val domainEventPublisher = mockk(relaxed = true) 24 | 25 | private val removeTeamMember = RemoveTeamMemberService(teamRepository, domainEventPublisher) 26 | 27 | @Test 28 | fun `remove a member from a team`() { 29 | val person = buildPerson() 30 | val team = buildTeam() 31 | val teamWithPerson = team.copy(members = setOf(TeamMember(person.personId))) 32 | every { teamRepository.find(team.teamId) } returns teamWithPerson.right() 33 | every { teamRepository.save(team) } returns team.right() 34 | 35 | val result = removeTeamMember(RemoveTeamMemberRequest(team.teamId.value, person.personId.value)) 36 | 37 | assertThat(result).isEqualTo(Unit.right()) 38 | verify { domainEventPublisher.publish(TeamMemberLeft(team, person.personId)) } 39 | } 40 | 41 | @Test 42 | fun `fail when team does not exists`() { 43 | val team = buildTeam() 44 | val person = buildPerson() 45 | every { teamRepository.find(team.teamId) } returns TeamNotFound.left() 46 | 47 | val result = removeTeamMember(RemoveTeamMemberRequest(team.teamId.value, person.personId.value)) 48 | 49 | assertThat(result).isEqualTo(TeamNotFound.left()) 50 | } 51 | 52 | @Test 53 | fun `fail when person is not part of this team`() { 54 | val person = buildPerson() 55 | val team = buildTeam() 56 | every { teamRepository.find(team.teamId) } returns team.right() 57 | 58 | val result = removeTeamMember(RemoveTeamMemberRequest(team.teamId.value, person.personId.value)) 59 | 60 | assertThat(result).isEqualTo(TeamMemberNotFound.left()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/adapters/outbound/db/PostgresPeopleReplicationRepository.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.db 2 | 3 | import arrow.core.Either 4 | import arrow.core.left 5 | import arrow.core.right 6 | import com.teammgmt.domain.model.FullName 7 | import com.teammgmt.domain.model.PeopleFinder 8 | import com.teammgmt.domain.model.Person 9 | import com.teammgmt.domain.model.PersonId 10 | import com.teammgmt.domain.model.PersonNotFound 11 | import com.teammgmt.infrastructure.adapters.outbound.db.PersonStatus.DELETED 12 | import org.springframework.dao.EmptyResultDataAccessException 13 | import org.springframework.jdbc.core.JdbcTemplate 14 | import org.springframework.jdbc.core.queryForObject 15 | import java.time.LocalDateTime 16 | import java.util.UUID 17 | 18 | class PostgresPeopleReplicationRepository(private val jdbcTemplate: JdbcTemplate) : PeopleFinder { 19 | 20 | override fun find(personId: PersonId): Either = try { 21 | jdbcTemplate.queryForObject( 22 | """ 23 | SELECT id, status, first_name, last_name, joined_at FROM people_replication WHERE id = ? AND status = 'ACTIVE' 24 | """, 25 | personId.value 26 | ) { rs, _ -> 27 | Person( 28 | personId = PersonId(UUID.fromString(rs.getString("id"))), 29 | fullName = FullName.create(rs.getString("first_name"), rs.getString("last_name")) 30 | ) 31 | }.right() 32 | } catch (exception: EmptyResultDataAccessException) { 33 | PersonNotFound.left() 34 | } 35 | 36 | fun save(person: PersonReplicationInfo) { 37 | jdbcTemplate.update( 38 | """ 39 | INSERT INTO people_replication (id, status, first_name, last_name, joined_at) VALUES (?,?,?,?,?) 40 | ON CONFLICT (id) DO UPDATE SET status = ?, first_name = ?, last_name = ?, joined_at = ? 41 | """, 42 | person.personId, 43 | person.status.name, 44 | person.firstName, 45 | person.lastname, 46 | person.joinedAt, 47 | person.status.name, 48 | person.firstName, 49 | person.lastname, 50 | person.joinedAt 51 | ) 52 | } 53 | 54 | fun delete(personId: UUID) { 55 | jdbcTemplate.update("UPDATE people_replication SET status = ? where id = ?", DELETED.name, personId) 56 | } 57 | } 58 | 59 | data class PersonReplicationInfo( 60 | val personId: UUID, 61 | val firstName: String, 62 | val lastname: String, 63 | val joinedAt: LocalDateTime, 64 | val status: PersonStatus 65 | ) 66 | 67 | enum class PersonStatus { 68 | ACTIVE, DELETED 69 | } 70 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/adapters/outbound/db/PostgresTeamRepository.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.db 2 | 3 | import arrow.core.Either 4 | import arrow.core.left 5 | import arrow.core.right 6 | import com.teammgmt.domain.model.PersonId 7 | import com.teammgmt.domain.model.Team 8 | import com.teammgmt.domain.model.TeamId 9 | import com.teammgmt.domain.model.TeamMember 10 | import com.teammgmt.domain.model.TeamName 11 | import com.teammgmt.domain.model.TeamNameAlreadyTaken 12 | import com.teammgmt.domain.model.TeamNotFound 13 | import com.teammgmt.domain.model.TeamRepository 14 | import org.springframework.dao.DuplicateKeyException 15 | import org.springframework.dao.EmptyResultDataAccessException 16 | import org.springframework.jdbc.core.JdbcTemplate 17 | import org.springframework.jdbc.core.queryForObject 18 | import java.sql.ResultSet 19 | import java.util.UUID 20 | import kotlin.streams.toList 21 | 22 | class PostgresTeamRepository(private val jdbcTemplate: JdbcTemplate) : TeamRepository { 23 | 24 | override fun find(teamId: TeamId): Either = try { 25 | jdbcTemplate.queryForObject(""" SELECT id, name, members FROM team WHERE id = ? FOR UPDATE """, teamId.value) { rs, _ -> rs.asTeam() }.right() 26 | } catch (exception: EmptyResultDataAccessException) { 27 | TeamNotFound.left() 28 | } 29 | 30 | override fun save(team: Team): Either = try { 31 | val members: Array = team.members.map { it.personId.value.toString() }.toTypedArray() 32 | jdbcTemplate.update( 33 | """ 34 | INSERT INTO team (id, name, members) VALUES (?,?,?) 35 | ON CONFLICT (id) DO UPDATE SET members = ?, name = ? 36 | """, 37 | team.teamId.value, 38 | team.teamName.value, 39 | members, 40 | members, 41 | team.teamName.value, 42 | ).let { team.right() } 43 | } catch (exception: DuplicateKeyException) { 44 | TeamNameAlreadyTaken.left() 45 | } 46 | 47 | fun findAllTeamsFor(personId: PersonId): List = 48 | jdbcTemplate.queryForStream( 49 | """ 50 | SELECT id, members, name 51 | FROM team 52 | WHERE '${personId.value}' = ANY (members) 53 | """ 54 | ) { rs, _ -> rs.asTeam() }.toList() 55 | 56 | private fun ResultSet.asTeam() = Team( 57 | teamId = TeamId(UUID.fromString(getString("id"))), 58 | teamName = TeamName.reconstitute(getString("name")), 59 | members = (getArray("members").array as Array).map { 60 | TeamMember(PersonId(UUID.fromString(it))) 61 | }.toSet() 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/domain/model/TeamShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.domain.model 2 | 3 | import arrow.core.left 4 | import arrow.core.right 5 | import com.teammgmt.domain.model.TeamValidationError.TooLongName 6 | import com.teammgmt.fixtures.buildPerson 7 | import com.teammgmt.fixtures.buildTeam 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.junit.jupiter.api.Nested 10 | import org.junit.jupiter.api.Test 11 | import java.util.UUID 12 | 13 | class TeamShould { 14 | 15 | @Nested 16 | inner class CreateTeam { 17 | 18 | @Test 19 | fun `create a team`() { 20 | val teamId = UUID.randomUUID() 21 | val teamName = "Teletubbies" 22 | 23 | val result = Team.create(teamId, teamName) 24 | 25 | assertThat(result).isEqualTo(buildTeam(teamId, teamName).right()) 26 | } 27 | 28 | @Test 29 | fun `fail creating a team`() { 30 | val teamName = """ 31 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut 32 | labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris 33 | nisi ut aliquip ex ea commodo consequat. 34 | """ 35 | 36 | val result = Team.create(UUID.randomUUID(), teamName) 37 | 38 | assertThat(result).isEqualTo(TooLongName.left()) 39 | } 40 | } 41 | 42 | @Nested 43 | inner class JoinTeam { 44 | 45 | @Test 46 | fun `allow a person to join the team`() { 47 | val team = buildTeam() 48 | val person = buildPerson() 49 | 50 | val result = team.join(person) 51 | 52 | assertThat(result).isEqualTo(team.copy(members = setOf(TeamMember(person.personId))).right()) 53 | } 54 | 55 | @Test 56 | fun `fail adding a person that was already part of the team`() { 57 | val person = buildPerson() 58 | val team = buildTeam(members = setOf(TeamMember(person.personId))) 59 | 60 | val result = team.join(person) 61 | 62 | assertThat(result).isEqualTo(AlreadyPartOfTheTeam.left()) 63 | } 64 | } 65 | 66 | @Nested 67 | inner class LeaveTeam { 68 | 69 | @Test 70 | fun `allow a person to leave the team`() { 71 | val person = buildPerson() 72 | val team = buildTeam(members = setOf(TeamMember(person.personId))) 73 | 74 | val result = team.leave(person.personId) 75 | 76 | assertThat(result).isEqualTo(team.copy(members = emptySet()).right()) 77 | } 78 | 79 | @Test 80 | fun `fail removing person that was not part of the team`() { 81 | val person = buildPerson() 82 | val team = buildTeam() 83 | 84 | val result = team.leave(person.personId) 85 | 86 | assertThat(result).isEqualTo(TeamMemberNotFound.left()) 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/fixtures/KafkaExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.fixtures 2 | 3 | import kotlinx.coroutines.delay 4 | import org.apache.kafka.clients.consumer.ConsumerConfig.AUTO_OFFSET_RESET_CONFIG 5 | import org.apache.kafka.clients.consumer.ConsumerRecord 6 | import org.apache.kafka.clients.consumer.KafkaConsumer 7 | import org.apache.kafka.clients.producer.KafkaProducer 8 | import org.apache.kafka.common.serialization.ByteArrayDeserializer 9 | import org.apache.kafka.common.serialization.ByteArraySerializer 10 | import org.apache.kafka.common.serialization.StringDeserializer 11 | import org.apache.kafka.common.serialization.StringSerializer 12 | import java.time.Duration 13 | import java.util.Properties 14 | import java.util.concurrent.TimeUnit.* 15 | 16 | fun buildKafkaProducer(bootstrapServers: String) = 17 | KafkaProducer(producerProperties(bootstrapServers)) 18 | 19 | fun buildKafkaConsumer(bootstrapServers: String, group: String = "test.team.mgmt.consumer") = 20 | KafkaConsumer(consumerProperties(bootstrapServers, group)) 21 | 22 | private fun producerProperties(bootstrapServers: String): Properties = Properties().apply { 23 | this["key.serializer"] = StringSerializer::class.java.name 24 | this["value.serializer"] = ByteArraySerializer::class.java.name 25 | this["bootstrap.servers"] = bootstrapServers 26 | } 27 | 28 | private fun consumerProperties(bootstrapServers: String, group: String): Properties = Properties().apply { 29 | this["key.deserializer"] = StringDeserializer::class.java.name 30 | this["value.deserializer"] = ByteArrayDeserializer::class.java.name 31 | this["bootstrap.servers"] = bootstrapServers 32 | this[AUTO_OFFSET_RESET_CONFIG] = "earliest" 33 | this["group.id"] = group 34 | this["enable.auto.commit"] = "true" 35 | this["auto.commit.interval.ms"] = "100" 36 | } 37 | 38 | fun KafkaConsumer.consumeAndAssert( 39 | stream: String, 40 | timeout: Long = 5000L, 41 | assertions: (ConsumerRecord) -> Unit 42 | ) { 43 | this.subscribe(listOf(stream)) 44 | val events = this.poll(Duration.ofMillis(timeout)).records(stream) 45 | this.commitSync() 46 | if (events.count() != 1) throw Exception("Expected to consume '1' record but '${events.count()}' were present.") 47 | this.unsubscribe() 48 | assertions(events.first()) 49 | } 50 | 51 | suspend fun KafkaConsumer.consumeAndAssertMultiple( 52 | numberOfMessages: Int = 1, 53 | stream: String, 54 | delay: Long = 3000L, 55 | assertions: (List>) -> Unit 56 | ) { 57 | this.subscribe(listOf(stream)) 58 | delay(delay) 59 | val events = this.poll(Duration.ofMillis(delay)).records(stream).toList() 60 | this.commitAsync() 61 | if (events.count() != numberOfMessages) 62 | throw Exception("Expected to consume '$numberOfMessages' record but '${events.count()}' were present.") 63 | 64 | this.unsubscribe() 65 | assertions(events) 66 | } 67 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/acceptance/DeleteTeamMemberFromTeamShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.acceptance 2 | 3 | import com.fasterxml.jackson.module.kotlin.readValue 4 | import com.teammgmt.fixtures.buildKafkaProducer 5 | import com.teammgmt.fixtures.consumeAndAssertMultiple 6 | import com.teammgmt.infrastructure.adapters.inbound.stream.PersonLeftEvent 7 | import com.teammgmt.infrastructure.adapters.outbound.event.IntegrationEvent 8 | import io.restassured.RestAssured.given 9 | import kotlinx.coroutines.runBlocking 10 | import org.apache.kafka.clients.producer.ProducerRecord 11 | import org.assertj.core.api.Assertions.assertThat 12 | import org.junit.jupiter.api.Tag 13 | import org.junit.jupiter.api.Test 14 | import org.junit.jupiter.api.assertDoesNotThrow 15 | import org.springframework.boot.test.context.SpringBootTest 16 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT 17 | import java.time.LocalDateTime.now 18 | 19 | @Tag("acceptance") 20 | @SpringBootTest(webEnvironment = RANDOM_PORT) 21 | class DeleteTeamMemberFromTeamShould : BaseAcceptanceTest() { 22 | 23 | @Test 24 | fun `delete a team member to a team successfully`() { 25 | runBlocking { 26 | val teamId = givenATeamExists() 27 | val personId = givenAPersonExists() 28 | givenAPersonPartOfTheTeam(teamId, personId) 29 | 30 | val response = given() 31 | .port(servicePort) 32 | .`when`() 33 | .delete("/teams/$teamId/person/$personId") 34 | .then() 35 | 36 | assertThat(response.extract().statusCode()).isEqualTo(204) 37 | kafkaConsumer.consumeAndAssertMultiple(stream = "public.team.v1", numberOfMessages = 3) { records -> 38 | assertThat(records[2].key()).isEqualTo(teamId.toString()) 39 | assertDoesNotThrow { mapper.readValue(records[2].value()) } 40 | } 41 | } 42 | } 43 | 44 | @Test 45 | fun `delete a team member to a team successfully when a person leave`() { 46 | runBlocking { 47 | val teamId = givenATeamExists() 48 | val personId = givenAPersonExists() 49 | givenAPersonPartOfTheTeam(teamId, personId) 50 | 51 | val leftEvent = PersonLeftEvent(personId, now()) 52 | buildKafkaProducer(kafka.container.bootstrapServers) 53 | .send( 54 | ProducerRecord( 55 | "public.person.v1", 56 | leftEvent.personId.toString(), 57 | mapper.writeValueAsBytes(leftEvent) 58 | ) 59 | ) 60 | 61 | kafkaConsumer.consumeAndAssertMultiple(stream = "public.team.v1", numberOfMessages = 3) { records -> 62 | assertThat(records[2].key()).isEqualTo(teamId.toString()) 63 | assertDoesNotThrow { mapper.readValue(records[2].value()) } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/adapters/inbound/stream/KafkaPeopleEventsConsumer.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.inbound.stream 2 | 3 | import com.fasterxml.jackson.annotation.JsonSubTypes 4 | import com.fasterxml.jackson.annotation.JsonTypeInfo 5 | import com.fasterxml.jackson.databind.ObjectMapper 6 | import com.teammgmt.application.service.RemoveTeamMemberRequest 7 | import com.teammgmt.application.service.RemoveTeamMemberService 8 | import com.teammgmt.domain.model.PersonId 9 | import com.teammgmt.infrastructure.adapters.outbound.db.PersonReplicationInfo 10 | import com.teammgmt.infrastructure.adapters.outbound.db.PersonStatus.ACTIVE 11 | import com.teammgmt.infrastructure.adapters.outbound.db.PostgresPeopleReplicationRepository 12 | import com.teammgmt.infrastructure.adapters.outbound.db.PostgresTeamRepository 13 | import org.springframework.kafka.annotation.KafkaListener 14 | import org.springframework.messaging.Message 15 | import org.springframework.stereotype.Component 16 | import org.springframework.transaction.annotation.Transactional 17 | import java.time.LocalDateTime 18 | import java.util.UUID 19 | 20 | @Component 21 | class KafkaPeopleEventsConsumer( 22 | private val removeTeamMember: RemoveTeamMemberService, 23 | private val postgresPeopleReplicationRepository: PostgresPeopleReplicationRepository, 24 | private val postgresTeamRepository: PostgresTeamRepository, 25 | private val mapper: ObjectMapper 26 | ) { 27 | 28 | @Transactional 29 | @KafkaListener( 30 | topics = ["\${spring.kafka.consumer.people.topic}"], 31 | groupId = "\${spring.kafka.consumer.people.group-id}" 32 | ) 33 | fun listenTo(message: Message): Unit = 34 | mapper.readValue(message.payload, PeopleEvent::class.java).let { 35 | when (it) { 36 | is PersonJoinedEvent -> reactTo(it) 37 | is PersonLeftEvent -> reactTo(it) 38 | } 39 | } 40 | 41 | fun reactTo(event: PersonJoinedEvent) = 42 | postgresPeopleReplicationRepository.save( 43 | PersonReplicationInfo(event.personId, event.firstName, event.lastname, event.joinedAt, ACTIVE) 44 | ) 45 | 46 | fun reactTo(event: PersonLeftEvent) { 47 | postgresPeopleReplicationRepository.delete(event.personId) 48 | postgresTeamRepository.findAllTeamsFor(PersonId(event.personId)) 49 | .map { removeTeamMember(RemoveTeamMemberRequest(it.teamId.value, event.personId)) } 50 | } 51 | } 52 | 53 | @JsonTypeInfo( 54 | use = JsonTypeInfo.Id.NAME, 55 | include = JsonTypeInfo.As.PROPERTY, 56 | property = "type" 57 | ) 58 | @JsonSubTypes( 59 | JsonSubTypes.Type(value = PersonJoinedEvent::class, name = "PersonJoinedEvent"), 60 | JsonSubTypes.Type(value = PersonLeftEvent::class, name = "PersonLeftEvent") 61 | ) 62 | sealed class PeopleEvent 63 | 64 | data class PersonJoinedEvent( 65 | val personId: UUID, 66 | val firstName: String, 67 | val lastname: String, 68 | val joinedAt: LocalDateTime 69 | ) : PeopleEvent() 70 | 71 | data class PersonLeftEvent(val personId: UUID, val leftAt: LocalDateTime) : PeopleEvent() 72 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/adapters/outbound/client/PeopleServiceHttpClientShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.client 2 | 3 | import arrow.core.left 4 | import arrow.core.right 5 | import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig 6 | import com.github.tomakehurst.wiremock.junit.WireMockRule 7 | import com.teammgmt.domain.model.FullName 8 | import com.teammgmt.domain.model.Person 9 | import com.teammgmt.domain.model.PersonId 10 | import com.teammgmt.domain.model.PersonNotFound 11 | import com.teammgmt.fixtures.stubHttpEnpointForFindPersonNonSucceeded 12 | import com.teammgmt.fixtures.stubHttpEnpointForFindPersonNotFound 13 | import com.teammgmt.fixtures.stubHttpEnpointForFindPersonSucceeded 14 | import com.teammgmt.infrastructure.configuration.InfrastructureConfiguration 15 | import io.github.resilience4j.circuitbreaker.CircuitBreaker 16 | import io.micrometer.core.instrument.simple.SimpleMeterRegistry 17 | import org.assertj.core.api.Assertions.assertThat 18 | import org.assertj.core.api.Assertions.assertThatThrownBy 19 | import org.junit.jupiter.api.Tag 20 | import org.junit.jupiter.api.Test 21 | import java.util.UUID 22 | 23 | @Tag("integration") 24 | class PeopleServiceHttpClientShould { 25 | 26 | private val peopleService = WireMockRule(wireMockConfig().dynamicPort()).also { it.start() } 27 | 28 | private val api = InfrastructureConfiguration().let { 29 | it.peopleServiceApi( 30 | defaultObjectMapper = it.defaultObjectMapper(), 31 | circuitBreaker = CircuitBreaker.ofDefaults(""), 32 | meterRegistry = SimpleMeterRegistry(), 33 | url = peopleService.baseUrl(), 34 | connectTimeout = 2000, 35 | readTimeout = 2000 36 | ) 37 | } 38 | 39 | private val client = PeopleServiceHttpClient(api) 40 | 41 | @Test 42 | fun `find an person`() { 43 | val personId = UUID.randomUUID() 44 | peopleService.stubHttpEnpointForFindPersonSucceeded(personId = personId) 45 | 46 | val result = client.find(PersonId(personId)) 47 | 48 | assertThat(result).isEqualTo( 49 | Person(personId = PersonId(personId), fullName = FullName.reconstitute("Jane Doe")).right() 50 | ) 51 | } 52 | 53 | @Test 54 | fun `fail when person does not exists`() { 55 | val personId = UUID.randomUUID() 56 | peopleService.stubHttpEnpointForFindPersonNotFound(personId) 57 | 58 | val result = client.find(PersonId(personId)) 59 | 60 | assertThat(result).isEqualTo(PersonNotFound.left()) 61 | } 62 | 63 | @Test 64 | fun `crash when there is a non successful http response`() { 65 | val personId = UUID.randomUUID() 66 | peopleService.stubHttpEnpointForFindPersonNonSucceeded(personId) 67 | 68 | assertThatThrownBy { client.find(PersonId(personId)) } 69 | .isExactlyInstanceOf(HttpCallNonSucceededException::class.java) 70 | .hasMessage( 71 | """Http call with 'PeopleServiceHttpClient' failed with status '400' and body '{"status":400,"detail":"Some problem"}' """ 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | undertow: 4 | eager-filter-init: true 5 | management: 6 | endpoints: 7 | enabled-by-default: false 8 | web: 9 | base-path: /management 10 | exposure: 11 | include: 12 | - info 13 | - health 14 | - metrics 15 | endpoint: 16 | info: 17 | enabled: true 18 | health: 19 | enabled: true 20 | show-details: always 21 | metrics: 22 | enabled: true 23 | health: 24 | db: 25 | enabled: false 26 | metrics: 27 | export: 28 | statsd: 29 | host: "172.17.0.1" 30 | enable: 31 | all: true 32 | 33 | info: 34 | app: 35 | java: 36 | source: '11' 37 | target: '11' 38 | 39 | spring: 40 | application: 41 | name: 'team-mgmt-service' 42 | main: 43 | banner-mode: 'off' 44 | mvc: 45 | favicon: 46 | enabled: false 47 | kafka: 48 | error-handling: 49 | exponential-backoff: 50 | max-elapsed-time: 30000 51 | multiplier: 1.5 52 | initial-interval: 100 53 | consumer: 54 | people: 55 | group-id: team-mgmt-service-consumer 56 | topic: public.person.v1 57 | bootstrap-servers: localhost:9092 58 | enable-auto-commit: true 59 | key-deserializer: org.apache.kafka.common.serialization.StringDeserializer 60 | value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer 61 | auto-offset-reset: earliest 62 | producer: 63 | team: 64 | topic: public.team.v1 65 | key-serializer: org.apache.kafka.common.serialization.StringSerializer 66 | value-serializer: org.apache.kafka.common.serialization.ByteArraySerializer 67 | properties: 68 | security.protocol: PLAINTEXT 69 | datasource: 70 | url: jdbc:postgresql://localhost:5432/teammgmt 71 | username: teammgmt 72 | password: teammgmt 73 | flyway: 74 | url: jdbc:postgresql://localhost:5432/teammgmt 75 | schemas: public 76 | user: teammgmt 77 | password: teammgmt 78 | 79 | springdoc: 80 | swagger-ui: 81 | path: /swagger-ui.html 82 | display-request-duration: true 83 | 84 | swagger: 85 | enabled: true 86 | title: team-mgmt-service 87 | description: Internal Payment service 88 | version: 0.0.1 89 | 90 | 91 | resilience4j.circuitbreaker: 92 | configs: 93 | default: 94 | registerHealthIndicator: true 95 | slidingWindowSize: 2 96 | slidingWindowType: TIME_BASED 97 | permittedNumberOfCallsInHalfOpenState: 3 98 | minimumNumberOfCalls: 10 99 | waitDurationInOpenState: 30s 100 | failureRateThreshold: 50 101 | eventConsumerBufferSize: 10 102 | slowCallDurationThreshold: 1s 103 | recordExceptions: 104 | - java.io.IOException 105 | - java.util.concurrent.TimeoutException 106 | instances: 107 | people-service: 108 | baseConfig: default 109 | clients: 110 | people-service: 111 | url: https://people-service.service.consul 112 | connectTimeoutMillis: 3000 113 | readTimeoutMillis: 3000 114 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/application/service/AddTeamMemberServiceShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.application.service 2 | 3 | import arrow.core.left 4 | import arrow.core.right 5 | import com.teammgmt.domain.model.AlreadyPartOfTheTeam 6 | import com.teammgmt.domain.model.DomainEventPublisher 7 | import com.teammgmt.domain.model.PeopleFinder 8 | import com.teammgmt.domain.model.PersonNotFound 9 | import com.teammgmt.domain.model.TeamMember 10 | import com.teammgmt.domain.model.TeamMemberJoined 11 | import com.teammgmt.domain.model.TeamNotFound 12 | import com.teammgmt.domain.model.TeamRepository 13 | import com.teammgmt.fixtures.buildPerson 14 | import com.teammgmt.fixtures.buildTeam 15 | import io.mockk.every 16 | import io.mockk.mockk 17 | import io.mockk.verify 18 | import org.assertj.core.api.Assertions.* 19 | import org.junit.jupiter.api.Test 20 | 21 | class AddTeamMemberServiceShould { 22 | 23 | private val teamRepository = mockk() 24 | 25 | private val peopleFinder = mockk() 26 | 27 | private val domainEventPublisher = mockk(relaxed = true) 28 | 29 | private val addTeamMember = AddTeamMemberService(teamRepository, peopleFinder, domainEventPublisher) 30 | 31 | @Test 32 | fun `add a new member to a team`() { 33 | val team = buildTeam() 34 | val person = buildPerson() 35 | every { teamRepository.find(team.teamId) } returns team.right() 36 | every { peopleFinder.find(person.personId) } returns person.right() 37 | every { teamRepository.save(any()) } returns team.right() 38 | 39 | val result = addTeamMember(AddTeamMemberRequest(team.teamId.value, person.personId.value)) 40 | 41 | assertThat(result).isEqualTo(Unit.right()) 42 | verify { domainEventPublisher.publish(TeamMemberJoined(team, person.personId)) } 43 | } 44 | 45 | @Test 46 | fun `fail when team does not exists`() { 47 | val team = buildTeam() 48 | val person = buildPerson() 49 | every { peopleFinder.find(person.personId) } returns person.right() 50 | every { teamRepository.find(team.teamId) } returns TeamNotFound.left() 51 | 52 | val result = addTeamMember(AddTeamMemberRequest(team.teamId.value, person.personId.value)) 53 | 54 | assertThat(result).isEqualTo(TeamNotFound.left()) 55 | } 56 | 57 | @Test 58 | fun `fail when person does not exists`() { 59 | val team = buildTeam() 60 | val person = buildPerson() 61 | every { teamRepository.find(team.teamId) } returns team.right() 62 | every { peopleFinder.find(person.personId) } returns PersonNotFound.left() 63 | 64 | val result = addTeamMember(AddTeamMemberRequest(team.teamId.value, person.personId.value)) 65 | 66 | assertThat(result).isEqualTo(PersonNotFound.left()) 67 | } 68 | 69 | @Test 70 | fun `fail when person is already part of the team`() { 71 | val person = buildPerson() 72 | val team = buildTeam(members = setOf(TeamMember(person.personId))) 73 | every { teamRepository.find(team.teamId) } returns team.right() 74 | every { peopleFinder.find(person.personId) } returns person.right() 75 | 76 | val result = addTeamMember(AddTeamMemberRequest(team.teamId.value, person.personId.value)) 77 | 78 | assertThat(result).isEqualTo(AlreadyPartOfTheTeam.left()) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/outbox/PostgresOutboxEventRepositoryShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.outbox 2 | 3 | import com.teammgmt.Postgres 4 | import com.teammgmt.fixtures.buildOutboxEvent 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.async 7 | import kotlinx.coroutines.delay 8 | import kotlinx.coroutines.runBlocking 9 | import org.assertj.core.api.Assertions.assertThat 10 | import org.junit.jupiter.api.Test 11 | import org.springframework.jdbc.core.JdbcTemplate 12 | import org.springframework.jdbc.datasource.DataSourceTransactionManager 13 | import org.springframework.transaction.support.TransactionTemplate 14 | 15 | class PostgresOutboxEventRepositoryShould { 16 | 17 | private val postgres = Postgres() 18 | 19 | private val transactionTemplate = TransactionTemplate(DataSourceTransactionManager(postgres.datasource)) 20 | 21 | private val jdbcTemplate = JdbcTemplate(postgres.datasource) 22 | 23 | private val repository = PostgresOutboxEventRepository(jdbcTemplate) 24 | 25 | @Test 26 | fun `store an outbox event for publishing`() { 27 | val outboxEvent = buildOutboxEvent() 28 | 29 | repository.storeForPublishing(outboxEvent) 30 | 31 | val record = jdbcTemplate.queryForMap("SELECT aggregate_id, event_payload, stream FROM outbox LIMIT 1") 32 | assertThat(record).containsAllEntriesOf( 33 | mapOf( 34 | "aggregate_id" to outboxEvent.aggregateId, 35 | "stream" to outboxEvent.stream, 36 | "event_payload" to outboxEvent.payload 37 | ) 38 | ) 39 | } 40 | 41 | @Test 42 | fun `find last events ready for publishing and delete them`() { 43 | val firstEvent = buildOutboxEvent().also(repository::storeForPublishing) 44 | val secondEvent = buildOutboxEvent().also(repository::storeForPublishing) 45 | val thirdEvent = buildOutboxEvent().also(repository::storeForPublishing) 46 | 47 | val outboxMessage = repository.findReadyForPublishing(batchSize = 2) 48 | 49 | assertThat(outboxMessage).isEqualTo(listOf(firstEvent, secondEvent)) 50 | val allIds = jdbcTemplate.query("SELECT aggregate_id FROM outbox") { rs, _ -> rs.getObject("aggregate_id") } 51 | assertThat(allIds).containsOnly(thirdEvent.aggregateId) 52 | } 53 | 54 | @Test 55 | fun `ensure events found for publishing are blocked till they are processed`() { 56 | val oldestEvent = buildOutboxEvent().also(repository::storeForPublishing) 57 | val newestEvent = buildOutboxEvent().also(repository::storeForPublishing) 58 | val findReadyForPublishing = { repository.findReadyForPublishing(1) } 59 | 60 | val result = runBlocking(Dispatchers.IO) { 61 | val reallyLongExecution = async { withinTransaction(executionDelay = 500, code = findReadyForPublishing) } 62 | delay(50) 63 | val execution = async { withinTransaction(executionDelay = 100, code = findReadyForPublishing) } 64 | delay(50) 65 | val immediateExecution = async { withinTransaction(executionDelay = 0, code = findReadyForPublishing) } 66 | Triple(reallyLongExecution.await(), execution.await(), immediateExecution.await()) 67 | } 68 | 69 | assertThat(result.first).isEqualTo(listOf(oldestEvent)) 70 | assertThat(result.second).isEqualTo(listOf(newestEvent)) 71 | assertThat(result.third).isEqualTo(emptyList()) 72 | } 73 | 74 | private fun withinTransaction(executionDelay: Long = 0, code: () -> T?): T? = 75 | transactionTemplate.execute { 76 | val result = code() 77 | Thread.sleep(executionDelay) 78 | result 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # team-mgmt-service 2 | 3 | ## Description 4 | 5 | Team management service is a production ready and fully tested service that can be used as a template for a microservice 6 | development. 7 | 8 | Keywords: `microservices`, `Hexagonal-Architecture`, `SOLID`, `Domain-Driven Design`, `functional-programming`, 9 | `Testing`, `Event-Driven Architecture`, `Domain-Events`, `Transactional-outbox` 10 | 11 | Tech-stack: `kotlin`, `Kafka`, `spring-boot`, `PostgreSQL`, `JUnit5`, `Arrow` 12 | 13 | ## Overview 14 | 15 |

16 | 17 |

18 | 19 | ## Use-cases 20 | 21 | - Create a team 22 | - Add person as a team member 23 | - Remove person as a team member 24 | 25 | ### Use-case diagram 26 | 27 | Example of how a use-case looks like: 28 | 29 |

30 | 31 |

32 | 33 | 34 | ## Architectural Patterns 35 | 36 | This project has been built using **[hexagonal architecture](https://alistair.cockburn.us/hexagonal-architecture/) (aka [ports & adapters](https://jmgarridopaz.github.io/content/hexagonalarchitecture.html))**, a domain-centric architectural pattern that use 37 | **[dependency inversion](https://blog.cleancoder.com/uncle-bob/2016/01/04/ALittleArchitecture.html)** as main principle behind. It also uses **tactical DDD patterns** in the domain layer. 38 | 39 | ### Package structure 40 | 41 | - Application: Application Services (the use cases) 42 | - Domain: Domain model and ports. 43 | - Infrastructure: Adapters, configuration and infrastructure code. 44 | 45 | ### Architectural shortcuts 46 | 47 | Even though the project follows hexagonal architecture, it also takes some shortcuts, breaking consciously 48 | some architectural constraints: 49 | 50 | - **Skipping incoming ports**: Incoming adapters are accessing application services directly. 51 | 52 | ## Messaging patterns 53 | 54 | In order to avoid [dual writes](https://thorben-janssen.com/dual-writes/) the project uses a couple of patterns: 55 | - [transactional-outbox](https://microservices.io/patterns/data/transactional-outbox.html) 56 | - [polling-publisher](https://microservices.io/patterns/data/polling-publisher.html) 57 | 58 | ## Events 59 | 60 | ### Domain events 61 | 62 | A Domain-event is something that happened in the domain that is important to the business. 63 | 64 | This service advocates for asynchronous communication instead of exposing endpoints to be consumed by clients. To do so 65 | , since the service uses also domain-driven design tactical patterns, all use-cases are producing domain-events: 66 | - [Team Created](/src/main/kotlin/com/teammgmt/domain/model/DomainEvents.kt) 67 | - [Team Member Joined](/src/main/kotlin/com/teammgmt/domain/model/DomainEvents.kt) 68 | - [Team Member Left](/src/main/kotlin/com/teammgmt/domain/model/DomainEvents.kt) 69 | 70 | ### Integration events 71 | 72 | An integration event is a committed event that ocurred in the past within a bounded context which may be interesting to other 73 | domains, applications or third party services, so it is the sibling of a domain event but for the external world. 74 | 75 | Why not to publish our domain events directly? We can not publish our domain events directly for several reasons: 76 | - Back-ward compatibility: We should provide a way to maintain backward compatibility, if we were publishing our domain events we would couple them to the external contracts. 77 | - Different schema for messages: In almost all the companies using event-driven these messages are defined in a different schema such as avro, protobuf or json schema. 78 | - We don't want to publish all domain-events: Sometimes we don't want to publish to our consumers all our internal domain events. 79 | 80 | Here the [contracts](/src/main/kotlin/com/teammgmt/infrastructure/adapters/outbound/event/IntegrationTeamEvents.kt) 81 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/outbox/PollingPublisherShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.outbox 2 | 3 | import com.teammgmt.fixtures.buildOutboxEvent 4 | import io.micrometer.core.instrument.simple.SimpleMeterRegistry 5 | import io.mockk.every 6 | import io.mockk.mockk 7 | import io.mockk.spyk 8 | import io.mockk.verify 9 | import org.assertj.core.api.Assertions.* 10 | import org.junit.jupiter.api.AfterEach 11 | import org.junit.jupiter.api.Test 12 | import org.slf4j.helpers.NOPLogger 13 | import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler 14 | import org.springframework.transaction.annotation.Transactional 15 | import org.testcontainers.shaded.org.awaitility.Awaitility.await 16 | import java.util.concurrent.TimeUnit 17 | import kotlin.reflect.KAnnotatedElement 18 | import kotlin.reflect.full.findAnnotation 19 | import org.springframework.transaction.PlatformTransactionManager 20 | import org.springframework.transaction.TransactionDefinition 21 | import org.springframework.transaction.TransactionStatus 22 | import org.springframework.transaction.support.SimpleTransactionStatus 23 | 24 | class PollingPublisherShould { 25 | 26 | private val logger = spyk(NOPLogger.NOP_LOGGER) 27 | 28 | private val meterRegistry = SimpleMeterRegistry() 29 | 30 | private val taskScheduler = ThreadPoolTaskScheduler().also { it.initialize() } 31 | 32 | private val outboxEventRepository = mockk(relaxed = true) 33 | 34 | private val kafkaOutboxEventProducer = mockk(relaxed = true) 35 | 36 | private val threadPoolTaskScheduler: ThreadPoolTaskScheduler = ThreadPoolTaskScheduler() 37 | 38 | private val transactionManager = spyk(object : PlatformTransactionManager { 39 | override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus() 40 | override fun rollback(status: TransactionStatus) {} 41 | override fun commit(status: TransactionStatus) {} 42 | }) 43 | 44 | init { 45 | threadPoolTaskScheduler.also { it.initialize() } 46 | PollingPublisher( 47 | transactionalOutbox = outboxEventRepository, 48 | kafkaOutboxEventProducer = kafkaOutboxEventProducer, 49 | pollingIntervalMs = 50, 50 | scheduler = taskScheduler, 51 | transactionManager = transactionManager, 52 | batchSize = 5, 53 | meterRegistry = meterRegistry, 54 | logger = logger 55 | ) 56 | } 57 | 58 | @AfterEach 59 | fun `tear down`() { 60 | threadPoolTaskScheduler.destroy() 61 | } 62 | 63 | @Test 64 | fun `send eventually events to kafka when outbox have some of them ready to be sent`() { 65 | val outboxEvent = buildOutboxEvent() 66 | every { outboxEventRepository.findReadyForPublishing(5) } returns listOf(outboxEvent) 67 | 68 | verify(timeout = 2000) { 69 | kafkaOutboxEventProducer.send(listOf(outboxEvent)) 70 | transactionManager.commit(any()) 71 | } 72 | } 73 | 74 | @Test 75 | fun `not delete from the outbox the message to be sent when there is a problem sending it`() { 76 | val outboxEvent = buildOutboxEvent() 77 | val crash = RuntimeException("Boom!") 78 | every { outboxEventRepository.findReadyForPublishing(5) } returns listOf(outboxEvent) 79 | every { kafkaOutboxEventProducer.send(listOf(outboxEvent)) } throws crash 80 | 81 | verify(timeout = 2000) { 82 | logger.error("Message batch publishing failed, will be retried", crash) 83 | transactionManager.rollback(any()) 84 | } 85 | await().atMost(2, TimeUnit.SECONDS).untilAsserted { 86 | assertThat(meterRegistry.counter("outbox.publishing.fail").count()).isGreaterThan(1.0) 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/acceptance/BaseAcceptanceTest.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.acceptance 2 | 3 | import com.github.javafaker.Faker 4 | import com.github.tomakehurst.wiremock.WireMockServer 5 | import com.teammgmt.App 6 | import com.teammgmt.Kafka 7 | import com.teammgmt.Postgres 8 | import com.teammgmt.acceptance.BaseAcceptanceTest.Initializer 9 | import com.teammgmt.fixtures.buildKafkaConsumer 10 | import com.teammgmt.fixtures.buildKafkaProducer 11 | import com.teammgmt.infrastructure.adapters.inbound.stream.PersonJoinedEvent 12 | import com.teammgmt.infrastructure.configuration.InfrastructureConfiguration 13 | import io.restassured.RestAssured 14 | import io.restassured.http.ContentType 15 | import kotlinx.coroutines.delay 16 | import org.apache.kafka.clients.producer.ProducerRecord 17 | import org.junit.jupiter.api.BeforeEach 18 | import org.springframework.boot.test.context.SpringBootTest 19 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT 20 | import org.springframework.boot.test.util.TestPropertyValues 21 | import org.springframework.boot.web.server.LocalServerPort 22 | import org.springframework.context.ApplicationContextInitializer 23 | import org.springframework.context.ConfigurableApplicationContext 24 | import org.springframework.test.context.ActiveProfiles 25 | import org.springframework.test.context.ContextConfiguration 26 | import java.time.LocalDateTime 27 | import java.util.UUID 28 | 29 | @ActiveProfiles("test") 30 | @SpringBootTest(webEnvironment = RANDOM_PORT) 31 | @ContextConfiguration(initializers = [Initializer::class], classes = [App::class]) 32 | abstract class BaseAcceptanceTest { 33 | 34 | private val faker = Faker() 35 | 36 | init { 37 | RestAssured.defaultParser = io.restassured.parsing.Parser.JSON 38 | } 39 | 40 | @LocalServerPort 41 | protected val servicePort: Int = 0 42 | 43 | protected val mapper = InfrastructureConfiguration().defaultObjectMapper() 44 | 45 | protected val kafkaConsumer = buildKafkaConsumer(kafka.container.bootstrapServers) 46 | 47 | protected val kafkaProducer = buildKafkaProducer(kafka.container.bootstrapServers) 48 | 49 | protected val wireMockServer = WireMockServer() 50 | 51 | companion object { 52 | 53 | val postgres = Postgres() 54 | val kafka = Kafka() 55 | } 56 | 57 | @BeforeEach 58 | fun setUp() { 59 | wireMockServer.resetAll() 60 | } 61 | 62 | class Initializer : ApplicationContextInitializer { 63 | 64 | override fun initialize(configurableApplicationContext: ConfigurableApplicationContext) { 65 | TestPropertyValues.of( 66 | "spring.datasource.url=" + postgres.container.jdbcUrl, 67 | "spring.datasource.password=" + postgres.container.password, 68 | "spring.datasource.username=" + postgres.container.username, 69 | "spring.kafka.bootstrap-servers=" + kafka.container.bootstrapServers, 70 | "spring.flyway.url=" + postgres.container.jdbcUrl, 71 | "spring.flyway.password=" + postgres.container.password, 72 | "spring.flyway.username=" + postgres.container.username, 73 | ).applyTo(configurableApplicationContext.environment) 74 | } 75 | } 76 | 77 | protected suspend fun givenAPersonExists(): UUID { 78 | val joinedEvent = PersonJoinedEvent(UUID.randomUUID(), "John", "Doe", LocalDateTime.now()) 79 | kafkaProducer 80 | .send( 81 | ProducerRecord( 82 | "public.person.v1", 83 | joinedEvent.personId.toString(), 84 | mapper.writeValueAsBytes(joinedEvent) 85 | ) 86 | ) 87 | delay(2000) // could be improved 88 | return joinedEvent.personId 89 | } 90 | 91 | protected fun givenATeamExists(): UUID { 92 | val id: String = RestAssured.given() 93 | .contentType(ContentType.JSON) 94 | .body("""{ "name": "${faker.team().name()}" }""") 95 | .port(servicePort) 96 | .`when`() 97 | .post("/teams") 98 | .then() 99 | .extract() 100 | .path("id") 101 | return UUID.fromString(id) 102 | } 103 | 104 | protected fun givenAPersonPartOfTheTeam(teamId: UUID, personId: UUID) { 105 | RestAssured.given() 106 | .port(servicePort) 107 | .`when`() 108 | .put("/teams/$teamId/person/$personId") 109 | .then() 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/adapters/outbound/db/PostgresPeopleReplicationRepositoryShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.db 2 | 3 | import arrow.core.left 4 | import arrow.core.right 5 | import com.teammgmt.Postgres 6 | import com.teammgmt.domain.model.FullName 7 | import com.teammgmt.domain.model.Person 8 | import com.teammgmt.domain.model.PersonId 9 | import com.teammgmt.domain.model.PersonNotFound 10 | import com.teammgmt.fixtures.buildPersonReplicationInfo 11 | import com.teammgmt.infrastructure.adapters.outbound.db.PersonStatus.DELETED 12 | import org.assertj.core.api.Assertions.assertThat 13 | import org.junit.jupiter.api.AfterEach 14 | import org.junit.jupiter.api.Nested 15 | import org.junit.jupiter.api.Tag 16 | import org.junit.jupiter.api.Test 17 | import org.springframework.jdbc.core.JdbcTemplate 18 | import java.util.UUID 19 | 20 | @Tag("integration") 21 | class PostgresPeopleReplicationRepositoryShould { 22 | 23 | private val database = Postgres() 24 | 25 | private val jdbcTemplate = JdbcTemplate(database.datasource) 26 | 27 | private val peopleReplicationRepository = PostgresPeopleReplicationRepository(jdbcTemplate) 28 | 29 | @AfterEach 30 | fun `tear down`() { 31 | database.container.stop() 32 | } 33 | 34 | @Nested 35 | inner class Find { 36 | 37 | @Test 38 | fun `find a person`() { 39 | val info = buildPersonReplicationInfo().also(peopleReplicationRepository::save) 40 | 41 | val result = peopleReplicationRepository.find(PersonId(info.personId)) 42 | 43 | assertThat(result).isEqualTo( 44 | Person( 45 | personId = PersonId(info.personId), 46 | fullName = FullName.reconstitute("${info.firstName} ${info.lastname}") 47 | ).right() 48 | ) 49 | } 50 | 51 | @Test 52 | fun `not find a person when it does not exists`() { 53 | val result = peopleReplicationRepository.find(PersonId(UUID.randomUUID())) 54 | 55 | assertThat(result).isEqualTo(PersonNotFound.left()) 56 | } 57 | 58 | @Test 59 | fun `not find a person when it has been deleted`() { 60 | val person = buildPersonReplicationInfo(status = DELETED).also(peopleReplicationRepository::save) 61 | 62 | val result = peopleReplicationRepository.find(PersonId(person.personId)) 63 | 64 | assertThat(result).isEqualTo(PersonNotFound.left()) 65 | } 66 | } 67 | 68 | @Nested 69 | inner class Save { 70 | 71 | @Test 72 | fun `save a person replication info`() { 73 | val person = buildPersonReplicationInfo() 74 | 75 | val result = peopleReplicationRepository.save(person) 76 | 77 | assertThat(result).isEqualTo(Unit) 78 | val storedPerson = jdbcTemplate.queryForMap( 79 | "SELECT * FROM people_replication WHERE id = '${person.personId}'" 80 | ) 81 | assertThat(storedPerson).containsAllEntriesOf( 82 | mapOf( 83 | "id" to person.personId, 84 | "status" to person.status.name, 85 | "first_name" to person.firstName, 86 | "last_name" to person.lastname 87 | ) 88 | ) 89 | } 90 | 91 | @Test 92 | fun `update the person replication info when it already exists`() { 93 | val person = buildPersonReplicationInfo().also(peopleReplicationRepository::save) 94 | val personWithNewName = person.copy(firstName = "James") 95 | 96 | val result = peopleReplicationRepository.save(personWithNewName) 97 | 98 | assertThat(result).isEqualTo(Unit) 99 | val storedPerson = jdbcTemplate.queryForMap( 100 | "SELECT * FROM people_replication WHERE id = '${person.personId}'" 101 | ) 102 | assertThat(storedPerson).containsAllEntriesOf( 103 | mapOf("first_name" to personWithNewName.firstName) 104 | ) 105 | } 106 | } 107 | 108 | @Nested 109 | inner class Delete { 110 | 111 | @Test 112 | fun `delete a person`() { 113 | val person = buildPersonReplicationInfo().also(peopleReplicationRepository::save) 114 | 115 | peopleReplicationRepository.delete(person.personId) 116 | 117 | val storedPerson = jdbcTemplate.queryForMap( 118 | "SELECT * FROM people_replication WHERE id = '${person.personId}'" 119 | ) 120 | assertThat(storedPerson).containsAllEntriesOf(mapOf("status" to DELETED.name)) 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/adapters/outbound/event/OutboxSubscriberShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.event 2 | 3 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 4 | import com.teammgmt.domain.model.TeamCreated 5 | import com.teammgmt.domain.model.TeamMember 6 | import com.teammgmt.domain.model.TeamMemberJoined 7 | import com.teammgmt.domain.model.TeamMemberLeft 8 | import com.teammgmt.fixtures.buildPerson 9 | import com.teammgmt.fixtures.buildTeam 10 | import com.teammgmt.infrastructure.outbox.OutboxEvent 11 | import com.teammgmt.infrastructure.outbox.TransactionalOutbox 12 | import io.mockk.mockk 13 | import io.mockk.verify 14 | import org.junit.jupiter.api.Test 15 | import java.time.Clock 16 | import java.time.LocalDateTime 17 | import java.time.ZoneId 18 | import java.time.ZoneOffset 19 | import java.util.UUID 20 | 21 | class OutboxSubscriberShould { 22 | 23 | private val transactionalOutbox = mockk(relaxed = true) 24 | 25 | private val objectMapper = jacksonObjectMapper() 26 | 27 | private val eventId = UUID.randomUUID() 28 | 29 | private val generateId = { eventId } 30 | 31 | private val now = LocalDateTime.now() 32 | 33 | private val clock = Clock.fixed(now.toInstant(ZoneOffset.UTC), ZoneId.of("UTC")) 34 | 35 | private val outboxSubscriber = OutboxSubscriber( 36 | transactionalOutbox = transactionalOutbox, 37 | teamEventStreamName = "stream", 38 | mapper = objectMapper, 39 | clock = clock, 40 | generateId = generateId 41 | ) 42 | 43 | @Test 44 | fun `store team created event into the outbox`() { 45 | val team = buildTeam() 46 | val event = TeamCreated(team) 47 | 48 | outboxSubscriber.handle(event) 49 | 50 | val expectedIntegrationEvent = IntegrationEvent.TeamCreatedEvent( 51 | team = TeamDto( 52 | id = team.teamId.value, 53 | name = team.teamName.value, 54 | members = team.members.map { TeamMemberDto(it.personId.value) } 55 | ), 56 | eventId = eventId, 57 | occurredOn = now 58 | ) 59 | verify { 60 | transactionalOutbox.storeForPublishing( 61 | OutboxEvent( 62 | aggregateId = team.teamId.value, 63 | stream = "stream", 64 | payload = objectMapper.writeValueAsBytes(expectedIntegrationEvent) 65 | ) 66 | ) 67 | } 68 | } 69 | 70 | @Test 71 | fun `store team member joined event into the outbox`() { 72 | val teamMember = TeamMember(buildPerson().personId) 73 | val team = buildTeam(members = setOf(teamMember)) 74 | val event = TeamMemberJoined(team, teamMember.personId) 75 | 76 | outboxSubscriber.handle(event) 77 | 78 | val expectedIntegrationEvent = IntegrationEvent.TeamMemberJoined( 79 | team = TeamDto( 80 | id = team.teamId.value, 81 | name = team.teamName.value, 82 | members = team.members.map { TeamMemberDto(it.personId.value) } 83 | ), 84 | eventId = eventId, 85 | occurredOn = now, 86 | teamMember = teamMember.personId.value 87 | ) 88 | verify { 89 | transactionalOutbox.storeForPublishing( 90 | OutboxEvent( 91 | aggregateId = team.teamId.value, 92 | stream = "stream", 93 | payload = objectMapper.writeValueAsBytes(expectedIntegrationEvent) 94 | ) 95 | ) 96 | } 97 | } 98 | 99 | @Test 100 | fun `store team member left event into the outbox`() { 101 | val teamMember = TeamMember(buildPerson().personId) 102 | val team = buildTeam(members = setOf(teamMember)) 103 | val event = TeamMemberLeft(team, teamMember.personId) 104 | 105 | outboxSubscriber.handle(event) 106 | 107 | val expectedIntegrationEvent = IntegrationEvent.TeamMemberLeft( 108 | team = TeamDto( 109 | id = team.teamId.value, 110 | name = team.teamName.value, 111 | members = team.members.map { TeamMemberDto(it.personId.value) } 112 | ), 113 | eventId = eventId, 114 | occurredOn = now, 115 | teamMember = teamMember.personId.value 116 | ) 117 | verify { 118 | transactionalOutbox.storeForPublishing( 119 | OutboxEvent( 120 | aggregateId = team.teamId.value, 121 | stream = "stream", 122 | payload = objectMapper.writeValueAsBytes(expectedIntegrationEvent) 123 | ) 124 | ) 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/main/kotlin/com/teammgmt/infrastructure/configuration/InfrastructureConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.configuration 2 | 3 | import com.fasterxml.jackson.databind.DeserializationFeature 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.databind.SerializationFeature 6 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule 7 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 8 | import com.teammgmt.infrastructure.adapters.outbound.client.PeopleServiceApi 9 | import com.teammgmt.infrastructure.adapters.outbound.db.PostgresPeopleReplicationRepository 10 | import com.teammgmt.infrastructure.adapters.outbound.db.PostgresTeamRepository 11 | import com.teammgmt.infrastructure.adapters.outbound.event.InMemoryDomainEventPublisher 12 | import com.teammgmt.infrastructure.adapters.outbound.event.LoggingSubscriber 13 | import com.teammgmt.infrastructure.adapters.outbound.event.MetricsSubscriber 14 | import com.teammgmt.infrastructure.adapters.outbound.event.OutboxSubscriber 15 | import com.teammgmt.infrastructure.outbox.TransactionalOutbox 16 | import io.github.resilience4j.circuitbreaker.CircuitBreaker 17 | import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry 18 | import io.github.resilience4j.circuitbreaker.autoconfigure.CircuitBreakerProperties 19 | import io.github.resilience4j.common.CompositeCustomizer 20 | import io.github.resilience4j.retrofit.CircuitBreakerCallAdapter 21 | import io.micrometer.core.instrument.MeterRegistry 22 | import okhttp3.OkHttpClient 23 | import org.springframework.beans.factory.annotation.Value 24 | import org.springframework.context.annotation.Bean 25 | import org.springframework.context.annotation.Configuration 26 | import org.springframework.context.annotation.Primary 27 | import org.springframework.http.HttpStatus 28 | import org.springframework.jdbc.core.JdbcTemplate 29 | import retrofit2.Retrofit 30 | import retrofit2.converter.jackson.JacksonConverterFactory 31 | import java.util.concurrent.TimeUnit.MILLISECONDS 32 | 33 | @Configuration 34 | class InfrastructureConfiguration { 35 | 36 | @Bean 37 | @Primary 38 | fun defaultObjectMapper(): ObjectMapper = jacksonObjectMapper() 39 | .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) 40 | .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) 41 | .configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE, true) 42 | .registerModules(JavaTimeModule()) 43 | .findAndRegisterModules() 44 | 45 | @Bean 46 | fun domainEventPublisher( 47 | transactionalOutbox: TransactionalOutbox, 48 | meterRegistry: MeterRegistry, 49 | objectMapper: ObjectMapper, 50 | @Value("\${spring.kafka.producer.team.topic}") 51 | teamEventStream: String, 52 | ) = InMemoryDomainEventPublisher( 53 | listOf( 54 | LoggingSubscriber(), 55 | MetricsSubscriber(meterRegistry), 56 | OutboxSubscriber(transactionalOutbox, teamEventStream, objectMapper) 57 | ) 58 | ) 59 | 60 | @Bean 61 | fun peopleFinder(jdbcTemplate: JdbcTemplate) = PostgresPeopleReplicationRepository(jdbcTemplate) 62 | 63 | // @Bean 64 | // fun peopleFinder(peopleServiceApi: PeopleServiceApi) = PeopleServiceHttpClient(peopleServiceApi) 65 | 66 | @Bean 67 | fun postgresTeamRepository(jdbcTemplate: JdbcTemplate) = 68 | PostgresTeamRepository(jdbcTemplate) 69 | 70 | @Bean 71 | fun peopleServiceCircuitBreaker( 72 | registry: CircuitBreakerRegistry, 73 | circuitBreakerProperties: CircuitBreakerProperties 74 | ) = 75 | registry.circuitBreaker( 76 | "people-service", 77 | circuitBreakerProperties.createCircuitBreakerConfig( 78 | "people-service", 79 | circuitBreakerProperties.instances["people-service"], 80 | CompositeCustomizer(emptyList()) 81 | ) 82 | ) 83 | 84 | @Bean 85 | fun peopleServiceApi( 86 | defaultObjectMapper: ObjectMapper, 87 | circuitBreaker: CircuitBreaker, 88 | meterRegistry: MeterRegistry, 89 | @Value("\${clients.people-service.url}") url: String, 90 | @Value("\${clients.people-service.connectTimeoutMillis}") connectTimeout: Long, 91 | @Value("\${clients.people-service.readTimeoutMillis}") readTimeout: Long, 92 | ): PeopleServiceApi { 93 | val okHttpClient = OkHttpClient.Builder() 94 | .connectTimeout(connectTimeout, MILLISECONDS) 95 | .readTimeout(readTimeout, MILLISECONDS) 96 | .build() 97 | return Retrofit.Builder() 98 | .baseUrl(url) 99 | .addConverterFactory(JacksonConverterFactory.create(defaultObjectMapper)) 100 | .addCallAdapterFactory( 101 | CircuitBreakerCallAdapter.of( 102 | circuitBreaker 103 | ) { r -> !HttpStatus.valueOf(r.code()).is5xxServerError } 104 | ) 105 | .client(okHttpClient) 106 | .build() 107 | .create(PeopleServiceApi::class.java) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/adapters/outbound/db/PostgresTeamRepositoryShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.outbound.db 2 | 3 | import arrow.core.left 4 | import arrow.core.right 5 | import com.teammgmt.Postgres 6 | import com.teammgmt.domain.model.Team 7 | import com.teammgmt.domain.model.TeamMember 8 | import com.teammgmt.domain.model.TeamName 9 | import com.teammgmt.domain.model.TeamNameAlreadyTaken 10 | import com.teammgmt.domain.model.TeamNotFound 11 | import com.teammgmt.fixtures.buildPerson 12 | import com.teammgmt.fixtures.buildTeam 13 | import org.assertj.core.api.Assertions.assertThat 14 | import org.junit.jupiter.api.AfterEach 15 | import org.junit.jupiter.api.Nested 16 | import org.junit.jupiter.api.Tag 17 | import org.junit.jupiter.api.Test 18 | import org.springframework.jdbc.core.JdbcTemplate 19 | import java.util.UUID.randomUUID 20 | 21 | @Tag("integration") 22 | class PostgresTeamRepositoryShould { 23 | 24 | private val database = Postgres() 25 | 26 | private val jdbcTemplate = JdbcTemplate(database.datasource) 27 | 28 | private val teamRepository = PostgresTeamRepository(jdbcTemplate) 29 | 30 | @AfterEach 31 | fun `tear down`() { 32 | database.container.stop() 33 | } 34 | 35 | @Nested 36 | inner class Find { 37 | 38 | @Test 39 | fun `find a team`() { 40 | val team = buildTeam().also(teamRepository::save) 41 | 42 | val result = teamRepository.find(team.teamId) 43 | 44 | assertThat(result).isEqualTo(team.right()) 45 | } 46 | 47 | @Test 48 | fun `not find a team when it does not exists`() { 49 | val team = buildTeam() 50 | 51 | val result = teamRepository.find(team.teamId) 52 | 53 | assertThat(result).isEqualTo(TeamNotFound.left()) 54 | } 55 | } 56 | 57 | @Nested 58 | inner class Save { 59 | 60 | @Test 61 | fun `save a team`() { 62 | val team = buildTeam() 63 | 64 | val result = teamRepository.save(team) 65 | 66 | assertThat(result).isEqualTo(team.right()) 67 | jdbcTemplate.queryForObject( 68 | """SELECT * FROM team WHERE id = '${team.teamId.value}'""" 69 | ) { it, _ -> 70 | assertThat(it.getString("id")).isEqualTo(team.teamId.value.toString()) 71 | assertThat(it.getString("name")).isEqualTo(team.teamName.value) 72 | assertThat(it.getString("members")).isEqualTo("{}") 73 | } 74 | } 75 | 76 | @Test 77 | fun `update the team when it already exists`() { 78 | val team = buildTeam() 79 | teamRepository.save(team) 80 | val updatedTeam = team.copy(teamName = TeamName.reconstitute("teletubbies")) 81 | 82 | val result = teamRepository.save(updatedTeam) 83 | 84 | assertThat(result).isEqualTo(updatedTeam.right()) 85 | jdbcTemplate.queryForObject( 86 | """SELECT * FROM team WHERE id = '${team.teamId.value}'""" 87 | ) { it, _ -> 88 | assertThat(it.getString("id")).isEqualTo(team.teamId.value.toString()) 89 | assertThat(it.getString("name")).isEqualTo(updatedTeam.teamName.value) 90 | assertThat(it.getString("members")).isEqualTo("{}") 91 | } 92 | } 93 | 94 | @Test 95 | fun `fail saving a team when the name is already taken by another one`() { 96 | val team = buildTeam(teamName = "Teletubbies") 97 | val teamWithAlreadyTakenName = buildTeam(teamId = randomUUID(), teamName = "Teletubbies") 98 | teamRepository.save(team) 99 | 100 | val result = teamRepository.save(teamWithAlreadyTakenName) 101 | 102 | assertThat(result).isEqualTo(TeamNameAlreadyTaken.left()) 103 | } 104 | } 105 | 106 | @Nested 107 | inner class FindTeamsForAPerson { 108 | 109 | @Test 110 | fun `find all teams for a person`() { 111 | val john = buildPerson(fullName = "John Doe") 112 | val jane = buildPerson(personId = randomUUID(), fullName = "Jane Doe") 113 | val firstJohnTeam = 114 | buildTeam(teamName = "first", teamId = randomUUID(), members = setOf(TeamMember(john.personId))) 115 | .also(teamRepository::save) 116 | val secondJohnTeam = 117 | buildTeam(teamName = "second", teamId = randomUUID(), members = setOf(TeamMember(john.personId))) 118 | .also(teamRepository::save) 119 | val janeTeam = 120 | buildTeam(teamName = "third", teamId = randomUUID(), members = setOf(TeamMember(jane.personId))) 121 | .also(teamRepository::save) 122 | 123 | val result = teamRepository.findAllTeamsFor(john.personId) 124 | 125 | assertThat(result).isEqualTo(listOf(firstJohnTeam, secondJohnTeam)) 126 | } 127 | 128 | @Test 129 | fun `not find teams for a person when they are not belonging to any`() { 130 | val person = buildPerson() 131 | 132 | val result = teamRepository.findAllTeamsFor(person.personId) 133 | 134 | assertThat(result).isEqualTo(emptyList()) 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/adapters/inbound/http/TeamsHttpControllerShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.inbound.http 2 | 3 | import arrow.core.left 4 | import arrow.core.right 5 | import com.ninjasquad.springmockk.MockkBean 6 | import com.teammgmt.application.service.AddTeamMemberRequest 7 | import com.teammgmt.application.service.AddTeamMemberService 8 | import com.teammgmt.application.service.CreateTeamRequest 9 | import com.teammgmt.application.service.CreateTeamResponse 10 | import com.teammgmt.application.service.CreateTeamService 11 | import com.teammgmt.application.service.RemoveTeamMemberRequest 12 | import com.teammgmt.application.service.RemoveTeamMemberService 13 | import com.teammgmt.domain.model.PersonNotFound 14 | import com.teammgmt.domain.model.TeamValidationError.TooLongName 15 | import io.mockk.every 16 | import io.undertow.util.StatusCodes.CREATED 17 | import io.undertow.util.StatusCodes.NO_CONTENT 18 | import org.assertj.core.api.Assertions.assertThat 19 | import org.junit.jupiter.api.Nested 20 | import org.junit.jupiter.api.Tag 21 | import org.junit.jupiter.api.Test 22 | import org.springframework.beans.factory.annotation.Autowired 23 | import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest 24 | import org.springframework.http.MediaType 25 | import org.springframework.test.web.reactive.server.WebTestClient 26 | import org.springframework.web.reactive.function.BodyInserters 27 | import java.util.UUID.randomUUID 28 | 29 | @Tag("integration") 30 | @WebFluxTest(TeamsHttpController::class) 31 | class TeamsHttpControllerShould(@Autowired val webTestClient: WebTestClient) { 32 | 33 | @MockkBean 34 | private lateinit var createTeam: CreateTeamService 35 | 36 | @MockkBean 37 | private lateinit var addTeamMember: AddTeamMemberService 38 | 39 | @MockkBean 40 | private lateinit var removeTeamMember: RemoveTeamMemberService 41 | 42 | @Nested 43 | inner class Create { 44 | 45 | @Test 46 | fun `should create a team`() { 47 | val teamId = randomUUID() 48 | every { createTeam(CreateTeamRequest("Hungry Hippos")) } returns CreateTeamResponse(teamId).right() 49 | 50 | val response = webTestClient 51 | .post() 52 | .uri("/teams") 53 | .contentType(MediaType.APPLICATION_JSON) 54 | .body(BodyInserters.fromValue("""{ "name": "Hungry Hippos" }""")) 55 | .exchange() 56 | 57 | response 58 | .expectStatus().isCreated 59 | .expectBody().json("""{ "id": "$teamId" }""") 60 | } 61 | 62 | @Test 63 | fun `should fail when there is an error creating the team`() { 64 | every { createTeam(CreateTeamRequest("Hungry Hippos")) } returns TooLongName.left() 65 | 66 | val response = webTestClient 67 | .post() 68 | .uri("/teams") 69 | .contentType(MediaType.APPLICATION_JSON) 70 | .body(BodyInserters.fromValue("""{ "name": "Hungry Hippos" }""")) 71 | .exchange() 72 | 73 | response.expectStatus().value { 74 | assertThat(it).isNotEqualTo(CREATED) 75 | } 76 | } 77 | } 78 | 79 | @Nested 80 | inner class AddTeamMember { 81 | 82 | @Test 83 | fun `should add a new team member to a team`() { 84 | val teamId = randomUUID() 85 | val personId = randomUUID() 86 | every { addTeamMember(AddTeamMemberRequest(teamId, personId)) } returns Unit.right() 87 | 88 | val response = webTestClient 89 | .put() 90 | .uri("/teams/$teamId/person/$personId") 91 | .exchange() 92 | 93 | response.expectStatus().isNoContent 94 | } 95 | 96 | @Test 97 | fun `should fail when there is an error creating the adding a team member`() { 98 | val teamId = randomUUID() 99 | val personId = randomUUID() 100 | every { addTeamMember(AddTeamMemberRequest(teamId, personId)) } returns PersonNotFound.left() 101 | 102 | val response = webTestClient 103 | .put() 104 | .uri("/teams/$teamId/person/$personId") 105 | .exchange() 106 | 107 | response.expectStatus().value { 108 | assertThat(it).isNotEqualTo(NO_CONTENT) 109 | } 110 | } 111 | } 112 | 113 | @Nested 114 | inner class RemoveTeamMember { 115 | 116 | @Test 117 | fun `should remove a team member from a team`() { 118 | val teamId = randomUUID() 119 | val personId = randomUUID() 120 | every { removeTeamMember(RemoveTeamMemberRequest(teamId, personId)) } returns Unit.right() 121 | 122 | val response = webTestClient 123 | .delete() 124 | .uri("/teams/$teamId/person/$personId") 125 | .exchange() 126 | 127 | response.expectStatus().isNoContent 128 | } 129 | 130 | @Test 131 | fun `should fail when there is an error creating the adding a team member`() { 132 | val teamId = randomUUID() 133 | val personId = randomUUID() 134 | every { removeTeamMember(RemoveTeamMemberRequest(teamId, personId)) } returns PersonNotFound.left() 135 | 136 | val response = webTestClient 137 | .delete() 138 | .uri("/teams/$teamId/person/$personId") 139 | .exchange() 140 | 141 | response.expectStatus().value { 142 | assertThat(it).isNotEqualTo(NO_CONTENT) 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/test/kotlin/com/teammgmt/infrastructure/adapters/inbound/stream/KafkaPeopleEventsConsumerShould.kt: -------------------------------------------------------------------------------- 1 | package com.teammgmt.infrastructure.adapters.inbound.stream 2 | 3 | import com.teammgmt.Kafka 4 | import com.teammgmt.application.service.RemoveTeamMemberService 5 | import com.teammgmt.domain.model.PersonId 6 | import com.teammgmt.fixtures.buildKafkaProducer 7 | import com.teammgmt.fixtures.buildTeam 8 | import com.teammgmt.infrastructure.adapters.outbound.db.PersonReplicationInfo 9 | import com.teammgmt.infrastructure.adapters.outbound.db.PersonStatus 10 | import com.teammgmt.infrastructure.adapters.outbound.db.PostgresPeopleReplicationRepository 11 | import com.teammgmt.infrastructure.adapters.outbound.db.PostgresTeamRepository 12 | import com.teammgmt.infrastructure.configuration.InfrastructureConfiguration 13 | import io.mockk.Runs 14 | import io.mockk.clearMocks 15 | import io.mockk.every 16 | import io.mockk.just 17 | import io.mockk.mockk 18 | import io.mockk.verify 19 | import org.apache.kafka.clients.producer.ProducerRecord 20 | import org.junit.jupiter.api.AfterEach 21 | import org.junit.jupiter.api.BeforeEach 22 | import org.junit.jupiter.api.Nested 23 | import org.junit.jupiter.api.Test 24 | import org.springframework.beans.factory.annotation.Autowired 25 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration 26 | import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration 27 | import org.springframework.boot.test.context.SpringBootTest 28 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE 29 | import org.springframework.boot.test.context.TestConfiguration 30 | import org.springframework.context.annotation.Bean 31 | import org.springframework.kafka.annotation.EnableKafka 32 | import org.springframework.test.context.ActiveProfiles 33 | import org.springframework.test.context.ContextConfiguration 34 | import org.springframework.test.context.TestPropertySource 35 | import java.time.LocalDateTime.now 36 | import java.util.UUID.randomUUID 37 | 38 | @ContextConfiguration(classes = [TestConfiguration::class]) 39 | @SpringBootTest(webEnvironment = NONE, classes = [KafkaPeopleEventsConsumer::class, ConsumerDependencies::class]) 40 | @TestPropertySource(properties = ["spring.flyway.enabled=false"]) 41 | @EnableAutoConfiguration(exclude = [DataSourceAutoConfiguration::class]) 42 | class KafkaPeopleEventsConsumerShould { 43 | 44 | companion object { 45 | val kafka = Kafka() 46 | } 47 | 48 | @BeforeEach 49 | fun clear() { 50 | clearMocks(removeTeamMember, postgresPeopleReplicationRepository, postgresTeamRepository) 51 | } 52 | 53 | @AfterEach 54 | fun `tear down`() { 55 | // kafka.container.stop() 56 | } 57 | 58 | @Autowired 59 | private lateinit var removeTeamMember: RemoveTeamMemberService 60 | 61 | @Autowired 62 | private lateinit var postgresPeopleReplicationRepository: PostgresPeopleReplicationRepository 63 | 64 | @Autowired 65 | private lateinit var postgresTeamRepository: PostgresTeamRepository 66 | 67 | private val mapper = InfrastructureConfiguration().defaultObjectMapper() 68 | 69 | @Nested 70 | inner class ConsumePersonJoinedEvent { 71 | 72 | @Test 73 | fun `replicate person information when it is created`() { 74 | val joinedEvent = PersonJoinedEvent(randomUUID(), "John", "Doe", now()) 75 | 76 | buildKafkaProducer(kafka.container.bootstrapServers) 77 | .send( 78 | ProducerRecord( 79 | "public.person.v1", 80 | joinedEvent.personId.toString(), 81 | mapper.writeValueAsBytes(joinedEvent) 82 | ) 83 | ) 84 | 85 | verify(timeout = 3000) { 86 | postgresPeopleReplicationRepository.save( 87 | PersonReplicationInfo( 88 | joinedEvent.personId, 89 | joinedEvent.firstName, 90 | joinedEvent.lastname, 91 | joinedEvent.joinedAt, 92 | PersonStatus.ACTIVE 93 | ) 94 | ) 95 | } 96 | } 97 | } 98 | 99 | @Nested 100 | inner class ConsumePersonLeftEvent { 101 | 102 | @Test 103 | fun `remove replication and remove it from any team that is a team member when a person leaves`() { 104 | val leftEvent = PersonLeftEvent(randomUUID(), now()) 105 | val firstTeam = buildTeam(randomUUID()) 106 | val secondTeam = buildTeam(randomUUID()) 107 | every { 108 | postgresTeamRepository.findAllTeamsFor(PersonId(leftEvent.personId)) 109 | } returns listOf(firstTeam, secondTeam) 110 | every { 111 | postgresPeopleReplicationRepository.delete(leftEvent.personId) 112 | } just Runs 113 | 114 | buildKafkaProducer(kafka.container.bootstrapServers) 115 | .send( 116 | ProducerRecord( 117 | "public.person.v1", 118 | leftEvent.personId.toString(), 119 | mapper.writeValueAsBytes(leftEvent) 120 | ) 121 | ) 122 | 123 | verify(timeout = 3000, exactly = 2) { removeTeamMember(any()) } 124 | } 125 | } 126 | } 127 | 128 | @ActiveProfiles("test") 129 | @EnableKafka 130 | @TestConfiguration 131 | private class ConsumerDependencies { 132 | 133 | @Bean 134 | fun removeTeamMember(): RemoveTeamMemberService = mockk(relaxUnitFun = true) 135 | 136 | @Bean 137 | fun postgresPeopleReplicationRepository(): PostgresPeopleReplicationRepository = mockk(relaxUnitFun = true) 138 | 139 | @Bean 140 | fun postgresTeamRepository(): PostgresTeamRepository = mockk(relaxUnitFun = true) 141 | } 142 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | indent_size = 4 5 | indent_style = space 6 | insert_final_newline = true 7 | max_line_length = 120 8 | tab_width = 4 9 | ij_continuation_indent_size = 8 10 | ij_formatter_off_tag = @formatter:off 11 | ij_formatter_on_tag = @formatter:on 12 | ij_formatter_tags_enabled = true 13 | ij_smart_tabs = false 14 | ij_visual_guides = 120 15 | ij_wrap_on_typing = false 16 | 17 | [*.json] 18 | indent_size = 2 19 | ij_json_keep_blank_lines_in_code = 0 20 | ij_json_keep_indents_on_empty_lines = false 21 | ij_json_keep_line_breaks = true 22 | ij_json_space_after_colon = true 23 | ij_json_space_after_comma = true 24 | ij_json_space_before_colon = true 25 | ij_json_space_before_comma = false 26 | ij_json_spaces_within_braces = false 27 | ij_json_spaces_within_brackets = false 28 | ij_json_wrap_long_lines = false 29 | 30 | [*.properties] 31 | ij_properties_align_group_field_declarations = false 32 | 33 | [*.proto] 34 | ij_proto_keep_indents_on_empty_lines = false 35 | 36 | [.editorconfig] 37 | ij_editorconfig_align_group_field_declarations = false 38 | ij_editorconfig_space_after_colon = false 39 | ij_editorconfig_space_after_comma = true 40 | ij_editorconfig_space_before_colon = false 41 | ij_editorconfig_space_before_comma = false 42 | ij_editorconfig_spaces_around_assignment_operators = true 43 | 44 | [{*.bash, *.zsh, *.sh}] 45 | indent_size = 2 46 | tab_width = 2 47 | ij_shell_binary_ops_start_line = false 48 | ij_shell_keep_column_alignment_padding = false 49 | ij_shell_minify_program = false 50 | ij_shell_redirect_followed_by_space = false 51 | ij_shell_switch_cases_indented = false 52 | 53 | [{*.gradle.kts, *.kt, *.kts, *.main.kts}] 54 | ij_continuation_indent_size = 4 55 | ij_kotlin_align_in_columns_case_branch = false 56 | ij_kotlin_align_multiline_binary_operation = false 57 | ij_kotlin_align_multiline_extends_list = false 58 | ij_kotlin_align_multiline_method_parentheses = false 59 | ij_kotlin_align_multiline_parameters = true 60 | ij_kotlin_align_multiline_parameters_in_calls = false 61 | ij_kotlin_assignment_wrap = normal 62 | ij_kotlin_blank_lines_after_class_header = 1 63 | ij_kotlin_blank_lines_around_block_when_branches = 0 64 | ij_kotlin_block_comment_at_first_column = true 65 | ij_kotlin_call_parameters_new_line_after_left_paren = true 66 | ij_kotlin_call_parameters_right_paren_on_new_line = true 67 | ij_kotlin_call_parameters_wrap = on_every_item 68 | ij_kotlin_catch_on_new_line = false 69 | ij_kotlin_class_annotation_wrap = split_into_lines 70 | ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL 71 | ij_kotlin_continuation_indent_for_chained_calls = true 72 | ij_kotlin_continuation_indent_for_expression_bodies = true 73 | ij_kotlin_continuation_indent_in_argument_lists = true 74 | ij_kotlin_continuation_indent_in_elvis = true 75 | ij_kotlin_continuation_indent_in_if_conditions = true 76 | ij_kotlin_continuation_indent_in_parameter_lists = true 77 | ij_kotlin_continuation_indent_in_supertype_lists = true 78 | ij_kotlin_else_on_new_line = false 79 | ij_kotlin_enum_constants_wrap = off 80 | ij_kotlin_extends_list_wrap = normal 81 | ij_kotlin_field_annotation_wrap = split_into_lines 82 | ij_kotlin_finally_on_new_line = false 83 | ij_kotlin_if_rparen_on_new_line = false 84 | ij_kotlin_import_nested_classes = false 85 | ij_kotlin_insert_whitespaces_in_simple_one_line_method = true 86 | ij_kotlin_keep_blank_lines_before_right_brace = 0 87 | ij_kotlin_keep_blank_lines_in_code = 1 88 | ij_kotlin_keep_blank_lines_in_declarations = 1 89 | ij_kotlin_keep_first_column_comment = true 90 | ij_kotlin_keep_indents_on_empty_lines = false 91 | ij_kotlin_keep_line_breaks = true 92 | ij_kotlin_lbrace_on_next_line = false 93 | ij_kotlin_line_comment_add_space = false 94 | ij_kotlin_line_comment_at_first_column = true 95 | ij_kotlin_method_annotation_wrap = split_into_lines 96 | ij_kotlin_method_call_chain_wrap = normal 97 | ij_kotlin_method_parameters_new_line_after_left_paren = true 98 | ij_kotlin_method_parameters_right_paren_on_new_line = true 99 | ij_kotlin_method_parameters_wrap = on_every_item 100 | ij_kotlin_name_count_to_use_star_import = 2147483647 101 | ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 102 | ij_kotlin_parameter_annotation_wrap = off 103 | ij_kotlin_space_after_comma = true 104 | ij_kotlin_space_after_extend_colon = true 105 | ij_kotlin_space_after_type_colon = true 106 | ij_kotlin_space_before_catch_parentheses = true 107 | ij_kotlin_space_before_comma = false 108 | ij_kotlin_space_before_extend_colon = true 109 | ij_kotlin_space_before_for_parentheses = true 110 | ij_kotlin_space_before_if_parentheses = true 111 | ij_kotlin_space_before_lambda_arrow = true 112 | ij_kotlin_space_before_type_colon = false 113 | ij_kotlin_space_before_when_parentheses = true 114 | ij_kotlin_space_before_while_parentheses = true 115 | ij_kotlin_spaces_around_additive_operators = true 116 | ij_kotlin_spaces_around_assignment_operators = true 117 | ij_kotlin_spaces_around_equality_operators = true 118 | ij_kotlin_spaces_around_function_type_arrow = true 119 | ij_kotlin_spaces_around_logical_operators = true 120 | ij_kotlin_spaces_around_multiplicative_operators = true 121 | ij_kotlin_spaces_around_range = false 122 | ij_kotlin_spaces_around_relational_operators = true 123 | ij_kotlin_spaces_around_unary_operator = false 124 | ij_kotlin_spaces_around_when_arrow = true 125 | ij_kotlin_variable_annotation_wrap = off 126 | ij_kotlin_while_on_new_line = false 127 | ij_kotlin_wrap_elvis_expressions = 1 128 | ij_kotlin_wrap_expression_body_functions = 0 129 | ij_kotlin_wrap_first_method_in_call_chain = false 130 | kotlin_imports_layout = idea 131 | 132 | [{*.tfvars, *.tf}] 133 | indent_size = 2 134 | ij_hcl-terraform_align_property_on_equals = 2 135 | ij_hcl-terraform_align_property_on_value = 1 136 | ij_hcl-terraform_array_wrapping = 2 137 | ij_hcl-terraform_do_not_align_property = 0 138 | ij_hcl-terraform_keep_blank_lines_in_code = 2 139 | ij_hcl-terraform_keep_indents_on_empty_lines = false 140 | ij_hcl-terraform_keep_line_breaks = true 141 | ij_hcl-terraform_object_wrapping = 2 142 | ij_hcl-terraform_property_alignment = 0 143 | ij_hcl-terraform_property_line_commenter_character = 0 144 | ij_hcl-terraform_space_after_comma = true 145 | ij_hcl-terraform_space_before_comma = false 146 | ij_hcl-terraform_spaces_around_assignment_operators = true 147 | ij_hcl-terraform_spaces_within_braces = false 148 | ij_hcl-terraform_spaces_within_brackets = false 149 | ij_hcl-terraform_wrap_long_lines = false 150 | 151 | [{*.yml, *.yaml}] 152 | indent_size = 2 153 | ij_yaml_keep_indents_on_empty_lines = false 154 | ij_yaml_keep_line_breaks = true 155 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | --------------------------------------------------------------------------------