Taking a step from monolithic thinking into the world of distributed systems has been one of the biggest challenges in my project journey. In the Patient Management project, what started as simple REST services evolved into a system using CQRS, gRPC, and the Outbox pattern. Here is how I refactored these foundational layers to solve real consistency and performance issues.
Moving from REST Postcards to gRPC Stubs
In my earlier implementation, services talked to each other using RestTemplate. It felt like sending postcards—I had to manually build URLs like baseUrl + "/patients/find/" + id, manage circuit breakers with Resilience4j, and handle JSON parsing at every step.
I replaced this with gRPC over Protobuf. Instead of "phone calls" where lines could be busy, it’s more like a direct radio connection. By defining our service contracts in .proto files, I could generate type-safe Java stubs that handle connections, retries, and deadlines natively.
RestTemplateConfig.java and RestTemplateAddresses.java. The code became cleaner and much more reliable.
Automating CQRS with AOP
When my database started slowing down due to expensive JOINs on tables receiving constant updates, I decided to implement CQRS (Command Query Responsibility Segregation). The goal was simple: route all writes to a "Master" DB and all reads to a "Replica."
To make this automatic, I used Spring AOP. I created a DataSourceAspect that intercepts any method call in a .query or .command package. If a method is marked as @Transactional(readOnly = true), the aspect automatically switches the ThreadLocal context to the read replica.
@Aspect
@Component
public class DataSourceAspect {
@Around("execution(* com.project.*.query.*Service.*(..))")
public Object routeToReplica(ProceedingJoinPoint joinPoint) throws Throwable {
DataSourceContextHolder.set(DbType.READ); // Switches to doctor-read-db
try { return joinPoint.proceed(); }
finally { DataSourceContextHolder.clear(); }
}
}
Keeping Data in Sync: The "Gold Standard"
One question I had was: how do we keep the Read model (DoctorSummary, PatientSummary) updated without slow cross-service messaging?
I implemented an Internal Event Pipeline. When a change happens on the write side, I use Spring's ApplicationEventPublisher to trigger a sync. The key is using @TransactionalEventListener(phase = AFTER_COMMIT). This ensures the read-model is only updated if the main database update actually succeeds.
This "Gold Standard" keeps our "Flattened" tables fast and join-free while maintaining perfect consistency within the service lifecycle.
Solving the Dual-Write Problem
A big risk in microservices is the "Dual-Write." If you save data to your database and then try to send a Kafka message, one might succeed while the other fails. This leads to data corruption across the system.
I solved this by implementing the Transactional Outbox Pattern. Now, my service writes the event into a local outbox_events table in the same transaction as the business data. A background relay worker then polls this table and ensures the message eventually reaches Kafka.
Saga Orchestration and Compensation
For complex flows like creating an appointment, which involves multiple services, I used an Orchestrated Saga. The CreateAppointmentSaga act as a state machine. It manages steps like validating the patient, checking the doctor's availability, and finally increasing the patient count.
A critical lesson I learned here was to move network-heavy gRPC calls outside of the database transactions. This protects our database connections from being held open while waiting for a slow network response. If a later step fails, the saga triggers "Compensation Logic" to revert the previous changes.
Better Together: The Support Service
Sometimes microservices can be too separated. I noticed my Lab and Inventory services were constantly talking to each other over the network, causing lag. I refactored them into a single Support Service with a shared schema.
By adding a Redis Second-Level Cache for these domains, I was able to speed up catalog lookups significantly, proving that sometimes, consolidation is just as important as separation.
Watching the System
Finally, I learned that you can't fix what you can't see. I set up Prometheus and Grafana to watch the project's pulse. I mainly focus on:
- Kafka Lag: To see if our outbox relay is keeping up.
- HikariCP Pool Health: To ensure we aren't running out of database connections.
- Docker Networking: Ensuring all services can reach each other via the global network.
Closing Thoughts
Being a junior developer doesn't mean we can't tackle complex patterns; it just means we have to be careful, humble, and always ready to refactor when we find a better way. This project has been an incredible foundation for my ongoing learning in the backend world.