User and Group Service implementation details
This chapter provides an overview of the implementation details of the User and Group Service.
High level overview
The User and Group Service is responsible for managing the users and groups of the system. It is a core microservice that supplies user and group data to other services and is composed of the following parts:
Authentication Management: This is responsible for managing the authentication and identity of the users of the system. It provides APIs for user authentication and identity management.
User Management: This is responsible for managing the users of the system. It provides APIs for creating, updating, deleting, and retrieving user information.
Group Management: This is responsible for managing the groups of the system. It provides APIs for creating, updating, deleting, and retrieving group information.
Membership Management: This is responsible for managing the relationship between users and groups. It provides APIs for adding users to groups, removing users from groups, and retrieving the users of a group.
User Service
User Authentication and Identity Management
The core responsibility of the User Service is handling user authentication and identity management, which forms the foundation of the entire PositionPal platform. This service must maintain robust and secure user profiles, manage user authentication flows, and provide user data to other services in a consistent and reliable manner.
The critical challenge is balancing security with performance while maintaining a clear separation of concerns in a microservice architecture. User data must be protected, yet accessible to authorized services across the platform.
Clean Architecture Implementation
We adopted a Clean Architecture with clearly separated layers. This architectural choice provides significant benefits for a service responsible for sensitive user data:
user-service/
├── domain/ # Core business entities and rules
├── application/ # Use cases and service interfaces
├── storage/ # Database and persistence implementations
├── presentation/ # Protocol definitions
├── grpc/ # gRPC service implementations
├── rabbitmq/ # Message broker integration
└── entrypoint/ # Application bootstrap
Each layer has a specific responsibility with dependencies pointing inward toward the domain layer. This approach allows us to isolate the core business logic from implementation details.
The domain layer contains pure business entities and rules, uncontaminated by external frameworks or technologies. For example, the User
entity contains only the essential properties and validation rules:
data class User(
val userData: UserData,
val password: String,
)
data class UserData(
val id: String,
val name: String,
val surname: String,
val email: String,
)
The application layer defines service interfaces and use cases without implementation details. For instance, the UserService
interface specifies what the service can do without dictating how:
interface UserService {
suspend fun createUser(user: User): User
suspend fun getUser(id: UserID): User?
suspend fun updateUser(id: UserID, firstName: String, lastName: String): User?
suspend fun deleteUser(id: UserID): Boolean
suspend fun getUserByEmail(email: Email): User?
}
This clean separation facilitates testing and allows different implementations without affecting the core domain logic.
Group Service
An interesting aspect of the User Service implementation is the management of user groups. We applied Domain-Driven Design principles to model this complex relationship:
data class Group(
val id: String,
val name: String,
val members: List<UserData>,
val createdBy: UserData,
)
The Membership
entity encapsulates all the business rules regarding group membership.
data class Membership(
val userId: String,
val groupId: String,
)
Group Operations and Membership Management
Here’s the interface that defines what our Group Service can do:
interface GroupService {
fun createGroup(group: Group): Group
fun getGroup(groupId: String): Group?
fun updateGroup(groupId: String, group: Group): Group?
fun deleteGroup(groupId: String): Boolean
fun addMember(groupId: String, userData: UserData): Group?
fun removeMember(groupId: String, userData: UserData): Group?
fun findAllGroupsOfUser(email: String): List<Group>
fun findAllGroupsByUserId(id: String): List<Group>
}
Authentication Implementation with JWT
The User Service implements JWT-based authentication, which is crucial for a microservice architecture where multiple services need to verify user identity without direct database access.
The service uses a combination of password hashing (with BCrypt) for secure storage and JWT tokens for stateless authentication:
interface AuthService {
fun authenticate(email: String, password: String): String?
fun authorize(token: String): Boolean
fun getEmailFromToken(token: String): String?
}
class AuthServiceImpl(
private val authRepository: AuthRepository,
private val secret: Secret,
private val issuer: Issuer,
private val audience: Audience,
private val expirationTime: Int = EXPIRATION_TIME,
) : AuthService {
override fun authenticate(email: String, password: String): String? {
...
return JWT.create()
.withIssuer(issuer.value)
.withAudience(audience.value)
.withClaim("email", email)
.withExpiresAt(Date(System.currentTimeMillis() + expirationTime))
.sign(algorithm)
}
}
This implementation follows the Strategy Pattern where different authentication methods could be plugged in, though currently JWT is the primary method. The service is designed to easily support additional authentication strategies if needed.
private val algorithm = Algorithm.HMAC256(secret.value)
Inter-Service Communication with gRPC
A significant challenge in implementing the User Service was defining how other services would interact with user data. We chose gRPC for synchronous service-to-service communication for several reasons:
- Type safety: Protocol buffers provide strict contract definitions;
- Performance: gRPC offers better performance than REST/JSON;
- Automatic code generation: Simplifies development by reducing repetitive code and maintaining consistency across services.
The service interfaces are defined using Protocol Buffers:
service UserService {
rpc CreateUser (CreateUserRequest) returns (CreateUserResponse);
rpc GetUser (GetUserRequest) returns (GetUserResponse);
rpc UpdateUser (UpdateUserRequest) returns (UpdateUserResponse);
rpc DeleteUser (DeleteUserRequest) returns (DeleteUserResponse);
rpc GetUserByEmail (GetUserByEmailRequest) returns (GetUserByEmailResponse);
}
service AuthService {
rpc Login (LoginRequest) returns (LoginResponse);
rpc ValidateToken (ValidateTokenRequest) returns (ValidateTokenResponse);
}
The gRPC service adapters then translate between these protocol messages and domain objects, following the Adapter Pattern:
class GrpcUserServiceAdapter(private val userService: UserService) : UserServiceCoroutineImplBase() {
override suspend fun createUser(request: CreateUserRequest): CreateUserResponse {
try {
val createdUser = userService.createUser(mapFromGrpcUser(request.user))
return CreateUserResponse.newBuilder()
.setUser(mapToGrpcUser(createdUser).userData)
.setStatus(createStatus(StatusCode.OK,
"User created successfully"))
.build()
} catch (e: Exception) {
return CreateUserResponse.newBuilder()
.setStatus(createStatus(StatusCode.INTERNAL_ERROR, e.message))
.build()
}
}
// Additional methods...
}
This adapter implementation allows us to keep the service interface stable while the internal implementation can evolve independently. The mapping functions handle the translation between domain models and protocol buffer messages, ensuring a clean separation of concerns.
Event-Driven Communication with RabbitMQ
For asynchronous communication scenarios, such as notifying other services when users are created or updated, we implemented an event-driven approach using RabbitMQ.
The User Service publishes domain events when user-related actions occur, such as member join group or group creation. Other services can subscribe to these events and update their local projections accordingly.
The message adapter uses the Strategy Pattern for serialization, allowing different serialization methods (currently Avro) to be used.
Group Event Publication System
One of the most interesting aspects of our implementation is how we’ve embraced event-driven architecture—but specifically focused on group operations.
In our system, we publish events exclusively for group-related operations through RabbitMQ. Here’s what that looks like:
enum class EventType {
GROUP_CREATED,
GROUP_UPDATED,
GROUP_DELETED,
MEMBER_ADDED,
MEMBER_REMOVED
}
Each event type corresponds to a significant domain event within our Group Service. When a group is created, members are added or removed, or a group is deleted, we publish an event to notify other services that might need to react to these changes.
Data Persistence with Repository Pattern
The User Service uses the Repository Pattern to abstract database operations, making it possible to change the underlying database without affecting the business logic:
interface UserRepository {
fun save(user: User): User
fun findById(userId: String): User?
fun update(user: User): User?
fun deleteById(userId: String): Boolean
fun findAll(): List<User>
fun findByEmail(email: String): User?
}
We use Ktorm, a lightweight ORM, to interact with PostgreSQL:
object Users : BaseTable<User>("users") {
val id = varchar("id").primaryKey()
val name = varchar("name")
val surname = varchar("surname")
val email = varchar("email")
val password = varchar("password")
override fun doCreateEntity(row: QueryRowSet, withReferences: Boolean) = User(
userData = UserData(
id = row[id].orEmpty(),
name = row[name].orEmpty(),
surname = row[surname].orEmpty(),
email = row[email].orEmpty(),
),
password = row[password].orEmpty(),
)
}