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

150K+lines of behaviour in scope
60+APIs migrated
20+event flows migrated
0minutes of downtime

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

Timeline 2006 to 2026Earlier 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.

Slice migration loopRepeated 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.

Ambassador weighted routingSetting 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.

Canary weight stepsIllustrative 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.
Characterization test loopTests 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

Target architectureWhile 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

Listing Service layers

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

Posting flow

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 and reads go to the relevance serviceWrites 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.

Author

👋 Hello, curious human.
Thanks for reading our Tech Blog.

We build things, break things, and occasionally write about both.
Get the best of our tech stories straight to your inbox.

We don’t spam, and your personal email stays private within OLX India.