ΠΡΠΎ ΡΡΠ°ΡΡΠΎΠ²ΡΠΉ ΡΠ°Π±Π»ΠΎΠ½ Π΄Π»Ρ Π½ΠΎΠ²ΡΡ event-sourcing ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΉ Π½Π° ΠΠΎΡΠ»ΠΈΠ½.
Π‘ΡΠ°ΡΡΠΎΠ²ΡΠΉ ΡΠ°Π±Π»ΠΎΠ½ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ Event sourcing: https://github.com/stepin/kotlin-event-sourcing-app
Π Π·Π°ΠΌΠ΅ΡΠΊΠ΅ "ΠΠ»Π°ΡΡΠΈΡΠ΅ΡΠΊΠΈΠΉ event sourcing" ΡΠ°Π·ΠΎΠ±ΡΠ°Π½Ρ ΠΎΡΠ½ΠΎΠ²Ρ, Π² "Inline event sourcing" ΡΠ°Π·ΠΎΠ±ΡΠ°Π½Π° Π°ΡΡ ΠΈΡΠ΅ΠΊΡΡΡΠ° ΡΡΠΎΠ³ΠΎ ΡΠ°Π±Π»ΠΎΠ½Π°.
ΠΠ°Π½Π½ΡΠΉ ΡΠ΅ΠΏΠΎΠ·ΠΈΡΠΎΡΠΈΠΉ ΠΏΡΠ΅Π΄ΡΡΠ°Π²Π»ΡΠ΅Ρ ΠΈΠ· ΡΠ΅Π±Ρ ΠΏΡΠΈΠΌΠ΅Ρ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ, Π° Π½Π΅ ΠΎΡΠ΄Π΅Π»ΡΠ½ΡΠΉ Π΄Π²ΠΈΠΆΠΎΠΊ. ΠΠΎΠΊΠ° ΡΡΠΎ Ρ ΠΌΠ΅Π½Ρ Π½Π΅Ρ ΡΠ²Π΅ΡΠ΅Π½Π½ΠΎΡΡΠΈ, ΡΡΠΎ ΡΡΠΎΡ Π΄Π²ΠΈΠΆΠΎΠΊ ΠΌΠΎΠΆΠ½ΠΎ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΡ "ΠΊΠ°ΠΊ Π΅ΡΡΡ" Π² Π΄ΡΡΠ³ΠΈΡ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡΡ . ΠΡΠΈ ΡΡΠΎΠΌ Π΅ΡΡΡ ΡΠ²Π΅ΡΠ΅Π½Π½ΠΎΡΡΡ, ΡΡΠΎ Π½Π°ΡΠ°Π² Ρ ΡΡΠΎΠ³ΠΎ ΡΠ°Π±Π»ΠΎΠ½Π°, Π²ΠΏΠΎΠ»Π½Π΅ ΡΠ΅Π°Π»ΡΠ½ΠΎ ΡΠ°Π·Π²ΠΈΠ²Π°ΡΡ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ.
ΠΠ°Π½Π½ΡΠΉ ΠΏΡΠΎΠ΅ΠΊΡ ΡΠ²Π»ΡΠ΅ΡΡΡ ΠΈΠ·Π²Π»Π΅ΡΠ΅Π½ΠΈΠ΅ΠΌ ΠΎΠ±ΡΠ΅ΠΉ ΡΠ°ΡΡΠΈ ΠΈΠ· ΠΎΠ΄Π½ΠΎΠ³ΠΎ ΠΈΠ· ΠΌΠΎΠΈΡ Π»ΠΈΡΠ½ΡΡ ΠΏΡΠΎΠ΅ΠΊΡΠΎΠ². ΠΡΠΎ ΡΠΆΠ΅ Π³Π΄Π΅-ΡΠΎ 5Π°Ρ Π²Π΅ΡΡΠΈΡ Π΄Π²ΠΈΠΆΠΊΠ° (ΠΏΠ΅ΡΠ²Π°Ρ Π²ΠΎΠΎΠ±ΡΠ΅ Π±ΡΠ»Π° Π½Π° Golang). ΠΡΠΈ ΡΡΠΎΠΌ Π²Π΅ΡΡΠΈΡ Π½ΠΎΠ²Π°Ρ -- Π²ΠΎΠ·ΠΌΠΎΠΆΠ½Ρ ΠΊΠ°ΠΊΠΈΠ΅-ΡΠΎ ΡΠ΅ΡΠΎΡ ΠΎΠ²Π°ΡΠΎΡΡΠΈ ΠΏΠ΅ΡΠ²ΠΎΠ΅ Π²ΡΠ΅ΠΌΡ.
Π¨Π°Π±Π»ΠΎΠ½ ΠΎΡΠ½ΠΎΠ²ΡΠ²Π°Π΅ΡΡΡ Π½Π° ΠΌΠΎΠ΅ΠΌ Π±Π°Π·ΠΎΠ²ΠΎΠΌ ΡΠ°Π±Π»ΠΎΠ½Π΅ ΠΠΎΡΠ»ΠΈΠ½-ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΉ: https://github.com/stepin/kotlin-bootstrap-app
ΠΠ°ΡΠΈΠ½Π°Π΅ΠΌ Ρ Π²ΡΡΠ²Π»Π΅Π½ΠΈΡ ΡΠΎΠ±ΡΡΠΈΠΉ ΠΈ ΡΡΡΠ½ΠΎΡΡΠ΅ΠΉ.
ΠΠΎΠΏΡΡΡΠΈΠΌ, Ρ Π½Π°Ρ Π΅ΡΡΡ ΠΏΡΠΎΡΡΠ°Ρ Π±ΠΈΠ·Π½Π΅Ρ-ΡΡΡΠ½ΠΎΡΡΡ ΠΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ:
data class User(
val displayName: String,
val firstName: String,
val seconfName: String,
val email: String
)
Π ΠΌΡ Ρ ΠΎΡΠΈΠΌ ΠΏΠΎΠ΄Π΄Π΅ΡΠΆΠ°ΡΡ ΡΠ»Π΅Π΄ΡΡΡΠΈΠ΅ ΡΡΠ΅Π½Π°ΡΠΈΠΈ (ΡΠΎΠ±ΡΡΠΈΡ):
- ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ
- ΡΠΌΠ΅Π½Π° ΠΈΠΌΠ΅Π½ΠΈ
- ΡΠ΄Π°Π»Π΅Π½ΠΈΠ΅ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ
ΠΠ»Ρ ΠΏΡΠΎΡΡΠΎΡΡ ΠΏΡΠΈΠΌΠ΅ΡΠ° Π½Π΅ Π±ΡΠ΄Π΅ΠΌ ΠΎΠ±ΡΠ°ΡΠ°ΡΡ Π²Π½ΠΈΠΌΠ°Π½ΠΈΠ΅ Π½Π° ΠΏΠΎΠ΄ΡΠ²Π΅ΡΠΆΠ΄Π΅Π½ΠΈΡ ΠΈ Π°Π²ΡΠΎΡΠΈΠ·Π°ΡΠΈΡ.
ΠΡΠΈΠΌΠ΅Ρ ΡΠΎΠ±ΡΡΠΈΡ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΈ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ:
data class UserRegistered(
val email: String,
val firstName: String?,
val secondName: String?,
val displayName: String,
override val accountGuid: AccountGuid,
override val aggregatorGuid: UserGuid = UUID.randomUUID(),
override val guid: EventGuid = UUID.randomUUID(),
) : UserEvent(eventTypeVersion = 3)
- 4 ΠΎΡΠ½ΠΎΠ²Π½ΡΡ ΠΏΠΎΠ»Ρ: email, firstName, secondName, displayName
- guid ΡΠ°ΠΌΠΎΠ³ΠΎ ΡΠΎΠ±ΡΡΠΈΠΉ (ΡΠ°Π½Π΄ΠΎΠΌΠ½ΡΠΉ)
- aggregator guid = user guid -- Π²ΠΎΡ ΡΡΠΎ Π½Π΅ΡΠ΄ΠΎΠ±Π½ΠΎ, ΡΡΠΎ Π½Π΅Ρ ΡΠΈΠ½ΠΎΠ½ΠΈΠΌΠ°, Π½ΠΎ ΠΌΠΎΠΆΠ½ΠΎ ΠΏΡΠΈΠ²ΡΠΊΠ½ΡΡΡ (ΠΈ ΡΠΊΠ°Π·Π°Π½ typealias UserGuid)
- account guid -- Π΄Π²ΠΈΠΆΠΎΠΊ ΡΠ°ΡΡΠΈΡΠ°Π½ Π½Π° ΠΌΡΠ»ΡΡΠΈΠ°ΠΊΠΊΠ°Π½ΡΠΎΠ²ΡΠ΅ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ
- data class -- ΡΠ΄ΠΎΠ±Π½ΠΎ. Π Π΅ΡΠ΅ ΡΠ΄ΠΎΠ±Π½Π΅Π΅, ΡΡΠΎ UserEvent -- sealed ΠΊΠ»Π°ΡΡ, ΠΌΠΎΠΆΠ½ΠΎ ΡΠ°ΠΊΠΈΠ΅ ΠΊΠΎΠ½ΡΡΡΡΠΊΡΠΈΠΈ Π΄Π΅Π»Π°ΡΡ:
when (val e = event as UserEvent) {
is UserMetaUpdated -> "updated $e"
is UserRegistered -> "user registered with id $id ${meta.createdAt} $e"
is UserRemoved -> "user ${e.email} deleted at ${meta.createdAt}"
}
ΠΠ°Π·ΠΎΠ²ΡΠΉ ΠΊΠ»Π°ΡΡ Π΄Π»Ρ ΡΠΎΠ±ΡΡΠΈΠΉ Π°Π³ΡΠ΅Π³Π°ΡΠ° User Π²ΡΠ³Π»ΡΠ΄ΠΈΡ ΡΠ°ΠΊ:
sealed class UserEvent(
override val eventTypeVersion: Short = 0,
) : DomainEvent {
override val aggregatorType: String
get() = "user"
override val eventType: String
get() = this.javaClass.simpleName
abstract override val aggregatorGuid: UserGuid
}
- ΡΠ΅Π°Π»ΠΈΠ·ΡΠ΅ΡΡΡ ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡ DomainEvent Π΄Π²ΠΈΠΆΠΊΠ°
- Π²ΡΡΡΠ°Π²Π»ΡΠ΅ΡΡΡ typealias UserGuid Π΄Π»Ρ aggregatorGuid -- Π½Π΅ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»ΡΠ½ΠΎ, ΠΊΠ°ΠΊ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠ°ΡΠΈΡ
- Π²ΡΡΡΠ°Π²Π»ΡΠ΅ΡΡΡ ΡΠΈΠΏ Π°Π³ΡΠ΅Π³Π°ΡΠ°
- Π²ΡΡΡΠ°Π²Π»ΡΠ΅ΡΡΡ ΡΠΈΠΏ ΡΠΎΠ±ΡΡΠΈΡ -- Π°Π²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΈ Π±Π΅ΡΠ΅ΡΡΡ ΠΈΠΌΡ ΠΊΠ»Π°ΡΡΠ° ΡΠΎΠ±ΡΡΠΈΡ (Π½Π°ΠΏΡΠΈΠΌΠ΅Ρ, UserRegistered)
- Π²ΡΡΡΠ°Π²Π»ΡΠ΅ΡΡΡ Π²Π΅ΡΡΠΈΡ ΡΠΎΠ±ΡΡΠΈΡ Π² 0 ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ, Π½ΠΎ ΡΡΠΎ Π·Π½Π°ΡΠ΅Π½ΠΈΠ΅ ΡΠΎΠ±ΡΡΠΈΠ΅ ΠΌΠΎΠΆΠ΅Ρ ΠΏΠ΅ΡΠ΅ΠΎΠΏΡΠ΅Π΄Π΅Π»ΠΈΡΡ
ΠΠΎ ΡΡΡΠΈ, ΠΎΡ ΡΠΎΠ±ΡΡΠΈΡ Π΄Π²ΠΈΠΆΠΎΠΊ ΡΡΠ΅Π±ΡΠ΅Ρ 2 Π²Π΅ΡΠΈ:
- ΡΠ΅Π°Π»ΠΈΠ·Π°ΡΠΈΠΈ ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡΠ° DomainEvent
- ΠΊΠΎΡΡΠ΅ΠΊΡΠ½ΠΎΠΉ ΡΠ΅ΡΠΈΠ°Π»ΠΈΠ·Π°ΡΠΈΠΈ ΠΈ Π΄Π΅ΡΠ΅ΡΠΈΠ°Π»ΠΈΠ·Π°ΡΠΈΠΈ JSONB
ΠΡΡΠ°Π»ΡΠ½ΠΎΠ΅ Π½Π° ΡΡΠΌΠΎΡΡΠ΅Π½ΠΈΠ΅ ΡΠ°Π·ΡΠ°Π±ΠΎΡΡΠΈΠΊΠ°. ΠΡΠΈ ΡΡΠΎΠΌ Π±Π°Π·ΠΎΠ²ΡΠΉ ΠΊΠ»Π°ΡΡ Π΄Π»Ρ Π²ΡΠ΅Ρ ΡΠΎΠ±ΡΡΠΈΠΉ Π°Π³ΡΠ΅Π³Π°ΡΠ° ΡΡΠΈΡΠ°Π΅ΡΡΡ Ρ ΠΎΡΠΎΡΠ΅ΠΉ ΠΏΡΠ°ΠΊΡΠΈΠΊΠΎΠΉ.
ΠΡΠΎ id/guid: Π² ΡΡΠΎΠΌ ΠΏΡΠΈΠΌΠ΅ΡΠ΅ ΠΏΠΎΠ΄ΡΠ°Π·ΡΠΌΠ΅Π²Π°Π΅ΡΡΡ, ΡΡΠΎ ΠΊΠΎΠΌΠ°Π½Π΄Ρ ΡΠ°Π±ΠΎΡΠ°ΡΡ Ρ guid, Π° ΠΏΡΠΈ Π½Π΅ΠΎΠ±Ρ ΠΎΠ΄ΠΈΠΌΠΎΡΡΠΈ join Π² SQL-Π·Π°ΠΏΡΠΎΡΠ°Ρ ΠΈΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΡΡΡ id (Ρ.ΠΊ. Π±ΡΡΡΡΠ΅Π΅).
Π£ Π½Π°Ρ ΠΊΠΎΠΌΠ°Π½Π΄Π° -- ΡΡΠΎ Π»ΠΈΠ±ΠΎ ΠΎΡΠ΄Π΅Π»ΡΠ½ΡΠΉ Spring-ΡΠ΅ΡΠ²ΠΈΡ, Π»ΠΈΠ±ΠΎ ΠΌΠ΅ΡΠΎΠ΄ Π²Π½ΡΡΡΠΈ Spring-ΡΠ΅ΡΠ²ΠΈΡΠ°. ΠΠΎ ΡΡΡΠΈ Π΅Π΄ΠΈΠ½ΡΡΠ²Π΅Π½Π½ΡΠΉ ΠΊΡΠΈΡΠΈΡΠ½ΡΠΉ ΠΌΠΎΠΌΠ΅Π½Ρ -- Π΄ΠΎΠ»ΠΆΠ΅Π½ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΡΡΡ ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡ EventStorePublish
Π΄Π»Ρ ΠΏΡΠ±Π»ΠΈΠΊΠ°ΡΠΈΠΈ ΡΠΎΠ±ΡΡΠΈΠΉ, Π° ΠΎΡΡΠ°Π»ΡΠ½ΠΎΠ΅ Π΄Π²ΠΈΠΆΠΎΠΊ Π½Π΅ ΠΎΠ³ΡΠ°Π½ΠΈΡΠΈΠ²Π°Π΅Ρ.
ΠΠΎΠΌΠ°Π½Π΄Π° ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΈ:
@Service
class RegisterUser(
private val store: EventStorePublisher,
private val userRepository: UserRepository,
) {
data class Params(
val email: String,
val firstName: String?,
val secondName: String?,
val displayName: String?,
)
sealed class Response {
data class Created(val userGuid: UUID) : Response()
data class Error(val errorCode: ErrorCode) : Response()
}
suspend fun execute(params: Params): Response = with(params) {
val user = userRepository.findByEmail(email)
if (user != null) {
return Response.Error(ErrorCode.USER_ALREADY_REGISTERED)
}
val accountGuid = UUID.randomUUID()
val userGuid = UUID.randomUUID()
val userRegistered = UserRegistered(
accountGuid = accountGuid,
aggregatorGuid = userGuid,
email = email,
firstName = firstName,
secondName = secondName,
displayName = displayName ?: calcDisplayName(email, firstName, secondName),
)
store.publish(userRegistered)
val accountCreated = AccountCreated(
name = "ΠΠ΅ΠΈΠ·Π²Π΅ΡΡΠ½Π°Ρ ΠΊΠΎΠΌΠΏΠ°Π½ΠΈΡ",
accountGuid = accountGuid,
userGuid = userGuid,
)
store.publish(accountCreated)
return Response.Created(userGuid)
}
}
ΠΠΎΠ·Π²ΡΠ°ΡΠ°Π΅ΠΌΡΠ΅ ΠΎΡ ΠΊΠΎΠΌΠ°Π½Π΄ Π·Π½Π°ΡΠ΅Π½ΠΈΡ Π·Π°Π²ΠΈΡΡΡ ΠΎΡ Π±ΠΈΠ·Π½Π΅Ρ-Π»ΠΎΠ³ΠΈΠΊΠΈ: ΠΌΠΎΠ³ΡΡ Π»ΠΈ Π±ΡΡΡ Π±ΠΈΠ·Π½Π΅Ρ-ΠΎΡΠΈΠ±ΠΊΠΈ, Π½ΡΠΆΠ½ΠΎ Π»ΠΈ Π²Π΅ΡΠ½ΡΡΡ guid ΠΈ Ρ.ΠΏ. Π ΠΊΠ°ΠΊΠΈΡ -ΡΠΎ ΡΠ»ΡΡΠ°ΡΡ ΠΌΠΎΠΆΠ΅Ρ Π½ΠΈΡΠ΅Π³ΠΎ Π½Π΅ Π²ΠΎΠ·Π²ΡΠ°ΡΠ°ΡΡΡΡ.
ΠΡΠΈΠΌΠ΅Ρ 2-Ρ ΠΏΡΠΎΠ΅ΠΊΡΠΎΡΠΎΠ² Π² ΠΎΠ΄Π½ΠΎΠΌ ΠΊΠ»Π°ΡΡΠ΅:
@Service
class UserProjector(
private val userRepository: UserRepository,
private val accountRepository: AccountRepository,
) {
companion object : Logging
@Projector
suspend fun handleUserRegistered(e: UserRegistered, meta: EventMetadata) {
val account = accountRepository.findByGuid(e.accountGuid)
val u = UserEntity()
u.accountGuid = e.accountGuid
u.accountId = account?.id ?: 0
u.guid = e.aggregatorGuid
u.email = e.email
u.displayName = e.displayName
u.firstName = e.firstName
u.secondName = e.secondName
u.createdAt = meta.createdAt.toInstant(ZoneOffset.UTC)
val savedUser = userRepository.save(u)
logger.debug { "new user id: ${savedUser.id}" }
}
@Projector
suspend fun handleUserRemoved(e: UserRemoved) {
val user = getUser(e.aggregatorGuid)
userRepository.delete(user)
}
private suspend fun getUser(userGuid: UUID) = userRepository.findByGuid(userGuid)
?: throw DomainException(ErrorCode.USER_NOT_FOUND)
}
- ΠΌΠ΅ΡΠΎΠ΄ ΠΏΡΠΎΠ΅ΠΊΡΠΎΡΠ° Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±ΡΡΡ Π² Spring-Π±ΠΈΠ½Π΅
- Π΄ΠΎΠ»ΠΆΠ½Π° Π±ΡΡΡ Π°Π½Π½ΠΎΡΠ°ΡΠΈΡ @Projector
- Π² ΠΊΠ»Π°ΡΡΠ΅ ΠΌΠΎΠΆΠ΅Ρ Π±ΡΡΡ Π½Π΅ΡΠΊΠΎΠ»ΡΠΊΠΎ ΠΌΠ΅ΡΠΎΠ΄ΠΎΠ² -- ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½ΠΈΠΉ Π½Π΅Ρ
- ΠΏΠ΅ΡΠ²ΡΠΉ Π°ΡΠ³ΡΠΌΠ΅Π½Ρ -- ΡΠΎΠ±ΡΡΠΈΠ΅
- Π²ΡΠΎΡΠΎΠΉ (ΠΎΠΏΡΠΈΠΎΠ½Π°Π»ΡΠ½ΠΎ) -- ΠΌΠ΅ΡΠ°Π΄Π°Π½Π½ΡΠ΅ ΡΠΎΠ±ΡΡΠΈΡ
- ΠΌΠ΅ΡΠΎΠ΄ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±ΡΡΡ suspend (Π² ΠΏΡΠΈΠ½ΡΠΈΠΏΠ΅, ΡΡΠΎ ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½ΠΈΠ΅ ΠΌΠΎΠΆΠ½ΠΎ ΡΠ½ΡΡΡ, Π½ΠΎ ΡΠ΅ΠΉΡΠ°Ρ ΡΠ°ΠΊ Π² Π΄Π²ΠΈΠΆΠΊΠ΅ ΠΈ Π½Π΅ ΠΏΠ»Π°Π½ΠΈΡΡΡ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΡ Π½Π΅ suspend-ΠΌΠ΅ΡΠΎΠ΄Ρ)
- ΠΈΡΠΊΠ»ΡΡΠ΅Π½ΠΈΠ΅ Π² ΠΏΡΠΎΠ΅ΠΊΡΠΎΡΠ΅ ΠΎΡΠΌΠ΅Π½ΠΈΡ ΡΠΎΡ ΡΠ°Π½Π΅Π½ΠΈΠ΅ ΡΠΎΠ±ΡΡΠΈΡ
@Service
class UserRegisteredEmailReactor(
private val emailService: SendEmailService,
) {
companion object : Logging
@Reactor
suspend fun handle(e: UserRegistered) {
emailService.sendEmailConfirmationEmail(e.displayName, e.email, e.aggregatorGuid.toString())
}
}
- ΠΌΠ΅ΡΠΎΠ΄ ΠΏΡΠΎΠ΅ΠΊΡΠΎΡΠ° Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±ΡΡΡ Π² Spring-Π±ΠΈΠ½Π΅
- Π΄ΠΎΠ»ΠΆΠ½Π° Π±ΡΡΡ Π°Π½Π½ΠΎΡΠ°ΡΠΈΡ @Reactor
- Π² ΠΊΠ»Π°ΡΡΠ΅ ΠΌΠΎΠΆΠ΅Ρ Π±ΡΡΡ Π½Π΅ΡΠΊΠΎΠ»ΡΠΊΠΎ ΠΌΠ΅ΡΠΎΠ΄ΠΎΠ² -- ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½ΠΈΠΉ Π½Π΅Ρ
- ΠΏΠ΅ΡΠ²ΡΠΉ Π°ΡΠ³ΡΠΌΠ΅Π½Ρ -- ΡΠΎΠ±ΡΡΠΈΠ΅
- Π²ΡΠΎΡΠΎΠΉ (ΠΎΠΏΡΠΈΠΎΠ½Π°Π»ΡΠ½ΠΎ) -- ΠΌΠ΅ΡΠ°Π΄Π°Π½Π½ΡΠ΅ ΡΠΎΠ±ΡΡΠΈΡ
- ΠΌΠ΅ΡΠΎΠ΄ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±ΡΡΡ suspend (Π² ΠΏΡΠΈΠ½ΡΠΈΠΏΠ΅, ΡΡΠΎ ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½ΠΈΠ΅ ΠΌΠΎΠΆΠ½ΠΎ ΡΠ½ΡΡΡ, Π½ΠΎ ΡΠ΅ΠΉΡΠ°Ρ ΡΠ°ΠΊ Π² Π΄Π²ΠΈΠΆΠΊΠ΅ ΠΈ Π½Π΅ ΠΏΠ»Π°Π½ΠΈΡΡΡ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΡ Π½Π΅ suspend-ΠΌΠ΅ΡΠΎΠ΄Ρ)
- ΠΈΡΠΊΠ»ΡΡΠ΅Π½ΠΈΠ΅ Π² ΡΠ΅Π°ΠΊΡΠΎΡΠ΅ ΠΠ ΠΎΡΠΌΠ΅Π½ΠΈΡ ΡΠΎΡ ΡΠ°Π½Π΅Π½ΠΈΠ΅ ΡΠΎΠ±ΡΡΠΈΡ ΠΈ Π·Π°ΠΏΡΡΠΊ Π΄ΡΡΠ³ΠΈΡ ΡΠ΅Π°ΠΊΡΠΎΡΠΎΠ²
Π§ΡΠ΅Π½ΠΈΠ΅ Π΄Π°Π½Π½ΡΡ ΠΎΡΠ½ΠΎΠ²Π½ΠΎΠΉ ΠΏΡΠΎΠ΅ΠΊΡΠΈΠΈ -- Π½ΠΈΠΊΠ°ΠΊΠΈΡ ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½ΠΈΠΉ, ΠΊΠ°ΠΊ ΠΎΠ±ΡΡΠ½ΠΎ.
Π’Π°ΠΊ ΠΆΠ΅ Π΄ΠΎΡΡΡΠΏΠ½ΠΎ ΡΡΠ΅Π½ΠΈΠ΅ ΡΠΎΠ±ΡΡΠΈΠΉ:
interface EventStoreReader {
fun <T : DomainEvent> findEventsSinceId(
eventIdFrom: Long,
aggregator: String? = null,
aggregatorGuid: UUID? = null,
accountGuid: AccountGuid? = null,
eventTypes: List<String>? = null,
maxBatchSize: Int? = null,
): Flow<DomainEventWithIdAndMeta<T>>
fun <T : DomainEvent> findEventsSinceGuid(
eventGuidFrom: UUID,
aggregator: String? = null,
aggregatorGuid: UUID? = null,
accountGuid: AccountGuid? = null,
eventTypes: List<String>? = null,
maxBatchSize: Int? = null,
): Flow<DomainEventWithIdAndMeta<T>>
fun <T : DomainEvent> findEventsSinceDate(
date: LocalDateTime,
aggregator: String? = null,
aggregatorGuid: UUID? = null,
accountGuid: AccountGuid? = null,
eventTypes: List<String>? = null,
maxBatchSize: Int? = null,
): Flow<DomainEventWithIdAndMeta<T>>
fun <T : DomainEvent> findEvents(
aggregator: String? = null,
aggregatorGuid: UUID? = null,
accountGuid: AccountGuid? = null,
eventTypes: List<String>? = null,
maxBatchSize: Int? = null,
): Flow<DomainEventWithIdAndMeta<T>>
}
ΠΡΠΎ API ΠΌΠΎΠΆΠ½ΠΎ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΡ Π΄Π»Ρ ΠΏΠΎΠ»ΡΡΠ΅Π½ΠΈΡ ΠΈΡΡΠΎΡΠΈΠΈ ΠΈΠ»ΠΈ Π΄Π»Ρ ΡΠΎΠ·Π΄Π°Π½ΠΈΡ Π°ΡΠΈΠ½Ρ ΡΠΎΠ½Π½ΡΡ ΠΏΡΠΎΠ΅ΠΊΡΠΈΠΉ.
ΠΠΎΡΠ΅Π½ΡΠΈΠ°Π»ΡΠ½ΠΎ ΠΌΠΎΠΆΠ½ΠΎ Π½Π°ΠΏΠΈΡΠ°ΡΡ ΠΈ ΡΠ²ΠΎΠ΅ API ΡΡΠ΅Π½ΠΈΡ ΡΠΎΠ±ΡΡΠΈΠΉ, Π² jOOQ Π²ΡΠ΅ Π΄Π»Ρ ΡΡΠΎΠ³ΠΎ Π΅ΡΡΡ.
Π’Π°ΠΊ ΠΆΠ΅ ΠΌΠΎΠΆΠ½ΠΎ Π΄Π΅Π»Π°ΡΡ ΠΏΠΎΠ»Π½ΡΡ ΠΈΠ»ΠΈ ΡΠ°ΡΡΠΈΡΠ½ΡΡ ΠΏΠ΅ΡΠ΅Π³Π΅Π½Π΅ΡΠ°ΡΠΈΡ Π±Π°Π·Ρ (Π°ΡΠ³ΡΠΌΠ΅Π½ΡΡ ΡΡΠ°ΡΡΠ° ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ ΠΈΠ»ΠΈ ΠΊΠ°ΡΡΠΎΠΌΠ½ΡΠΉ ΠΊΠΎΠ΄).
ΠΡΠΈΠΌΠ΅Ρ ΠΏΠΎΠ»ΡΡΠ΅Π½ΠΈΡ ΠΈΡΡΠΎΡΠΈΠΈ (Π΅ΡΡΠ΅ΡΡΠ²Π΅Π½Π½ΠΎ, ΠΌΠΎΠΆΠ½ΠΎ ΡΠΌΠ΅ΡΠΈΠ²Π°ΡΡ ΡΡΠ΅Π½ΠΈΠ΅ ΠΈΠ· ΡΠΎΠ±ΡΡΠΈΠΉ ΠΈ ΠΈΠ· ΠΎΡΠ½ΠΎΠ²Π½ΠΎΠΉ ΠΏΡΠΎΠ΅ΠΊΡΠΈΠΈ, Ρ.ΠΊ. ΡΡΠΎ Π²ΡΠ΅ Π² Π΄Π°ΠΆΠ΅ ΠΎΠ΄Π½ΠΎΠΉ Π±Π°Π·Π΅):
@Service
class DebugService(
private val eventStoreReader: EventStoreReader,
) {
suspend fun getUserAudit(userGuid: UUID): List<String> {
return eventStoreReader.findEvents<UserEvent>("user", userGuid, maxBatchSize = 100)
.map { (id, event, meta) ->
when (event) {
is UserMetaUpdated -> "updated $event"
is UserRegistered -> "user registered with id $id ${meta.createdAt} $event"
is UserRemoved -> "user deleted at ${meta.createdAt}"
}
}
}
}
Π’ΡΡ Π² API Π½Π΅ΠΌΠ½ΠΎΠ³ΠΎ Π½Π΅ΠΊΡΠ°ΡΠΈΠ²ΠΎ -- Π½Π΅Ρ ΡΠ²ΡΠ·ΠΈ "user" ΠΈ UserEvent. ΠΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎ, ΠΈΠΌΠ΅Π΅Ρ ΡΠΌΡΡΠ» ΠΏΠ΅ΡΠ΅Π΄Π°Π²Π°ΡΡ Π±Π°Π·ΠΎΠ²ΡΠΉ ΠΊΠ»Π°ΡΡ, Π½ΠΎ ΠΎΠ½ Π°Π±ΡΡΡΠ°ΠΊΡΠ½ΡΠΉ. ΠΡΠ»ΠΈ Ρ ΠΊΠΎΠ³ΠΎ-ΡΠΎ Π΅ΡΡΡ ΠΈΠ΄Π΅ΠΈ ΠΊΠ°ΠΊ Π»ΡΡΡΠ΅ ΡΠ΄Π΅Π»Π°ΡΡ API (Π±Π΅Π· ΡΡΡΠΎΡΠΊΠΈ "user" ΠΈ Π±Π΅Π· ΠΏΡΠΈΠ²Π΅Π΄Π΅Π½ΠΈΡ "as UserEvent") -- Π±ΡΠ΄ΡΡ ΡΠ°Π΄ ΠΏΡΠΎΡΠΈΡΠ°ΡΡ.
- Π Π΄Π°Π½Π½ΠΎΠΉ ΡΠ΅Π°Π»ΠΈΠ·Π°ΡΠΈΠΈ Event Bus Π½Π΅ Π²Π½Π΅Π΄ΡΠ΅Π½ (Π΄Π»Ρ ΡΡΠ°Π½ΡΠ»ΡΡΠΈΠΈ ΡΠΎΠ±ΡΡΠΈΠΉ ΡΠ΅ΡΠ΅Π· ΠΊΠ°ΠΊΡΡ-Π½ΠΈΠ±ΡΠ΄Ρ ΠΠ°ΡΠΊΡ ΠΈΠ»ΠΈ NATS), Π½ΠΎ Π½ΠΈΡΠ΅Π³ΠΎ Π½Π΅ ΠΌΠ΅ΡΠ°Π΅Ρ ΡΠ°ΠΊΠΎΠ΅ ΠΏΡΠΈΠΊΡΡΡΠΈΡΡ, Π΅ΡΠ»ΠΈ ΠΊΠΎΠΌΡ-Π½ΠΈΠ±ΡΠ΄Ρ Π±ΡΠ΄Π΅Ρ Π½ΡΠΆΠ½ΠΎ.
ΠΠΎΠ΄Π° Π½Π΅ΠΌΠ½ΠΎΠ³ΠΎ Π±ΠΎΠ»ΡΡΠ΅ Π·Π° ΡΡΠ΅Ρ Π²ΡΠ΄Π΅Π»Π΅Π½ΠΈΡ ΠΎΡΠ΄Π΅Π»ΡΠ½ΠΎΠΉ Π°Π±ΡΡΡΠ°ΠΊΡΠΈΠΈ -- Π‘ΠΎΠ±ΡΡΠΈΠ΅. Π’Π°ΠΊ ΠΆΠ΅ Π²ΡΠ΅ΠΌΡ ΡΡ ΠΎΠ΄ΠΈΡ Π½Π° ΡΠ°ΠΌΡ Π°Π±ΡΡΡΠ°ΠΊΡΠΈΡ -- Π½Π°Π·Π²Π°ΡΡ, Π²ΡΠ΄Π΅Π»ΠΈΡΡ ΠΏΠΎΠ»Ρ ΠΈ Ρ.ΠΏ.
ΠΠ»Ρ CRUD ΠΏΠΎΠ»ΡΡΠ°Π΅ΡΡΡ Π±ΠΎΠ»ΡΡΠ΅ ΠΊΠΎΠ΄Π°, Π½ΠΎ ΠΊΡΡΠ΄Π° Π½Π΅ ΡΠ°ΠΊ ΠΌΠ½ΠΎΠ³ΠΎ ΠΊΠ°ΠΊ ΠΌΠΎΠΆΠ΅Ρ ΠΏΠΎΠΊΠ°Π·Π°ΡΡΡΡ -- Π½ΡΠΆΠ½ΠΎ ΠΏΡΠΈΡΡΠΈΡΡ ΡΠ΅Π±Ρ Π΄ΡΠΌΠ°ΡΡ Π² ΡΠΎΠ±ΡΡΠΈΡΡ Π±ΠΈΠ·Π½Π΅Ρ-ΠΎΠ±Π»Π°ΡΡΠΈ, Π° Π½Π΅ ΡΠΎΠ·Π΄Π°ΡΡ/ΡΠ΄Π°Π»ΠΈΡΡ Π·Π°ΠΏΠΈΡΡ Π² ΡΠ°Π±Π»ΠΈΡΠ΅ Π±Π°Π·Ρ Π΄Π°Π½Π½ΡΡ .
Π ΡΠ΅Π»ΠΎΠΌ, ΠΌΠ½Π΅ Π½ΡΠ°Π²ΠΈΡΡΡ, ΠΏΠΎΡΡΠΎΠΌΡ ΠΈ ΡΠ΅ΡΠΈΠ» ΠΏΠΎΠ΄Π΅Π»ΠΈΡΡΡΡ Ρ ΡΠΎΠΎΠ±ΡΠ΅ΡΡΠ²ΠΎΠΌ.
- Kotlin 1.8
- Spring Boot 3 (reactive with Kotlin co-routines)
- Spring Data Repositories & jOOQ
- JUnit 5 with mockk
- Java 17
- Postgres
- Docker
- App: http://localhost:8080/
- Dev UI: http://localhost:8081/actuator
- Swagger spec json: http://localhost:8080/v3/api-docs
- Swagger spec yaml: http://localhost:8080/v3/api-docs.yaml
- Swagger UI: http://localhost:8080/swagger-ui.html
- GraphQL endpoint: http://localhost:8080/graphql/
- GraphQL schema: http://localhost:8080/graphql/schema.graphql
- GraphQL UI: http://localhost:8080/graphiql
- Health liveness: http://localhost:8081/actuator/health/liveness
- Health readiness: http://localhost:8081/actuator/health/readiness
- Generic metrics: http://localhost:8081/actuator/metrics/disk.free
- Prometheus metrics: http://localhost:8081/actuator/prometheus
- Config props: http://localhost:8081/actuator/configprops
- Env variables: http://localhost:8081/actuator/env
- Log settings: http://localhost:8081/actuator/loggers
- DB migrations info: http://localhost:8081/actuator/flyway
You can run your application in dev mode that enables live coding using:
./bin/start-postgres
./bin/generate-flyway
./bin/generate-jooq
./bin/run-dev
The application can be packaged using:
./bin/build-docker