Architecture Migration and Modernization
The modular monolith from [Layered and Modular Monolith Architecture](/learn/advanced-system-design/layered-and-modular-monolith-architecture) already has catalog, orders, payment,
Architecture Migration and Modernization
Advanced System Design & Software Architecture · Lesson 48 · Microservices and Enterprise Architecture
Lesson Overview
The modular monolith from Layered and Modular Monolith Architecture already has catalog, orders, payment, and fulfillment behind module APIs, with separate PostgreSQL schemas. That structure was the point of the lesson: extraction is a later decision, not a prerequisite for sanity. The later decision has arrived for orders. A second team needs to ship checkout weekly. Payment’s PCI boundary should not be the same deployable as the catalog CMS. The temptation in the room is a rewrite: freeze features, staff a new platform, cut over on a Saturday, turn the old one off.
That rewrite is the highest-risk plan you can write down, and it is usually proposed with the least evidence. The running system contains years of edge cases that are not in the backlog: voucher expiry, tax rounding, a partner import, a retry that is load-bearing. A big-bang replacement has to rediscover them on the weekend the old binary stops. Fred Brooks called the clean replacement the second system, and the record has not improved because the new system is drawn as microservices. Independent deployability does not arrive on the day you delete the monolith. It arrives when one capability can move, fail, and roll back without the others.
Two patterns do that move. The strangler fig, Martin Fowler’s name, puts a facade in front of the old system and routes one capability at a time to a new implementation until the old one can be removed. The branch by abstraction, Paul Hammant’s name, does the same job inside a codebase: introduce an interface, run the old implementation and the new one behind it, switch callers, delete the old branch. You use the branch to make a seam where the monolith does not have one, and the strangler to move traffic once the seam exists. Lesson 7’s seams — public API, private schema, no cross-module transaction, a team that owns the module — are the checklist that says whether orders are ready to leave. If OrderService still joins catalog tables, the first project is the join, not Kubernetes.
Data is the part diagrams skip. Two writers, a shared primary key, and a “we’ll sync later” job are how orders fork. Prefer one writer, with the new service fed by the old system’s commits (a transactional outbox, or a CDC tool such as Debezium reading the PostgreSQL WAL) until you deliberately cut writes. Expand/contract from Microservices Deployment and Release Strategies is how columns change during the overlap. The gateway from API Gateway and Backend for Frontend is the usual strangler facade. This lesson is the sequence and the rollback, not another tour of containers.
Module 5 starts from blank pages: a URL shortener, a payment system, an e-commerce platform drawn as if they were new. Some of them will be new. When the real constraint is a system you already run, you bring this lesson back. You do not big-bang the ledger because the case study’s boxes look cleaner in greenfield form. You extract, or you stay modular, on purpose.
Learning Objectives
By the end of this lesson, you should be able to:
- Explain the strangler fig as a facade that routes a growing share of capabilities to a new system while the old system still serves the rest.
- Explain branch by abstraction as an interface inside one deployable, with two implementations and a switch, used to create a seam before traffic moves.
- Apply Lesson 7’s extraction checklist to the orders module: API, schema, transaction boundary, and team, and refuse to extract across a join.
- Choose a data movement that keeps one writer: outbox or CDC first, a cutover of writes second, and name why dual-write without a transaction forks.
- Plan coexistence: the monolith and the new service both correct for their share, with comparison checks and a way back that does not require a weekend restore as the only undo.
- State the risks of a big-bang rewrite in concrete terms: behavior you have not characterized, a feature freeze, a single cutover, and a rollback that is a restore.
- Sequence the order extraction so security, contracts, and observability from this module exist on the new path before the old path is deleted.
- Tell a Module 5 design when it is honestly greenfield and when it is a strangler in disguise, so the case study does not hide a migration inside a blank diagram.
Prerequisites
- Lesson 7, Layered and Modular Monolith Architecture: modules, schema grants, and the seams this lesson consumes.
- Lesson 38, Microservices Architecture Principles: the cost of a service, so extraction is a trade and not a graduation.
- Lesson 46, Microservices Deployment and Release Strategies: expand/contract and the meaning of undo.
- Lesson 44, Microservices Security and Zero Trust: the new path must not trust
X-User-Idfrom the facade without a token. - Lesson 45, Microservices Testing Strategies: characterization tests are how you notice the rewrite is wrong before the cutover.
Detailed Core Concepts
The strangler fig is a route, not a rewrite
A strangler fig grows around a host tree and replaces it while the host still stands. In a system, the facade is usually the gateway or a reverse proxy you already operate. Today it sends /catalog/** and /orders/** to the monolith. You stand up an order service that can authorize one SKU family. The facade sends that path, or that cohort, to the new service and everything else to the monolith. Learners do not choose the implementation. When the new service is the only one that still receives /orders/**, you delete the order module. Until that day, both exist, and the facade is the only place that knows the split.
The facade is a router of the kind Lesson 47 described, with a boring predicate: path, tenant, or a flag. It is not the place to reimplement pricing. If the gateway starts containing checkout rules, you have moved the monolith into the proxy and called it modernization. Keep the predicate declarative. A feature flag from Lesson 46 is a legitimate predicate for a cohort (“staff” then “five percent”) as long as the default is the monolith. A failed flag service must not send everyone to an unfinished order service.
Strangling works when the capability has an edge you can intercept. HTTP at the gateway is the easy edge. A nightly job, a message from the ERP, and a classmate’s in-process call are edges too, and they bypass the gateway. Inventory every caller before you call the route “done.” A cron job that still writes orders in the monolith after the cutover is a second writer you forgot because it had no route.
The fig is incremental by definition. If the plan says “we will run the new platform in shadow for a year and switch everything on one night,” you have a big bang with a long rehearsal, which may be justified, but do not call it a strangler. A strangler’s rollback of one capability is a route change back to the monolith, provided the monolith can still perform that capability and the data it needs is still there.
Branch by abstraction creates the seam inside the process
Sometimes you cannot route at the edge yet because the callers are method calls. CheckoutService constructs LegacyTaxCalculator and reaches into its package. Paul Hammant’s branch by abstraction is the sequence. Introduce TaxCalculator as the interface the caller already needed. Move the current code behind LegacyTaxCalculator without changing results. Write RulesTaxCalculator. Choose the implementation with configuration or a flag, not with a scattered if. Run characterization tests against both. When the new one is the only caller, delete the legacy class and, if nothing else needed the branch, consider deleting the interface only if it no longer marks a boundary. Do not leave an interface with one implementation and a comment that says “for testing” if the extraction is done and the mock is the only other type. Do leave the interface when the next step is to replace the in-process call with an HTTP adapter. The adapter is the third implementation, and it is the one the strangler will call.
The branch is how you stop a cross-module join before you extract. OrderService that calls catalogRepository.findPrice gets a CatalogPrice port. The first implementation reads through the catalog module’s API. The later one calls the catalog service. You do not extract orders while production still joins catalog’s tables, because the new process will not have that grant. Teams that skip the branch discover the join as the extracted service’s first SQLException.
Branch by abstraction is also the safe way to replace a library or a persistence style inside a module you are not extracting. It is not a license to introduce an interface for every class. The abstraction sits at a boundary you intend to switch. A port per method call is noise.
What Lesson 7 already decided
Extraction of orders is cheap only if these are already true.
The module has a public API other modules use, and they do not import its repositories. Spring Modulith’s ApplicationModules.verify(), if you used it, is evidence. A failing verify is a list of work, not a reason to start a new repository.
The module’s tables live in a schema the runtime role owns, and other modules do not have SELECT on them. A shared app user means the schema boundary is fiction. Fix the role before you move the database. Lesson 44’s least privilege is the same control.
A single transaction does not update orders and catalog, or orders and payment, except through an outbox row in the order transaction. If it does, a distributed transaction is hiding in a local commit. Split the transaction while you can still debug it in one process. Sagas are more expensive than a refactor of one method.
One team can be on call for the service. Extracting a module nobody will own creates a deployable with the monolith’s coupling and a second pager. That is a worse operational posture than the modular monolith you already had.
If any item fails, the modernization project is “make the seam real.” Shipping a Kubernetes Deployment first is how the join moves to HTTP as a chatty chain of calls that still share a database.
One writer, then a cut, and why dual-write forks
During coexistence the order row has a home. Pick it. The usual safer sequence is: the monolith remains the writer; the new service builds a copy by consuming a feed; you compare; you switch the facade’s reads when the copy is good; you switch writes only when the new service is the one you will operate; you keep a feed back, or you keep the monolith’s writer idle but deployable, until the rollback window closes; then you stop the old writer.
Dual-write means the request writes the monolith database and the new database, or writes the database and publishes a message, without one transaction. A crash between the two writes leaves one side updated. A retry can reverse the order. Readers then disagree, and the disagreement is a specific order id, not a chart. The transactional outbox from the event-driven lessons is the fix when you own the writer: one transaction commits the row and the outbox, and a relay publishes. Change data capture is the fix when you cannot change the writer yet. Debezium’s PostgreSQL connector reads the WAL (with plugin.name pgoutput on a current Postgres) and publishes row changes to Kafka. The new service consumes that log and applies it idempotently on the primary key. The monolith does not know the consumer exists. That ignorance is a virtue. You did not add a second write to the purchase path.
CDC is not free. It publishes what the WAL contains, including changes from jobs and consoles, which is what you want for a copy and a problem if a human hotfix should not have been replicated. It has lag. A read of the new service immediately after a write to the monolith can miss the row. Do not point the facade’s reads at the copy until you have measured that lag against the promise, or until you read from the writer. Schema changes in the monolith are schema changes in the feed. Expand/contract applies. A DROP COLUMN in the monolith drops it from the stream.
The cutover of writes is a short, explicit step. New purchases go to the order service, which is now the only writer. The monolith’s order module either proxies to it (a branch whose implementation is the HTTP adapter) or is unreachable for writes because the facade no longer routes there. Proxies inside the monolith catch callers you missed. They also keep the monolith in the latency path, so they are a transition, not a destination. A date on the calendar deletes the proxy.
Comparison before the cut is a batch, not a feeling. Count rows. Checksum money columns. Sample order ids and diff status. Investigate every mismatch. A mismatch is a bug in the mapper or a writer you did not know about. Switching writes on a copy that is “close” writes new orders onto a wrong history.
Rollback of the route is easy while the monolith is still a competent writer and its data is current. If the new service was the only writer for a day, the monolith is stale unless you replicated back. Plan the reverse feed before you need it, or accept that rollback is a restore and say so in the change record. Lesson 46’s rule applies: an undo you have not rehearsed is a slogan. Rehearse the route flip on staging with a copy of the feed.
Characterization before replacement
A rewrite discovers behavior by failing learners. A characterization test records what the current system does, including the quirks, and locks it. For orders: given this voucher and this address, the total is this many cents; given a duplicate submit, the second response is 409 and the row count is one. Those tests run against the monolith first. The new service does not replace the route until the same tests pass against it. Where the new behavior is intentionally different, the test is updated in a reviewed change that product signed, not in a “the new code is cleaner” pull request that alters a total.
Pacts from Lesson 45 cover the HTTP shape between the facade’s caller and the service. Characterization covers the money. You want both. A service that returns 201 with the wrong tax is a successful contract and a failed business.
Shadow traffic is a characterization you did not think to write. The facade sends a copy of the request to the new service and ignores the response, or compares it offline. Shadow writes are dangerous: do not let the shadow path capture a real payment. Shadow reads, and shadow computation of a total that is not persisted, are the useful forms. A difference log is an input to tests. It is not itself a fix.
Why the big bang stays a bad default
A big-bang rewrite freezes or forks the product. Every feature has to be built twice or postponed. The freeze is the hidden cost, and it is why stakeholders eventually force a cutover before the new system is ready: the old system was not allowed to change, so the business is stuck, so the date becomes political. The cutover then happens with an uncharacterized gap.
The new design also tends to be larger than the need. The second system picks up every feature anyone resented missing, plus microservices, plus a new data store, plus a new cloud account. Risk multiplies. The modular monolith’s payment module might have needed a separate deployable for PCI and nothing else. A rewrite that extracts twelve services because the slide had twelve boxes pays the operational cost Lesson 38 listed — traces, deploys, contracts, on-call — on day one, before any one service has earned it.
Rollback of a big bang is a restore of the old binary and the old database onto a state that continued to change until the switch, or a state that was frozen and is now missing a day of orders. Neither is a route flip. Teams discover this in the runbook paragraph that says “in case of failure, restore from backup,” which they have not timed.
Big bangs are still chosen, sometimes honestly: the old system cannot be safely changed (no tests, no one understands it, a vendor binary), a regulatory deadline forces a new control the old system cannot host, or the system is small enough that the characterization suite is the whole behavior. Even then, run the new system in shadow, migrate data with a rehearsal, and keep the old binary startable. “We cannot strangler” is occasionally true. “We prefer a rewrite because the old code is ugly” is not the same sentence. Ugliness is a branch-by-abstraction project.
What you delete, and when
Modernization fails by accumulation. The facade still has the cohort flag two years later. The monolith still has the order tables “in case.” The CDC connector still publishes. Each leftover is a writer or a route you can accidentally use. The plan needs a deletion step with a date: drop the route, revoke the database role, stop the connector, archive the schema, remove the flag. Until that step, you are paying for two systems, which is the actual cost of a strangler and the reason it has to finish. A strangler that never deletes the host is a permanent fork.
Architecture and Design Diagrams
The facade sends one capability to the new service. Callers that bypass the facade are drawn on purpose, because they are the usual missed edge. The monolith remains the writer until the cut.
mermaidflowchart LRLearner[Learner]Job[Nightly job]GW[Gateway facade]Mono[Modular monolith]Ord[order-service]Learner --> GWGW -->|/catalog| MonoGW -->|/orders cohort| OrdGW -->|/orders default| MonoJob --> MonoMono -->|outbox or WAL| Feed[(change feed)]Feed --> Ord
The sequence from seam to deletion. Skipping the middle boxes is the big bang.
mermaidflowchart TDS[Seam: API, schema, transaction, team] --> B[Branch by abstraction]B --> C[Copy via outbox or CDC\none writer]C --> V[Compare counts and samples]V --> R[Route a cohort at the facade]R --> W[Cut writes to the new service]W --> K[Keep a reverse path for the rollback window]K --> D[Delete module, route, role, feed]
Write cutover and the rollback that still exists. After the reverse feed is gone, rollback is a restore, and the diagram should not pretend otherwise.
mermaidsequenceDiagramparticipant G as Facadeparticipant M as Monolith ordersparticipant N as Order serviceparticipant F as Reverse feedNote over G: before cut, M is writerG->>M: POST /ordersM-->>F: changesF->>N: apply copyNote over G: cut writesG->>N: POST /ordersN-->>F: changes for rollback windowF->>M: apply so route can returnNote over G: window ends, F stops, M module deleted
Technical Explanation
A branch you can switch
The caller depends on the port. The configuration chooses the implementation. A characterization test runs the same fixture through whichever bean is active.
javapublic interface OrderPlacement {Placement place(OrderDraft draft);}public final class LocalOrderPlacement implements OrderPlacement {private final OrderService orders;public LocalOrderPlacement(OrderService orders) {this.orders = orders;}@Overridepublic Placement place(OrderDraft draft) {return Placement.from(this.orders.authorize(draft));}}public final class RemoteOrderPlacement implements OrderPlacement {private final RestClient orders;public RemoteOrderPlacement(RestClient orders) {this.orders = orders;}@Overridepublic Placement place(OrderDraft draft) {return this.orders.post().uri("/orders").body(draft).retrieve().body(Placement.class);}}
LocalOrderPlacement is the monolith module you have. RemoteOrderPlacement is the HTTP adapter, and it is the only class in the monolith allowed to know the order service’s URL. Callers do not construct either one. A @Configuration selects one bean, or a delegating implementation reads the flag and defaults to local when the flag store is down. The remote adapter uses the workload credential from Lesson 44. It does not copy a raw X-User-Id header it received from the internet. The facade already authenticated the learner. The adapter propagates a token whose audience is the order service.
The characterization test lives next to the port:
javaabstract class OrderPlacementCharacterization {abstract OrderPlacement placement();@Testvoid duplicateSubmitReturnsTheOriginal() {OrderDraft draft = OrderDraft.of("ord-1", "JAVA-101", Money.cents(4900, "USD"));Placement first = placement().place(draft);Placement second = placement().place(draft);assertThat(second.orderId()).isEqualTo(first.orderId());assertThat(second.createdNew()).isFalse();}}
Two subclasses, local and remote, run the same assertions. The duplicate-submit behavior is the idempotency contract. If the new service returns a second order, this test fails before the route flips. That is the point of characterizing the quirk instead of cleaning it up silently.
The facade predicate
Spring Cloud Gateway, which Lesson 34 used, can send a path to a different URI. A cohort is harder than a path: you might route POST /orders to the new service only when a cookie or a header the gateway itself set says cohort=orders-v2. The header must be set by the gateway from the flag evaluation, not accepted from the browser. Otherwise any client selects the new path and also selects your rollback. The predicate stays a route. Pricing stays in a service.
When the cohort is “everyone,” the route is a static URI change you can undo by reverting the route manifest, which is the strangler’s rollback. Practice that revert. A route that only lives in a console you cannot diff is not a rollback you can review.
CDC as a copy, not as a second domain
Debezium publishes change events that include the row image. The order service’s importer treats the primary key as the idempotency key, applies inserts and updates, and does not invent a second order id. It stores the source offset or the Debezium LSN position the way any consumer stores progress, so a restart does not fork. The importer is not the HTTP OrderPlacement path. Mixing “apply a captured row” and “accept a new checkout” in one unguarded method is how a replay overwrites a newer write after the cutover. After the cut, stop the importer or teach it to ignore writes that originated from the service itself. A loop — service writes, CDC captures, importer writes, CDC captures — is the dual-write you thought you had left behind, now with extra steps.
The monolith’s outbox is the alternative when you can change the monolith and you only want domain events, not every column update. Prefer the outbox when the new service needs OrderAuthorized and not a mirror of the table. Prefer CDC when you need a faithful copy of a schema you have not yet wrapped in events. Many extractions use CDC until the cut and events after it. Say which one you are in.
Data migration checks
Before the route flip, a job that only reads both sides reports: row count per status, sum of amount_cents per day, and a sample of ids present on only one side. It does not repair by writing. Repair is a reviewed change. The job’s output is an artifact in the change record. “We glanced at the dashboard” is not a comparison.
Personal data in the copy is still personal data. The new database has the same access controls and the same retention. A migration is not an excuse to replicate the orders table into a world-readable analytics bucket “temporarily.”
Practical Implementation or Design Walkthrough
Problem
Extract the orders module from TechLearningPro’s modular monolith. Catalog and the CMS stay. Payment stays in the monolith for this phase, called through the module API, and will be a later strangler with a stricter boundary. You must keep selling during the work. Rollback of the first cohort must be a route change, not a restore.
Constraints that are already met, and one that is not
Met: orders has a public OrderService API, its tables are in schema orders, and the team exists. Not met: OrderService.authorize still reads catalog.prices with a SQL join inside the order transaction, and a nightly partner import writes orders with the shared app role.
Sequence
- Stop the join. Introduce
CatalogPriceas a port. The local implementation reads catalog through the catalog module’s API, not through a cross-schema join. The order transaction no longer locks catalog rows. Characterization tests cover totals for the voucher fixtures you actually sell. This is branch by abstraction, still one deployable. - Stop the shared role. The nightly import calls the order API or writes through a role that is the order module’s, not
SUPERUSERand not catalog’s role. Inventory every other writer with a search for the table name and withpg_stat_activityduring a day, not only with a code search. - Publish a feed. Add an outbox in the order transaction for
OrderAuthorizedand the other facts the copy needs, or add a Debezium connector on the orders schema only (table.include.listlimited to those tables, not the whole database). The new order service consumes and stores a copy. It does not yet receive learner traffic. - Compare. Counts, sums, and a sample. Fix the mapper until the job is clean across a business week, including the nightly import.
- Route a cohort. Gateway sends staff, then five percent, to the new service for
POST /orders, defaulting to the monolith when the flag is down. The new service is now a writer for that cohort only. This is the dangerous step if both write the same ids. So the cohort must be partitioned by order id ownership: the monolith must refuse ids the new service owns, and the reverse, or only the new service accepts writes for the cohort and the monolith’s copy is read-only for those ids via the feed. Do not let both acceptPOSTfor the same id. The simple rule: the facade decides, and the non-owner rejects. - Shadow the money before the cohort, not after. For a week, staff traffic is computed on both paths and the totals are compared. No second capture at the acquirer. Payment capture stays on the path that owns the order.
- Cut the rest of the writes when the cohort’s SLI matches the monolith’s and the comparison stays clean. Keep the reverse feed so a route revert still sees yesterday’s orders.
- Delete. Remove the monolith’s order module from the build, revoke the role, drop the route predicate, stop the feed, archive the schema. The date is in the plan at step 1, not invented at step 8.
Payment extraction is a later project with its own seam. You do not “finish microservices” in this change. Catalog never moves in this change. A scope that extracts three modules because the team is already in the repository is how step 1 never finishes.
Failure and rollback
Flag or route back to the monolith for the cohort. The reverse feed has kept the monolith’s rows current for orders the new service accepted. If the feed is lagging, the rollback waits or the monolith reads the new service for those ids until it catches up. You do not drop the new service’s tables in a panic. You stop sending it writes.
A broken comparison blocks step 5. There is no override called “good enough” without a named residual and a person who accepts the mismatched cents.
The new path is not done if it skips audience checks, if it has no trace, or if its pacts were not verified. Those are the earlier lessons, and they are entry conditions to step 5, not follow-up work.
Expected result
Orders run as a service for all new checkouts. The monolith no longer contains the module. Catalog joins are gone. One writer owns an order id at a time. You can explain the rollback that existed at each step, and you can point at the day the reverse feed was removed. The nightly import calls an API. Nothing in the story required a feature freeze on catalog.
Enterprise Real-World Scenarios
A ledger you do not rewrite to match a slide
Design a Payment Processing System drawn greenfield hides the fact that many teams already have a capture path in a monolith or a vendor. The modernization is a strangler at the capture facade: new card brands or a new acquirer adapter behind a route, old brands still on the old path, one writer of the ledger row. A big-bang “new ledger” duplicates money. Dual-write of a capture is the worst member of that family, because the failure mode is a double charge or a missing one, not a confusing log. The case study should say whether it is greenfield. If it is not, the diagram needs the facade, the feed, the comparison of posted amounts, and the deletion of the old poster. Characterization tests are the rounding examples finance already argues about. They are not optional color.
An e-commerce platform that grew as a monolith
Design a Large-Scale E-Commerce Platform is the same shape as TechLearningPro at a larger SKU count. The honest architecture chapter starts from the seams that exist, not from twelve services. Cart and catalog can stay together longer than payment. The strangler order is driven by change rate and by boundary pressure (PCI, a second team, a scaling hotspot), which is Lesson 38’s reason to split, applied to a migration. A case study that extracts everything in one cutover has chosen a big bang and should describe the characterization suite, the data rehearsal, the freeze, and the restore time. If those are absent, the design is a picture of the end state, not a plan to get there. Module 5 will also include systems that are genuinely new — a URL shortener, a dispatch platform — where no strangler is required. Say so. Applying this lesson to a greenfield system produces a facade in front of nothing.
Best Practices
- Inventory callers and writers before you draw the new service. Jobs and consoles count.
- Make the seam inside the monolith first when the edge is an in-process call or a cross-schema join.
- Keep one writer per order id. Use an outbox or CDC to feed the other side.
- Compare counts and money before a route carries learners. Do not repair mismatches by hand in the comparison job.
- Default the facade to the old path. Practice the route revert.
- Plan the reverse feed before the write cut if rollback must be a route change.
- Put a deletion date on the module, the role, the flag, and the connector.
- Run characterization tests on both implementations. Change a total only with an explicit product decision.
- Do not extract a module that fails the transaction or ownership check. Fix the seam.
- Do not staff a big bang because the old code is unpleasant. Staff a branch, or document why a strangler cannot be intercepted.
Common Mistakes and Anti-Patterns
Rewrite as the first response. A freeze, a second system full of new scope, and a Saturday cutover. The replacement is a seam plus a strangler on one capability. Ugliness is not an interception problem.
Extracting across a join. The new service cannot compile the old SQL without a database grant that recreates the monolith’s coupling. Close the join with a port first.
Dual-write from the request. Two databases, no transaction, retries in the middle. Forked orders. Use one writer and a feed.
Calling a year of shadow mode a strangler. If nothing is deleted and everything switches at once, you have a rehearsal for a big bang. Rehearsals are useful. The name matters because the rollback differs.
A facade that trusts client headers. The cohort header or X-User-Id from the browser bypasses the flag and the identity work from Lesson 44. The gateway sets the cohort after it evaluates the flag, and the service demands a token.
CDC into a service that is also the writer, with the importer still on. A feedback loop rewrites rows and the lag chart looks like traffic. Stop or filter the importer at the cut.
Never deleting the monolith path. Two systems remain, and the next incident is served by the one you forgot was still routed for a partner. Deletion is a step with an owner.
Migrating data into a weaker permission model. The copy is a new place for the same personal data. Apply the same roles and retention before the feed is turned on, not after the first audit question.
Troubleshooting and Design Review
Symptom: totals differ for one percent of sampled orders. Cause: a writer you did not inventory (the nightly import, a support SQL script) or a mapper that drops a voucher type. Fix: stop the route expansion. Find the writer. Add the characterization fixture. Do not average the error away.
Symptom: rollback to the monolith shows missing orders. Cause: the reverse feed was not built or was lagging, and the new service had accepted writes. Fix: read those ids from the new service until the feed catches up, which you can do only if you did not already delete it. The design fix is the feed before the cut, rehearsed.
Symptom: extracted orders time out on price lookup. Cause: the join became a synchronous chain of HTTP calls inside the request, one per line. Fix: the port should have been a batch or a snapshot of price on the order line at authorize time, which is also how you stop depending on catalog’s availability. A chatty extraction is still coupled.
Symptom: duplicate orders after the cohort starts. Cause: both the monolith and the service accept POST for the same id. Fix: the non-owner rejects. The facade is the only router. A partner who bypasses the facade is the inventory item you missed.
Symptom: Debezium lag grows and the copy falls behind. Cause: the connector is reading more tables than the orders schema, or the consumer applies changes with a table lock. Fix: table.include.list limited to the module, and an importer that uses the primary key without a global lock. Lag is a readiness signal for the route flip. You do not flip while it is growing.
Design review. What is the interception point, and which callers bypass it? What is the single writer right now? What is the comparison gate? What is the rollback at this step, in one sentence, and when does it become a restore? What is the deletion date? Which characterization fixtures lock the money? Does this Module 5 system even have a legacy host?
Interview Questions and Answers
Q1. What is the strangler fig, in operational terms?
A facade in front of the existing system routes a subset of capabilities or cohorts to a new implementation and leaves the rest on the old one. You grow the subset until the old capability has no traffic, then you delete it. The facade is a route, not a second place to put business rules. Rollback of a capability you have not yet made the new system the sole writer for is reverting that route. A plan that switches every capability on one night is a big bang, even if a proxy sat in front for months.
Q2. What is branch by abstraction, and when do you need it before a strangler?
You introduce an interface at the boundary you want to replace, move the current code behind it, add the new implementation, switch callers, and delete the old one. You need it when callers are in-process and will not naturally pass through the gateway: a direct repository join, a constructed calculator, a package-private call. The strangler then routes to a service that the new implementation of the same interface calls. Without the branch, the extracted service discovers the join at runtime.
Q3. Which seams from the modular monolith have to be true before orders leave the process?
A public API with no imported repositories, a schema and a database role other modules cannot use, a transaction that does not update someone else’s tables except via an outbox in this module’s commit, and a team that will own the pager. A failed seam is work inside the monolith. Extracting anyway moves the coupling into the network and keeps the shared database, which has the costs of distribution and none of the isolation.
Q4. Why is dual-write of an order a fork, and what do you do instead?
The request updates two stores without a single transaction. A failure or a retry between them leaves different rows, and the next read depends on which store you ask. Instead, one side is the writer. The other receives a feed: an outbox relay if you can commit the event with the row, or CDC from the WAL if you must mirror a schema you do not yet wrap in events. The consumer applies changes idempotently on the primary key. You cut writes in a later, explicit step. You do not “also write” from the request just in case.
Q5. What does a characterization test lock, that a pact does not?
The pact locks the HTTP shape two teams agreed: status codes and fields. The characterization test locks behavior of the current system, including totals, voucher quirks, and duplicate-submit results, and it runs against both the old implementation and the new one. A new service can satisfy the pact with a wrong tax. The characterization fixture is how that fails in CI. Intentional behavior changes are reviewed updates to those fixtures, not silent drift.
Q6. When is CDC the wrong feed?
When you need a domain event and not a physical row image, the outbox is the clearer contract and it does not publish every internal column update. CDC is also the wrong tool to leave running after the new service is the writer, unless you filter the loop. It is the wrong thing to point at the entire database if only the orders schema is in scope; you will couple the connector’s lag to catalog traffic. And it is not a substitute for authorization. A mirror of the table in a second account with a wide grant is a new incident.
Q7. How do you roll back after half of the writes have already landed on the new service?
Only if those writes were replicated to the monolith, or if the monolith is allowed to read the new service for the ids it does not have. A route flip alone will send learners to a monolith that has never seen their orders. Build and rehearse the reverse feed before the cut, and keep it until the rollback window ends. After you delete it, rollback is a restore, and the change record should say that in the same sentence as the deletion.
Q8. Why do big-bang rewrites fail even when the new design is better?
The running system’s behavior is larger than the backlog. The freeze forces a political cutover date. The second system accumulates extra scope. Data migration is rehearsed less than the diagrams. Rollback is a restore nobody has timed. Microservices as the shape of the rewrite multiply the number of new moving parts on the same night. A better target architecture does not remove the need to get there one capability at a time, unless the system is small or cannot be intercepted at all.
Q9. A partner job bypasses the gateway and writes the old table after cutover. What did the plan miss?
The inventory of writers. The facade only strangler the callers that pass through it. Jobs, support scripts, and a second ingress are still writers. The fix before cutover is to make those jobs call the API that the single writer owns, and to revoke the role that allowed the direct update. The symptom after cutover is a comparison mismatch or a duplicate. Finding it then means the cohort already ran.
Q10. How should Module 5 use this lesson without strangling every greenfield design?
If the system does not exist yet, say it is greenfield and design the target. A URL shortener or a new dispatch platform does not need a facade in front of a host you do not have. If the system replaces a live checkout or a live ledger, the case study needs the interception point, the single writer, the comparison, the rollback at each step, and the deletion. A payment design that only shows the new boxes has skipped the migration that would actually lose money. The test of honesty is one sentence: what is in production the morning before this design ships, and how does one order avoid having two writers?
Hands-On Exercises
Exercise 1: Plan the orders extraction
Problem. The orders module has a cross-schema price join and a nightly import that writes its tables. Catalog stays in the monolith. Payment stays for now.
Tasks.
- List the seams that already hold and the two that do not. Say what you will not deploy until the failed seams are fixed.
- Specify the branch-by-abstraction port and the two implementations, including the flag default.
- Choose outbox or CDC for the copy, and say what you are replicating and what you refuse to replicate.
- Define the comparison that blocks the route flip.
- State the single-writer rule for the cohort, including how a bypassing job is rejected.
- Describe rollback at the cohort step and the step that later turns rollback into a restore.
- Give a deletion list: module, role, route, feed, flag.
Deliverables. The sequence, the writer rule, the comparison, and the rollback sentence for each step.
Acceptance. No step has two writers for the same order id. The price join is gone before the new Deployment receives traffic. A route revert is rehearsed while the reverse feed still exists. Catalog is not extracted.
Exercise 2: Review a rewrite proposal
The proposal freezes catalog features for two quarters, builds twelve services, dual-writes orders to both databases from the checkout request, cuts over on a Saturday with a backup taken Friday night, and treats characterization tests as optional because “the new model is cleaner.” Write the review. Name the risk each choice creates and the replacement step from this lesson. Keep the scope on migration, not on new product features.
Rubric.
| Assessment area | Weight |
|---|---|
| Strangler versus big bang | 25% |
| Seams and branch by abstraction | 20% |
| Single writer, feed, and comparison | 25% |
| Rollback and deletion | 15% |
| Characterization and scope control | 15% |
Lesson Summary
Modernization is a sequence that keeps the current system earning money while one capability moves. Branch by abstraction builds a switchable seam inside the monolith, which is how a cross-schema join and an in-process call become visible before they become network calls. The strangler fig is the facade that routes a cohort to the new service and leaves the rest alone, then grows, then deletes. Lesson 7’s seams are the entry test: API, schema, transaction, team. Data moves by a feed from a single writer, outbox or CDC, with a comparison of counts and money before learners are routed, and with a reverse feed if rollback must be a route change rather than a restore. Dual-write from the request forks. A big-bang rewrite freezes the product, rediscovers behavior in production, and rolls back only by restore. Deletion of the old path is part of the plan. Module 5’s greenfield designs can ignore the facade. Its replacements for a live checkout or a live ledger cannot. The morning before you ship, one order must still have one writer.
Further Reading
- Martin Fowler, StranglerFigApplication — the facade and the incremental replacement this lesson uses.
- Paul Hammant, Branch by Abstraction — the in-process seam before the route exists.
- Martin Fowler, Parallel Change — expand/contract, which the overlap of schema still requires.
- Debezium, PostgreSQL connector — logical decoding,
pgoutput, andtable.include.listas a copy of a schema you still own in one writer. - Lesson 7, Layered and Modular Monolith Architecture — the seams extraction consumes.
- Fred Brooks, The Mythical Man-Month, the second-system effect — why the replacement grows past the need.
- Michael Feathers, Working Effectively with Legacy Code — characterization tests as the lock on behavior you do not fully understand.
- Next in the course: Module 5, starting with Design a URL Shortening Service, which is greenfield, and Design a Large-Scale E-Commerce Platform and Design a Payment Processing System, which must say whether a live system is being replaced.