I've spent the last several months rebuilding the backend for Kheti Sahayak, an agricultural assistance platform, from the ground up — taking it from a single Node.js/Express monolith to 23 Spring Boot microservices. Not because microservices are trendy, but because the monolith had genuinely started working against us: every deploy touched everything, every schema change was a coordination problem, and the team (well, mostly just me) couldn't ship one feature without risking three others.
Here's how the migration actually went, and what I'd tell someone about to do the same thing.
Strangler-fig, not big-bang
The instinct when a monolith gets painful is to stop, design the "real" architecture, and rewrite. I didn't do that, and I'd argue you shouldn't either unless you can afford months of feature freeze.
Instead I used the strangler-fig pattern: new functionality got built as standalone Spring Boot services from day one, while the old Node.js backend kept serving existing traffic. Over time, more and more surface area moved to the new services, until the monolith's job shrank to "the stuff nobody's gotten around to migrating yet." The system stayed shippable the entire time — there was never a week where the app was down for "the migration."
One front door: the API Gateway
With 23 services, you can't have every client know 23 different addresses. Every request — from the Kotlin/Compose Android app or the React admin dashboard — goes through a single Spring Cloud Gateway instance. It's the only thing external clients ever talk to directly.
# gateway-service application.yml — routing to internal services
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
- id: payment-service
uri: lb://payment-service
predicates:
- Path=/api/payments/**
filters:
- name: RequestRateLimiter
The gateway also owns JWT verification before a request ever reaches a downstream service — auth logic doesn't get duplicated 23 times.
Finding each other without hardcoded IPs
Services need to find each other, and hardcoding hostnames doesn't survive contact with real deployments. I run a discovery-service (Eureka) that every service registers with on startup, and a config-service that serves configuration — including secrets — encrypted at rest.
# any service's bootstrap config
eureka:
client:
service-url:
defaultZone: http://discovery-service:8761/eureka
spring:
config:
import: "configserver:http://config-service:8888"
That second point matters more than it looks: no service carries plaintext secrets in an env var or a properties file. The config server decrypts on the fly using a key that never leaves the server itself.
Not everything needs to be synchronous
Some of these services genuinely don't need to talk to each other in real time. When an order gets placed, the notification service doesn't need to block the checkout flow to send a push notification — it just needs to know the order happened, eventually. That's where Kafka comes in.
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public void publishOrderPlaced(OrderEvent event) {
kafkaTemplate.send("orders.placed", event.getOrderId(), event);
}
}
notification-service consumes from that topic independently and fires push notifications (via Firebase Cloud Messaging), SMS, and text-to-speech — the last one matters a lot for farmers who may not read English fluently. That's an accessibility requirement that shaped the architecture, not an afterthought bolted on later.
Observability isn't optional past a certain scale
With one service, you can tail -f a log file and mostly know what's happening. With 23, you can't. The stack I run:
- Prometheus scraping metrics from every service
- Grafana for dashboards
- Elasticsearch, Logstash, Kibana (the ELK stack) for centralized logs
- An APM server for distributed tracing across service boundaries
- Purpose-built exporters for Postgres, Redis, and Kafka broker metrics
# prometheus scrape config (excerpt)
scrape_configs:
- job_name: 'gateway-service'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['gateway-service:8080']
Fourteen components total in that stack. It sounds like a lot for a side project, but the alternative — debugging a cross-service failure blind — is worse.
Where the AI actually lives
One service doesn't run on the JVM at all: a FastAPI service handles crop disease detection from photos farmers upload, sitting alongside the Java stack and reached the same way every other service is — through the gateway. Polyglot isn't a religion here; it's just that Python's ML tooling is better than Java's for this specific job, so that's what that one service uses.
What I'd actually tell you
If you're staring down a monolith that's slowing your team (or just yourself) down: don't wait for permission to do a "proper" rewrite. Strangle it. Ship the new service, route one path to it, watch it work in production, then move the next path. The migration becomes a series of small, reversible bets instead of one enormous one — and you never have to explain to anyone why the app is down for a week.
Kheti Sahayak is a solo project — architecture, migration, and all the mistakes along the way are mine. Happy to go deeper on any piece of this in the comments.

Top comments (0)