How we moved the ad lifecycle out of a decade-old PHP service and into a Java service of its own, with zero downtime and without asking any dependent system to change on the same day.
Legacy: Core Monolith (PHP) · New: Listing Service (Java) · Approach: strangler fig + weighted canary
Context
The ad lifecycle is the spine of OLX India. Posting, reading, moderating, boosting, and expiring an ad all ran through a two-decade-old PHP monolith, Core Monolith. It runs on Kubernetes and talks to a relational database, a cache, object storage, and an event bus. It began inside a global monolith and never stopped acting like one.
With the consumer business fully on OLX India, we had a clear reason to modernise it: faster delivery, higher reliability, better security, better performance, and room to scale.
The problem
Changing the ad lifecycle meant touching posting rules, status transitions, monetization, and event payloads in one codebase. A change to ads was a release of everything around ads. Incidents took a long time to diagnose and a long time to undo.
Search, chat, moderation, and monetization were all coupled to the old responses and events. That ruled out a big-bang cutover, because every one of those teams would have had to change on the same day.
How we got here
Earlier attempts stalled. They taught us to make the migration smaller than the fear of it.What we had to guarantee
The rewrite was only acceptable if all of these held at once:
| Guarantee | What it meant in practice |
|---|---|
| Zero downtime | Sellers keep posting and buyers keep browsing during every step |
| No regressions | Same behaviour for 60+ APIs and 20+ event flows |
| Legacy and new stay consistent | Both services read and write the same ads without disagreeing |
| Backward compatibility | No dependent system changes on our schedule |
| Controlled traffic shifting | Exposure grows in small, deliberate steps |
| Rollback faster than the incident | Undoing a bad step takes seconds, not a redeploy |
The approach we took
We did not rewrite and switch. We kept Core Monolith in production and moved the lifecycle across one capability at a time, with traffic as the dial. Seven patterns carried the work.
| Pattern | How we used it |
|---|---|
| Strangler fig | A decade of behaviour moved without a cutover weekend |
| Ambassador mapping | Clients keep the URL and contract. The edge picks the service. |
| Canary release | Correctness proven on live traffic. Rollback is weight 0. |
| Anti-corruption layer | Old contracts at the boundary, clean model inside |
| Adapter | A neighbour’s change stays in one place |
| Feature toggle | Handlers and jobs ship dark, so side effects never apply twice |
| Expand, then contract | Shared database. Only the writer changes. |
Decisions we made, and why
| Decision | Why |
|---|---|
| Migrate by capability, not by system | Stalled rewrites taught us that a small slice can be verified and a whole system cannot |
| Keep the existing URLs, response shapes, and events | Search, chat, moderation, and monetization were coupled to them. The contract was the product. |
| Share the existing database and change only the writer | A data migration would have added a second risk to every slice |
| Route at the edge with weights | It gave us a canary and a rollback with no code change in the clients |
| Ship event handlers and jobs dark | Boost and expiry would have applied twice if both services acted on them |
| Never change a consumer’s schema | We fixed the mapping at our boundary instead |
Strangler fig: one slice at a time
Core Monolith stayed in production and answered anything Listing Service did not own yet. Each slice followed the same loop.
Repeated for posting, drafts, read paths, status changes, monetization events, and scheduled jobs.Ambassador: same contract, two services
Clients never learned a new host or path. For each capability we registered two mappings on the same prefix, one per service, with weights summing to 100. A request goes to exactly one of them.
Setting Listing Service’s weight to 0 sends the next request straight back to the legacy service.# Illustrative mapping pair, not a production manifest apiVersion: getambassador.io/v2 kind: Mapping metadata: { name: ads-legacy } spec: prefix: /ads service: core-monolith weight: 90 --- apiVersion: getambassador.io/v2 kind: Mapping metadata: { name: ads-new } spec: prefix: /ads service: listing-service weight: 10
Same prefix, two services, complementary weights. The client keeps calling /ads.
Canary release: the weight is the dial
Listing Service started at weight 0: deployed, mapped, receiving nothing. We raised it in steps, checking each against the legacy baseline for error rate, latency, response shape, and published events.
Illustrative steps. Each is held only after the previous one stayed quiet. At 100%, the legacy mapping comes off.# Step up: 10% to Listing Service, 90% stays on legacy kubectl patch mapping ads-new --type merge -p '{"spec":{"weight":10}}' kubectl patch mapping ads-legacy --type merge -p '{"spec":{"weight":90}}' # Rollback: the whole incident response is one weight change kubectl patch mapping ads-new --type merge -p '{"spec":{"weight":0}}' kubectl patch mapping ads-legacy --type merge -p '{"spec":{"weight":100}}'
Illustrative commands. Each step is held only after the previous one stayed quiet.
Anti-corruption layer, adapters, toggles, shared database
Listing Service’s domain model differs from the monolith’s. The anti-corruption layer translates at the two edges that dependents know: HTTP responses and published events. Neighbours (user data, monetization, media, location, notifications, the event bus) sit behind adapters.
Handlers that would double-apply a side effect, like boost or expiry, ship dark and switch on when the legacy handler is drained. Both services share the existing database during the transition, and only the writer changes.
In code
Anti-corruption layer: the domain model stays clean, and a mapper at the edge produces the legacy response shape callers already parse.
public class LegacyAdResponseMapper { // Domain Ad in, historical envelope out. Callers never see the new model. public LegacyAdResponse toLegacy(Ad ad) { return new LegacyAdResponse( ad.id().value(), ad.title(), LegacyStatus.from(ad.status()), // e.g. ACTIVE -> "active" ad.price().amount()); } }
Adapter: flows ask for a user or a limit, and the adapter owns the protocol.
public interface UserGateway { User findById(long userId); boolean isBanned(long userId); } class HttpUserGateway implements UserGateway { // HTTP client, retries, and error mapping live here and nowhere else }
Feature toggle: the new event handler ships dark and applies the side effect only once the legacy handler is drained.
@KafkaListener(topics = "ad-boosted") void onAdBoosted(AdBoostedEvent event) { if (!toggles.isEnabled("ad-boosted-handler")) { return; // legacy handler still owns this event } listingService.applyBoost(event.adId(), event.expiresAt()); }
Illustrative snippets, simplified from the patterns described above.
Locking behaviour with characterization tests
The contract was the product, and a decade of PHP held that contract in ways nobody had written down. Before reimplementing a capability, we pinned down what the legacy code did today, quirks included. Only then did we write it in Java.
| Kind of test | What it answers | How we used it |
|---|---|---|
| Characterization tests (unit and integration) | What does the current system actually do? | Documented real behaviour as a baseline. The goal was never to find bugs. Where legacy was odd, the new service matched it. |
| Regression tests (integration) | Did a recent change break an existing feature? | A broader suite run on every change, to catch drift as the new service grew. |
Tests come first, so each slice is judged against what legacy does, not against what we remember it doing.How we wrote them
- Unit level: status transitions, limits, expiry, and validation rules were exercised against known inputs, and the outputs were recorded as the expected values.
- Integration level: API responses and published events were captured for real scenarios and asserted field by field, including odd defaults and field ordering that callers had come to rely on.
- Same suite, both services: a scenario that passed on legacy had to pass on Listing Service unchanged. A failing scenario meant a gap in the new code, or a quirk we had to decide on deliberately.
// Characterization test: records what legacy does, not what we think it should do @Test void expiredAdStaysVisibleToOwner() { Ad ad = anAd().withStatus("expired").build(); Response r = api.get("/ads/" + ad.id(), asOwner(ad)); // Surprising, but it is today's behaviour and dependents rely on it assertThat(r.status()).isEqualTo(200); assertThat(r.body().get("status")).isEqualTo("expired"); }
Illustrative test, simplified. A quirk is changed later, in its own change, never as a side effect of the migration.
This is also how we treated a green canary that might still be wrong. A behaviour difference found in production became a new characterization test first, then a fix.
How Gen AI helped us move faster
We used a coding assistant throughout the migration. It did not decide the design. The patterns, slices, and rollout plan above were ours. It sped up the repetitive work inside each slice, which is where most of the hours went.
| Where the assistant helped | What it did |
|---|---|
| Reading legacy PHP | Explained unfamiliar flows, traced a request through the code, and listed the branches and side effects of a handler |
| Drafting characterization tests | Proposed test cases from the legacy code paths, including edge cases we had not listed. We reviewed each one against real behaviour. |
| Porting logic to Java | Produced a first version of mappers, DTOs, validators, and repository code, following our domain and layering conventions |
| Boilerplate and adapters | Generated gateway interfaces, clients, config, and test fixtures |
| Refactoring and clean-up | Applied repetitive changes across many files, such as renames and signature updates |
Guardrails we kept
- Tests are the judge. Generated code was accepted only when the characterization and regression suites passed. A confident answer from the tool counted for nothing without that.
- Humans own the decisions. Slicing, contracts, toggles, and rollback stayed with engineers. Every change went through normal code review.
- Context makes it better. Project rules that described our layers, naming, and the legacy contract led to output that needed far less rework.
- Treat output as a draft. Subtle legacy quirks, such as an odd default or a status mapping, were the most common things to get wrong, and the tests caught them.
The pairing worked because the tests came first. Characterization tests gave the tool a precise target, and gave us a fast way to tell good generated code from plausible-looking code.
The architecture during the migration
While a capability sits on the left, legacy is its writer and publisher. Once traffic stays on the right, that slice is done.What Listing Service is

Schema changes are versioned and applied by the pipeline. The migration landed at 88% unit-test coverage and 76% integration-test coverage.
A posting, walked through

What we migrated
- 60+ APIs: create, edit, read, disable, delete, drafts, images, posting rules, categories, moderation, internal operations
- 20+ event flows: outbound ad changes, plus inbound monetization and account actions such as a ban
- Business rules: status transitions, limits, expiry, drafts, validation
Results
| Metric | Before | After |
|---|---|---|
| Ad-path latency | Baseline | ~25–30% lower |
| Development velocity | Tied to monolith releases | ~3× faster |
| Architecture | Modules in a PHP monolith | Domain-driven Java service |
| Resilience | Long time to repair | Faster rollback, lower MTTR |
| Unit / integration coverage | Uneven / partial | 88% / 76% |
| Cutover | Unsafe single release | Zero downtime, per capability |
Sellers now see about 25–30% lower latency on the ad path. Ad changes ship on Listing Service’s own deployment instead of waiting for the monolith’s release.
Splitting reads from writes
Fetching an ad is a different job from creating or changing one. We split the two paths. Write requests go to the ads service, which handles posting, updates, and the rest of the ad lifecycle. Read requests go to the relevance service, which only fetches the ad. That split is a CQRS approach.
Writes go to the ads service. Reads go to the relevance service.Impact
- Fetch APIs left the ads service. Ad reads now run on the relevance service.
- Less fan-out, same user demand. Demand on the Ad Details Page View APIs stayed flat, while calls to Ad-Manager dropped 46% and calls to User Service dropped 67%.
- Lower infrastructure cost. Fewer requests reach those services at peak, so they need less capacity and can be scaled down.
- Faster responses. Latency on the ad-fetch path improved 26–41%, in a narrower and more predictable band.
Challenges we hit
| Challenge | What we did |
|---|---|
| The contract was the product | Reimplemented existing APIs and response shapes |
| Events are a public language | Fixed the boundary mapping, never the consumer schema |
| Shared cache and auth state during overlap | Gave each generation its own cache identity |
| Scheduled jobs are traffic too | Shipped dark, enabled later, kept the legacy job until proven |
| A green canary can still be wrong | Treated behaviour diffs as their own migrations |
| Big-bang pressure | One capability at a time, each promoted on evidence |
The risky part of a rewrite is the period when both systems are telling the truth about the same ad. We designed for that period first.
What we would tell the next team
- Treat the existing contract as the requirement. Improve it later, in a separate change.
- Put the router in front before you write the first slice. Weight 0 costs nothing and gives you the rollback from day one.
- Lock behaviour before you rewrite it. Characterisation tests on the legacy code turn “what does it do?” into a suite you can run.
- Slice smaller than feels necessary. Small slices were the only ones we could verify with confidence.
- A healthy error rate is not proof. One diverging status change hides inside it.
- Ship side effects dark. Anything that could apply twice stays off until the legacy path is drained.
- Let Gen AI draft, and let tests decide. The assistant sped up the porting work, and the test suite decided what was accepted.
- Keep rollback shorter than the incident. A weight change, never a redeploy.
Conclusion
The ad lifecycle now runs in Listing Service. We got there by being stubborn about the contract: same host, same paths, same events. Ambassador held both services behind it, and a canary weight let us verify the new service on real traffic before it owned the prefix.
We built the new service so the old one could stay up beside it, moved traffic when the evidence said so, and kept the rollback shorter than the incident.
Acknowledgements
- Backend: Yash Sharma, Shiwani Tehlan, Karan Singh, Rajat Bhati, Bhavika Goyal, Ravinder Singh, Vaibhav Jain, Sourabh Garg, Nihul Rawat, Priyam Nigam.
- QA: Ashlesha, Aman Singh, Rathod.
- Leadership: Parvez Hassan, Jatin Juneja.
- CQRS Segregation: Karan Gupta, Bharat Ghimre.
