Microservices¶
The Munchies application is split up into multiple microservices, each with its own purpose:
Microservices Division¶
User Service¶
Stack: Kotlin + Micronaut.
Behavior¶
Microservice responsible for user management like:
- User authentication, registration, deletion.
- Updating user credentials and other information.
- Email verification.
Restaurant Service¶
Stack: Kotlin + Micronaut.
Behavior¶
Microservice responsible for restaurant management:
- Create new restaurants.
- Handle the menu of each restaurant:
- Create menu items.
- Create categories that organize each dish.
- Check for dish availability.
Order Service¶
Stack: Kotlin + Micronaut.
Behavior¶
Microservice responsible for managing orders:
- Create orders.
- Retrieve order information.
- Advance or discard their status.
Gateway Service¶
Stack: Express.js.
Behavior¶
The main entry point of the application. Routes every request to the right microservice, managing authentication via JWT.
Notification Service¶
Stack: Express.js.
Behavior¶
Microservice responsible for sending notifications to the user through the frontend.
Payment Service¶
Stack: Express.js.
Behavior¶
Microservice responsible for validation and processing of any payment on the platform, mainly for orders.
Scheduler Service¶
Stack: Express.js.
Behavior¶
Microservice responsible for delivery scheduling and logistics of every order.
Table Reservation Service - not implemented¶
Stack: Express.js.
Behavior¶
Microservice responsible for table reservations.
Frontend Service¶
Stack: Vue.js, using the Composition API.
Behavior¶
This microservice provides a user interface for the end user.
Microservice implementation¶
Each backend microservice implements the Hexagonal architecture described in the Domain Model:
Domain Layer¶
The domain layer contains entities, value objects, aggregates, factories. These are the building blocks for every business operation of the application.
// domain/model/User.kt
class User private constructor(
override val id: UserId,
val profile: UserProfile,
) : Entity<UserId>(id) {
fun updateEmailAsVerified(): User = User(this.id, this.profile.updateEmailAsVerified())
companion object {
// validates username/email/role before constructing a User
val factory: UserFactory = DefaultUserFactory()
}
}
// domain/model/UserProfile.kt
data class UserProfile(
val username: String,
val email: Email,
val role: UserRole,
)
Application Layer¶
This layer contains the business logic. Every operation is encapsulated in "use cases" that can return a result or an
error, handled by a result object. They are used by the controller of the microservice and are responsible for
validating input.
// application/port/inbound/RegisterUser.kt
interface RegisterUser {
fun execute(user: User, credentials: UserCredentials): RegisterUserResult
companion object {
sealed interface RegisterUserResult {
data class Success(val user: User) : RegisterUserResult
data object UserIsAlreadyRegistered : RegisterUserResult
data class Failure(val reason: String) : RegisterUserResult
}
}
}
// application/usecase/RegisterUserUseCase.kt
class RegisterUserUseCase(
private val userRepository: UserRepository,
private val credentialsRepository: UserCredentialsRepository,
private val hasher: PasswordHasher,
private val mailer: Mailer,
) : RegisterUser {
override fun execute(user: User, credentials: UserCredentials): RegisterUserResult {
return findUser(user)
?.let { RegisterUserResult.UserIsAlreadyRegistered }
?: try {
userRepository.save(user)
credentialsRepository.save(credentials.copy(id = user.id))
mailer.sendMail(user.profile.email.address, "...")
RegisterUserResult.Success(user)
} catch (e: kotlin.Error) {
RegisterUserResult.Failure(e.localizedMessage)
}
}
// findUser omitted...
}
Infrastructure Layer¶
Persistence¶
For persistence, each microservice has its own MongoDB database. The CRUD operations are implemented in the repository
subpackage. At the same time, to separate Mongo documents from the domain model we implemented the document package
that maps each domain object to a Mongo @MappedEntity.
// infrastructure/adapter/outbound/mongo/document/UserDocument.kt
@MappedEntity
data class UserDocument(
@field:Id val id: String,
val username: String,
val email: String,
val isVerified: Boolean,
val role: String,
)
@MongoRepository
sealed interface MongoCrudUserRepository : CrudRepository<UserDocument, String>
// infrastructure/adapter/outbound/mongo/repository/MongoUserRepository.kt
@Singleton
@Requires(env = ["prod"])
class MongoUserRepository(
private val repository: MongoCrudUserRepository,
) : UserRepository {
override fun findById(id: UserId): User? = repository.findById(id.value).map {
it.toNullableDomain()
}.orElse(null)
override fun save(entity: User) {
repository.save(entity.toDocument())
}
// update, delete, findByEmail, findByUsername omitted...
}
Events¶
Kafka producers implement a domain outbound port, so the application layer only ever depends on the port
interface, never on Kafka directly. In order-service, notification-service subscribes to order lifecycle
events published through OrderNotificationPublisher:
// domain/port/OrderNotificationPublisher.kt
interface OrderNotificationPublisher {
fun publishStatusChanged(order: Order)
}
// infrastructure/adapter/outbound/kafka/OrderStatusChangedKafkaClient.kt
@KafkaClient
interface OrderStatusChangedKafkaClient {
@Topic(OrderStatusChangedNotificationInfo.ORDER_STATUS_CHANGED_TOPIC)
fun publish(notification: String)
}
// infrastructure/adapter/outbound/kafka/KafkaOrderNotificationPublisher.kt
@Singleton
class KafkaOrderNotificationPublisher(
private val client: OrderStatusChangedKafkaClient,
) : OrderNotificationPublisher {
override fun publishStatusChanged(order: Order) {
client.publish(
OrderStatusChangedNotification(
order_id_key = order.id.value,
restaurant_id_key = order.restaurantId.value,
customer_id_key = order.customerId.value,
status_key = order.status.name,
).toJson(),
)
}
}
Dependency Injection¶
A Micronaut @Factory wires each use case to its concrete infrastructure dependencies, so the rest of the
application only ever injects the inbound port interface:
// infrastructure/adapter/inbound/web/config/UserBeans.kt
@Factory
class UserBeans {
@Singleton
fun registerUser(
userRepository: UserRepository,
userCredentialsRepository: UserCredentialsRepository,
hasher: PasswordHasher,
mailer: Mailer,
): RegisterUser = RegisterUserUseCase(userRepository, userCredentialsRepository, hasher, mailer)
// one @Singleton factory method per use case...
}
Interfaces Layer¶
This layer houses the engine of the microservice: the controller that receives REST calls, translates and delegates them to the inbound ports to the application layer:
// infrastructure/adapter/inbound/web/controller/MicronautUserController.kt
@Controller(value = UserServiceConfig.SERVICE_PATH)
class MicronautUserController(
private val services: UserServices,
) : UserAPI.GetUserAPI<HttpResponse<GetUserResponse>> {
private val getUser: GetUser = services.getUser
@Get(UserServiceConfig.GET_USER_PATH)
override fun getUser(@PathVariable id: String): HttpResponse<GetUserResponse> {
return when (val res = getUser.execute(UserId(id))) {
is GetUser.Companion.GetUserResult.Success -> HttpResponse.ok(
GetUserResponse(result = res.user.toDTO(), code = HttpStatus.OK.code),
)
GetUser.Companion.GetUserResult.NotFound -> throw NotFoundException("User not found")
}
}
// other endpoints follow the same pattern...
}
Micronaut lets us define an exception handler once and have it apply to every controller in the service:
// infrastructure/adapter/inbound/web/controller/ExceptionHandlers.kt
@Singleton
class NotFoundExceptionHandler : ExceptionHandler<NotFoundException, HttpResponse<ErrorResponse>> {
override fun handle(
request: HttpRequest<*>,
exception: NotFoundException,
): HttpResponse<ErrorResponse> {
return HttpResponse.status<ErrorResponse>(HttpStatus.NOT_FOUND)
.body(ErrorResponse(result = exception.message ?: "Resource not found", code = HttpStatus.NOT_FOUND.code))
}
}
Presentation Layer¶
To facilitate the controller's job, every DTO has its own mapper that converts it to a domain object through extension methods and vice versa:
// infrastructure/adapter/dto/factory/UserDTOFactory.kt
object UserDTOFactory {
fun User.toDTO(): UserDTO = UserDTO(
id = this.id.value,
username = this.profile.username,
email = this.profile.email.address,
role = this.profile.role.toString(),
isEmailVerified = this.profile.email.isVerified,
)
// toDomain omitted...
}
Configuration Layer¶
This layer contains the configuration files used to set up the application using Micronaut properties
or the application.yml file: