2 minute read

This is about the backend of an unmanned-café SaaS — a system handling payments, orders, device management, loyalty points, and settlement for stores nationwide. Here’s the state it was in when I inherited it:

  • A Django 4.2 monolith with a single 14,646-line views.py
  • 67 models, 218 URLs
  • A hybrid store with MySQL and MongoDB mixed together
  • And it was a production system actually making money

That last line is the important one. You can’t halt a running system and rebuild it. So we went with the Strangler Fig pattern.

Migration strategy

Three generations coexist in one repo: the Django monolith still in production, the Java 21 MSA being migrated into, and a new Next.js admin. Move one domain at a time to Java, shift traffic gradually, and kill the Django code once parity is verified.

The MSA monorepo is laid out as 19 modules: 11 microservices (store, catalog, machine, machine-gateway, payment, order, point, billing, notification, integration, analytics) plus 8 shared libraries. The shared libraries hold cross-cutting concerns — the event envelope (CloudEvents), the Outbox pattern, security, and observability.

Enforcing architecture in CI, not in a document

The thing I think I got most right on this project: five ArchUnit rules wired up as a CI gate.

  • Enforce the dependency direction api → application → domain ← infrastructure
  • No Spring imports in the domain layer
  • @Transactional only in the application layer
  • No field injection

You can write “we’re going hexagonal” in a document all you like; it will erode within three months. Encode it as a test and it can’t erode, because the PR won’t merge. This matters doubly when working with AI. However good the code Claude Code writes, humans should set the architectural boundaries and machines should hold them.

The event pipeline

Service-to-service communication is standardized on RabbitMQ, with every event wrapped in the CloudEvents 1.0 format. Each event always carries a unique ID (ULID) that prevents the same message from being processed twice, a traceId for following a request through the system, and a tenantId identifying which customer the event belongs to. On the publishing side I turned the Outbox pattern into a library — an @OutboxPublisher annotation plus a relay — so each service only has to add one annotation. The event specification alone runs to 599 lines, and it functions as the contract between services.

From NCP to AWS, with zero downtime

We also changed clouds mid-migration: Naver Cloud to AWS. I drew up three scenarios and compared them, then wrote delta-sync scripts by hand — PK-based for MySQL, ObjectId-based for DocumentDB — to let the data catch up before cutover. Zero downtime. The plan document came to 134KB, which confirmed once again that with migrations, writing the plan is 80% of the work.

Things caught in production

A few pulled from the commit log:

  • machine-service OOM during a burst of 10,000 simultaneous device connections → scaled up tasks and tuned the gateway auth hot-path timeouts (5s/2s)
  • The Netty DNS resolver triggering cascading 500s under 10,000 WebSocket authentications → swapped in the JDK resolver
  • Admin category filter: HTML 1.1MB → 208KB, response 8.8s → 1.1s

Legacy migration is not glamorous work. Most of it is a run of tedious fights like these. Still, watching 14,646 lines of views.py turn into 19 modules with per-service architecture tests, I’m glad I took it on.

Updated: