The problem: "let's move everything to microservices"

A fictional scenario. A fifteen-year-old lending platform runs applications, scoring, contracts, payments, notifications and reporting in one Java monolith. It is stable and earns money. One day a proposal arrives: "This is old technology. Let's move everything to microservices."

That is not an architecture decision; it is a solution looking for a problem. The architect's first question is different: which business problem are we solving? This article walks through the decision process that starts with that question, and the technical details of extracting one capability.

Five options — only one of them is "microservices"

OptionWhen it makes senseMain risk
KeepThe system is stable, its cost is acceptable and it does not block the businessKnowledge loss; the platform falling out of support
RefactorThe code is hard to change but the structure is soundRefactoring without tests causes regressions
ModularizeBoundaries are blurry and everything calls everythingBenefits stay invisible for a long time
Extract one capabilityOne capability has a different pace of change, load profile or ownerOperational cost of a distributed system; data consistency
RewriteThe platform is unsupported or the business model has changed completelyA long big bang, forgotten behaviour, the business waiting

AWS Prescriptive Guidance makes the same point: a monolith can be a valid option in some cases. Before modernizing, fully understand the monolith's business use case, its technology and its dependencies on other applications. Martin Fowler warns about full-rewrite plans: they often fail, because they take a long time, existing behaviour is hard to specify, and the business cannot wait.

Decision criteria

Business:

  • Speed of change: how many days, teams and which release does a small change need?
  • Resilience: does one module's failure take the whole system down?
  • Scale: which part is under a different load?
  • Cost and risk: platform support, licensing, the people who still know the system.

Technical:

  • How clear are the module boundaries?
  • Do we know who owns which table?
  • Is automated test coverage enough to protect changes?
  • Monitoring: will we be able to see the impact of a change?

In this scenario the problem is specific: changing the text of a notification template requires releasing the whole platform. Releases happen monthly, and every release puts every module at risk. This is a speed problem confined to one capability. It is no reason to rewrite the whole system.

Mapping dependencies and data ownership

Before extracting anything, map it:

            ┌─────────── Monolith (one deploy, one database) ───────────┐
            │ Applications ──► Scoring ──► Contracts ──► Payments        │
            │      │                          │             │            │
            │      └──────────────► Notifications ◄─────────┘            │
            │                          │                                  │
            │   applications  contracts  payments  notifications  ...     │
            └─────────────────────────────────────────────────────────────┘

Questions to answer:

  • Static dependencies: which package calls which? jdeps, IDE analysis or ArchUnit tests show this.
  • Runtime dependencies: which calls happen in real traffic? Tracing and logs show this.
  • Data ownership: who writes the notifications table and who reads it? Does another module JOIN it directly? A shared database is the hardest coupling. Even if the code is split, two systems writing the same table are still one system.
  • Business processes: which events create a notification (application received, loan approved, payment overdue)?

Notifications are a good candidate. The boundary is clear: other modules only tell it "this happened". It has its own table and changes often. Contracts and payments are tightly coupled through transactions, so extracting them first raises the risk.

Strangler Fig or Branch by Abstraction?

  • Strangler Fig builds the new capability beside the old system and gradually routes calls to it by intercepting them at the edge. It fits when calls can be intercepted at the system's perimeter (a gateway or routing layer).
  • Branch by Abstraction fits when the component sits deep inside the monolith and code above it calls it. First create an abstraction (an interface) and move the callers onto it. Then plug in the new implementation and switch with a toggle. As AWS notes, this pattern gets harder where data consistency is involved.

Notifications are called from inside the monolith, so in practice the two combine: a NotificationSender abstraction is created inside, and the routing decision is made behind it.

The new service: outbox and idempotent consumer

The monolith does not call the notification service synchronously. It writes the event to a transactional outbox table in its own transaction, and a separate relay publishes it to the broker:

@Transactionalpublic void approve(LoanId id) {    Loan loan = loans.get(id);    loan.approve();    outbox.save(OutboxEvent.of("LoanApproved", loan.id(), loan.customerId()));    // the loan status and the event are written in the same local transaction}

The outbox solves "the event got lost": if the status was written, so was the event. But it does not mean exactly-once delivery. If the relay crashes after publishing but before recording success, the event is published again. Delivery is at-least-once, so the new service must process events idempotently:

@Transactionalpublic void on(LoanApproved event) {    int inserted = jdbc.update(        "INSERT INTO processed_events(event_id) VALUES (?) ON CONFLICT DO NOTHING",        event.eventId());    if (inserted == 0) return; // already processed    notifications.enqueue(event.customerId(), Template.LOAN_APPROVED, event.eventId());}

The processed_events table and the notification queue live in the new service's own database. There is an important limit here: the real send to an SMS or email provider happens outside that database. If the service sends and then crashes before recording it, a duplicate is still possible. Handle that with the provider's idempotency key (for example the eventId), or with an explicit business decision to accept the occasional duplicate. More detail: idempotency patterns and Kafka delivery semantics.

The result is eventual consistency: the notification goes out a few seconds after the loan is approved. That delay must be acceptable to the business, so ask before you build.

Incremental routing

The switch does not happen at once:

boolean useNew = flags.isEnabledFor("notifications.new-service", customerId);if (useNew) {    // the event goes to the new service; the monolith module does not send for this customer} else {    legacyNotifications.send(customerId, template);}

For example: internal test customers first, then 1%, 5%, 25%, 50% and finally all customers. At every step only one system sends for a given customer. In a parallel "shadow" mode, the new service computes its result without sending, and its decision is compared with the old system's real send.

At each step, watch for missing or duplicate notifications, latency, error rate and provider cost. That needs logs, metrics and traces with the same correlation id in both systems. See the three pillars of observability.

Rollback: fast, but not free

Flipping the routing flag back returns traffic to the old module within seconds. But rollback has limits:

  • Data the new service wrote to its own database (send history, template versions) does not exist in the old system. It has to be reconciled or knowingly accepted.
  • Events that were already processed are not undone. The same event must not be processed by both systems at the switch point.
  • Schema changes, such as dropping a column from the old table, close the way back. Keep such one-way doors for the very end of the migration.
  • If the old module has been switched off for a long time, rollback also needs it to still work. Test that too.

The business measures success

"The service was extracted" is not a success metric. In this scenario, measure:

  • the time from a template change to production (lead time);
  • the number of incidents caused by notification changes (change failure rate);
  • the share of missing and duplicate notifications;
  • operating cost: the new service's infrastructure, monitoring and on-call load.

If the metrics do not improve, stopping the extraction is also a correct decision. Write that down in advance in an ADR: writing ADRs.

Common mistakes

  1. Starting from technology: "Kubernetes, Kafka, microservices", without naming the problem.
  2. Keeping the shared database. The code is split, the tables stay shared: a distributed monolith.
  3. Extracting the hardest module first. Start with a low-risk capability that has a clear boundary, like notifications, not payments.
  4. Assuming exactly-once. The outbox and the broker give at-least-once, so the consumer must be idempotent.
  5. Never testing rollback. The plan stays on paper and fails when you need it.
  6. A migration that never ends. Both systems live side by side for years, and the old code and routing flag are never removed.
  7. Ignoring operating cost. Every new service means networking, monitoring, on-call and distributed data.

Summary

Modernization is not a technology choice; it starts from a business problem. Keeping a stable monolith that does not block the business is also an architecture decision. If there is a problem, choose one capability with a clear boundary, run old and new side by side and move traffic in stages. Measure data consistency, and know the limits of rollback in advance. Strangler Fig is not a recipe. It is a reversible step chosen for a specific problem.

To see how module boundaries are protected from the inside, read microservice boundaries. For the price of event-driven designs, read event-driven trade-offs; for the architect's role, read the Solution Architect.

Sources

  • Martin Fowler — Strangler Fig Application.
  • AWS Prescriptive Guidance — Decomposing monoliths into microservices (decompose by business capability, strangler fig, branch by abstraction).
  • Martin Fowler — Branch By Abstraction.
  • Chris Richardson — Transactional outbox and Idempotent consumer patterns (microservices.io).