Microservices Implementation
Clean Architecture Perspective
Implementation choices are driven by a Clean Architecture objective: keep business decisions stable while allowing infrastructure and framework details to evolve. In practical terms, this means protecting domain and application logic from direct coupling to HTTP frameworks, database drivers, and broker clients.
The working rule across services is the dependency rule: dependencies point inward, while implementations of technical concerns stay outward. Repositories, event publishers, and external clients are modeled as ports in the inner layers and implemented as adapters in infrastructure.
Layering Strategy
The implementation follows a practical layering model across runtimes:
- API layer (interface adapters): HTTP routes/controllers and request validation.
- Application layer (use cases): orchestration of workflows and domain rules.
- Domain layer (enterprise rules): entities/value objects and repository contracts.
- Infrastructure layer (frameworks and drivers): DB adapters, Kafka adapters, external service clients.
The diagram below summarizes dependency direction and the role of each layer.
The same shape is applied in Kotlin and Node.js services, with language-specific tooling.
From a Clean Architecture angle, this layering keeps controllers thin, use cases explicit, and infrastructure replaceable.
Runtime Composition and Bootstrap
Kotlin services (Ktor)
In appointments-service, the application is composed in one place, wiring middleware, DB, Kafka, and routes.
Source: appointments-service/src/main/kotlin/it/nucleo/Application.kt (Application.module).
fun Application.module() {
configureSerialization()
configureStatusPages()
configureCors()
configureCallLogging()
installJwtAuthGuard()
initializeDatabase()
configureKafkaConsumers()
configureRouting()
logger.info("Application initialized successfully")
}The same explicit module wiring approach is also used in documents-service, where bootstrap assembles MongoDB, MinIO, AI client, Kafka consumers, and HTTP routes in a single startup flow.
Node.js services (Express)
In users-service, app creation is separated from DB connection and process startup.
Source: users-service/src/app.ts (createApp).
export function createApp(): Express {
const app = express();
app.use(cors());
app.use(express.json());
app.get('/health', function (_req, res) {
res.json({ status: 'ok', timestamp: new Date().toISOString(), service: 'users-service' });
});
app.use('/api/auth', authRoutes);
app.use('/api/users', requireAuth, userNotificationRoutes);
app.use('/api/users', requireAuth, userRoutes);
app.use('/api/delegations', requireAuth, delegationRoutes);
return app;
}The same app-factory and startup orchestration pattern is reused in master-data-service for consistency.
API, Application, Domain Flow
Kotlin vertical slice
In appointments-service, the Kotlin flow starts at the routing layer, where handlers validate and decode input, then delegate the use case to application services.
Source: appointments-service/src/main/kotlin/it/nucleo/appointments/api/routes/AppointmentRoutes.kt (appointmentRoutes).
post {
val request =
try {
call.receive<CreateAppointmentRequest>()
} catch (_: Exception) {
return@post call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(error = "INVALID_BODY", message = "Invalid request body")
)
}
val result =
service.createAppointment(
patientId = request.patientId,
availabilityId = request.availabilityId,
)
call.respondEither(result, HttpStatusCode.Created) { it.toResponse() }
}The same API -> application -> domain pattern is applied in documents-service: routes in documents/api/routes handle request parsing and validation, then delegate business actions to DocumentService, which depends on the DocumentRepository domain contract.
At the application layer, services coordinate domain operations, repository calls, and external integrations such as event publication.
Source: appointments-service/src/main/kotlin/it/nucleo/appointments/application/AppointmentService.kt (AppointmentService.createAppointment).
class AppointmentService(
private val appointmentRepository: AppointmentRepository,
private val availabilityRepository: AvailabilityRepository,
private val notificationEventsPublisher: NotificationEventsPublisher? = null
) {
suspend fun createAppointment(
patientId: String,
availabilityId: String,
): Either<DomainError, Appointment> {
val patientId = PatientId(patientId).getOrElse { return failure(it) }
val availabilityId = AvailabilityId(availabilityId).getOrElse { return failure(it) }
val availability = availabilityRepository.findById(availabilityId)
?: return failure(AvailabilityError.NotFound(availabilityId.value))
// ... domain checks, save, side effects
}
}To keep business rules independent from persistence details, repository contracts are defined in the domain layer and implemented separately in infrastructure. In Clean Architecture terms, AppointmentRepository is a port exposed by the inner layers; concrete Exposed/Mongo adapters implement it without changing application logic.
Source: appointments-service/src/main/kotlin/it/nucleo/appointments/domain/AppointmentRepository.kt (AppointmentRepository).
interface AppointmentRepository {
suspend fun save(appointment: Appointment): Appointment
suspend fun findById(id: AppointmentId): Appointment?
suspend fun findByFilters(
patientId: PatientId? = null,
doctorId: DoctorId? = null,
status: AppointmentStatus? = null
): List<Appointment>
suspend fun update(appointment: Appointment): Appointment?
}Node.js vertical slice
In users-service, the Node.js flow follows the same layering principle: routes validate payloads with Zod and then delegate orchestration to the service layer.
Source: users-service/src/api/user.routes.ts (router.post('/')).
router.post('/', async (req: Request, res: Response) => {
try {
const { fiscalCode, password, name, lastName, dateOfBirth, doctor } = validateWithSchema(
createUserBodySchema,
req.body,
'create user body'
);
const user = await userService.createUser({
fiscalCode,
password,
name,
lastName,
dateOfBirth,
doctor,
});
return success(res, user, 201);
} catch (err) {
return handleRouteError(res, err, 'Create user error', USER_ERROR_RULES);
}
});The same vertical slice is used in master-data-service: route handlers (for example service-catalog.routes) validate input, delegate to ServiceCatalogService, and rely on repository interfaces defined in the domain layer.
At service level, orchestration includes aggregate updates, consistency checks, and publication of cross-service events when required.
Source: users-service/src/services/user.service.ts (UserService.deleteUser).
export class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly patientRepository: PatientRepository,
private readonly doctorRepository: DoctorRepository,
private readonly userEventsPublisher: UserEventsPublisher | null = null
) {}
async deleteUser(userId: string) {
const existingUser = await this.userRepository.findUserById(userId);
if (!existingUser) throw new Error('User not found');
await this.patientRepository.delete(userId);
await this.doctorRepository.delete(userId);
await this.userRepository.delete(userId);
await this.userEventsPublisher?.publishUserDeleted({
userId,
deletedAt: new Date().toISOString(),
});
}
}Event-Driven Integration (Kafka)
Producers
In appointments-service, domain actions that require user-facing communication are translated into Kafka notification events.
Source: appointments-service/src/main/kotlin/it/nucleo/appointments/infrastructure/kafka/NotificationEventsPublisher.kt (NotificationEventsPublisher.publish).
fun publish(receiver: String, title: String, content: String? = null) {
if (!isEnabled() || receiver.isBlank() || title.isBlank()) {
return
}
val event =
NotificationEvent(
receiver = receiver,
title = title,
content = content,
sourceService = clientId,
occurredAt = Instant.now().toString()
)
getOrCreateProducer().send(ProducerRecord(notificationsTopic, receiver, json.encodeToString(event)))
}In master-data-service, lifecycle changes on reference data are emitted as deletion events for downstream consumers.
Source: master-data-service/src/infrastructure/kafka/master-data-events.publisher.ts (MasterDataEventsPublisher).
async publishServiceTypeDeleted(event: EntityDeletedEvent): Promise<void> {
await this.publish(this.serviceTypeDeletedTopic, event);
}
private async publish(topic: string, event: EntityDeletedEvent): Promise<void> {
if (!this.isEnabled(topic)) return;
await this.ensureConnected();
await this.producer?.send({
topic,
messages: [{ key: event.id, value: JSON.stringify(event) }],
});
}Within Clean Architecture boundaries, event publishing is modeled as an infrastructure concern triggered by application workflows, not as a domain-side broker dependency.
Consumers
In documents-service, delete events are consumed to trigger data cleanup and preserve cross-service consistency.
Source: documents-service/src/main/kotlin/it/nucleo/documents/infrastructure/kafka/DeleteEventsConsumer.kt (DeleteEventsConsumer.handleRecord).
private fun handleRecord(topic: String, payload: String) {
when (topic) {
userDeletedTopic -> handleUserDeleted(payload)
medicineDeletedTopic -> handleMedicineDeleted(payload)
serviceTypeDeletedTopic -> handleServiceTypeDeleted(payload)
else -> logger.warn("Received message from unexpected topic: {}", topic)
}
}In users-service, notification events are consumed and persisted so they can be surfaced to end users.
Source: users-service/src/infrastructure/kafka/notification-events.consumer.ts (NotificationEventsConsumer.start).
this.consumer.run({
eachMessage: async ({ message }) => {
if (!message.value) return;
const parsed = JSON.parse(message.value.toString()) as RawNotificationEvent;
const payload = this.parseNotificationEvent(parsed);
if (!payload) return;
await this.notificationService.consumeNotificationEvent(payload);
},
});Consumers therefore act as inbound adapters: they translate transport-level messages into application actions.
Persistence Implementations
SQL persistence
For relational persistence, appointments-service uses PostgreSQL through Exposed repositories and coroutine-based transactions.
Source: appointments-service/src/main/kotlin/it/nucleo/appointments/infrastructure/persistence/ExposedAppointmentRepository.kt (ExposedAppointmentRepository.save).
override suspend fun save(appointment: Appointment): Appointment = dbQuery {
AppointmentsTable.insert {
it[appointmentId] = appointment.id.value
it[patientId] = appointment.patientId.value
it[availabilityId] = appointment.availabilityId.value
it[status] = appointment.status.name
it[createdAt] = appointment.createdAt.toJavaLocalDateTime()
it[updatedAt] = appointment.updatedAt.toJavaLocalDateTime()
}
appointment
}The repository implementation is an outbound adapter for a domain contract, so switching persistence strategy does not alter use-case orchestration.
Mongo persistence
For document-oriented persistence, documents-service uses MongoDB and performs atomic updates on nested medical record arrays.
Source: documents-service/src/main/kotlin/it/nucleo/documents/infrastructure/persistence/mongodb/MongoDocumentRepository.kt (MongoDocumentRepository.addDocument).
collection.updateOne(
Filters.eq(MedicalRecordDocument::patientId.name, patientId.id),
Updates.push(MedicalRecordDocument::documents.name, bsonDoc),
UpdateOptions().upsert(true)
)In users-service, MongoDB repositories handle user authentication data, patient and doctor profiles, and delegation relationships. Repository implementations inherit the same contract from the domain layer and perform standard CRUD operations via Mongoose models.
Source: users-service/src/infrastructure/repositories/implementations/user-repository.impl.ts (UserRepositoryImpl.findByFiscalCode, save, create).
async findByFiscalCode(fiscalCode: string): Promise<UserData | null> {
const user = await UserModel.findOne({ fiscalCode: fiscalCode.toUpperCase() });
if (!user) return null;
return this.toUserData(user);
}
async save(user: User): Promise<void> {
const update = {
fiscalCode: user.fiscalCode.value,
passwordHash: user.credentials.passwordHash,
name: user.profileInfo.name,
lastName: user.profileInfo.lastName,
dateOfBirth: user.profileInfo.dateOfBirth,
};
const result = await UserModel.findOneAndUpdate({ userId: user.userId }, update, { new: false });
if (!result) throw new Error(`User with id ${user.userId} does not exist`);
}
async create(user: User): Promise<void> {
await UserModel.create({
userId: user.userId,
fiscalCode: user.fiscalCode.value,
passwordHash: user.credentials.passwordHash,
name: user.profileInfo.name,
lastName: user.profileInfo.lastName,
dateOfBirth: user.profileInfo.dateOfBirth,
});
}In master-data-service, reference data repositories (service types, facilities, medicines) maintain catalog information across the platform. Each repository uses Mongoose to implement soft and permanent deletes, ensuring backward compatibility while supporting cascading cleanup from event consumers.
Source: master-data-service/src/infrastructure/repositories/implementations/service-type-repository.impl.ts (ServiceTypeRepositoryImpl.findAll, create, softDelete).
async findAll(query: RepositoryQuery): Promise<ServiceType[]> {
const docs = await ServiceTypeModel.find(query).sort({ code: 1 });
return docs.map(this.toServiceType);
}
async create(data: ServiceTypeCreateData): Promise<ServiceType> {
const serviceType = new ServiceTypeModel({ _id: data.code, ...data });
const doc = await serviceType.save();
return this.toServiceType(doc);
}
async softDelete(id: string): Promise<ServiceType | null> {
const doc = await ServiceTypeModel.findByIdAndUpdate(
id,
{ isActive: false },
{ new: true }
);
return doc ? this.toServiceType(doc) : null;
}Documents and AI Service Integration
In documents-service, an infrastructure HTTP client invokes the Python AI service:
Source: documents-service/src/main/kotlin/it/nucleo/documents/infrastructure/ai/AiServiceClient.kt (AiServiceClient.analyzeDocument).
suspend fun analyzeDocument(patientId: String, documentId: String): AiAnalysisResult {
val url = URI("$baseUrl/analyze").toURL()
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true
val requestBody = """{"document_id": "$documentId", "patient_id": "$patientId"}"""
connection.outputStream.use { os ->
os.write(requestBody.toByteArray(Charsets.UTF_8))
}
// ... map HTTP response to AiAnalysisResult
}On the AI side, the FastAPI service exposes the analysis endpoint consumed by documents-service.
Source: ai-service/src/main.py (analyze_document).
@app.post("/analyze", response_model=AnalyzeResponse)
async def analyze_document(request: AnalyzeRequest):
pdf_content = minio_client.fetch_document(request.patient_id, request.document_id)
document_text = pdf_extractor.extract_text(pdf_content)
metadata = ai_analyzer.analyze(document_text)
return AnalyzeResponse(
success=True,
summary=metadata.summary,
tags=metadata.tags,
)