At Shutterfly the customer record lived where the customer record in any fifteen-year-old company tends to live: in the middle of a monolith, in one relational schema that a hundred other features also read from and wrote to. Identity was load-bearing and untouchable. Change a column and you risked checkout, email, billing, and the print pipeline all at once. So when we decided to pull the monolith apart, we started exactly there, with the part everything else depended on.
This is the story of how we extracted User and Profile first, grew a graph-driven personalization service out of them, and used the strangler-fig pattern to retire the legacy database, all under full production load and with no maintenance window. It worked well enough that it stopped being a project and became the way we took the rest of the monolith apart.
Start with the domain, not the database
The instinct on a migration like this is to open the legacy schema and start drawing new tables. We didn't. We started with bounded contexts and the language each one spoke, because the schema is a consequence of the model, not the other way around.
Three contexts fell out quickly. User owned identity and account: credentials, status, and the authoritative answer to "who is this." Small, security-critical, strongly consistent. Profile owned the descriptive truth about a customer: contact details, addresses, preferences, consent. Larger, edited constantly, tolerant of a slightly stale read. And later, Personalization owned the derived view of what a customer was likely to want next. That one turned out to be a different shape entirely.
Splitting User from Profile was the decision people questioned most and the one that paid off the most. They change at different rates, demand different consistency, carry different blast radius, and want different owners. Fusing them because "they're both about the user" is how you get the monolith's coupling back under a new name. The test we held each boundary to was simple: can this context define its model without leaking the next one's? An authentication flow should not need to know a customer's favorite print size.
A bounded context is not a microservice. It's a decision about where one model ends and the next begins. Getting that line right is most of the work. The deployment artifact is an afterthought.
A schema that maps to the monolith without inheriting its problems
We had a hard constraint: we could not fork the data. The monolith would keep reading and writing the legacy tables for a long time, so the new model had to map cleanly onto the existing one, with the same identifiers and the same semantics, while staying free to evolve on its own.
The answer was a canonical model per context sitting behind an anti-corruption layer. The legacy schema had nullable everything, overloaded flags, and three columns that all roughly meant "active." The ACL normalized all of it. Consumers of the new services saw a clean, intentional contract, and the translation absorbed fifteen years of accumulated meaning. Where the legacy semantics were genuinely ambiguous, we pinned them down in the ACL and wrote tests against the interpretation so it couldn't drift.
Extensibility was by addition, never mutation. New attributes were additive and versioned; we never repurposed a field to mean something new, because that is exactly how the monolith's schema had degraded. Contracts were explicit: OpenAPI definitions backed by consumer-driven contract tests. A downstream team broke a test in CI before it broke a customer. And we kept the legacy user identifier as the durable key, so every existing reference stayed valid straight through the cutover instead of forcing a coordinated re-keying nobody had appetite for.
Stateless services, on purpose
We built on Spring Boot, but the framework was the least interesting choice we made. The discipline was that the services held no state. Every request carried its own context, any instance could serve any request, and scaling meant adding instances behind the load balancer. State lived in the datastore or the token, never in the JVM.
That single property bought most of the non-functional requirements that matter at this scale. Deploys became rolling and boring: no sticky sessions, no draining state, no instance worth saving. Resilience followed from treating instances as disposable. An availability zone could disappear and traffic simply rerouted. We wrapped the legacy ACL in circuit breakers and bulkheads so a slow monolith could degrade us but never take us down, with bounded retries, jitter, and idempotency keys so a retried write never double-applied.
The rest we treated as contracts rather than aspirations. Read latency had an explicit p99 budget in the low tens of milliseconds, defended with caching for hot keys and disciplined invalidation. Observability was there from the first commit: structured logs, a correlation id threaded from the edge through the ACL into the legacy call, RED metrics per endpoint, and distributed traces. You cannot replace a monolith you cannot see. And because this was identity and personal data, privacy was a first-class requirement, not a review at the end: PII minimized and classified, encryption in transit and at rest, field-level encryption and tokenization for the sensitive attributes, least-privilege access to every store, and the Profile context owning consent and the machinery for data-subject requests. A personalization system that cannot forget a person on request is a liability, not a feature.
Mosaic: a personalization profile as a graph
Profile answers "what is true about this customer." Personalization answers "what is this customer likely to want next," and that is not a row. It's a graph. A customer connects to categories, to occasions, to the people they create for, to price sensitivity, to seasonal rhythms, each a weighted edge that shifts over time. We called the service Mosaic: thousands of small signals assembled into a picture of someone.
A graph because the questions were traversals, and they were sparse and many-to-many: given a customer, which affinities; given an affinity, which customers. Forcing that into the relational model meant junction tables that didn't scale and queries that scaled even worse. But we deliberately did not reach for a graph database, because we didn't need arbitrary deep traversal. We needed predictable single-digit-millisecond reads at very high volume, and our traversals were shallow and known in advance. That pointed at DynamoDB, with the graph modeled as an adjacency list.
The design discipline there is to key the table around access patterns, not entities. Every item is a node or an edge: the partition key is the entity (USER#id), and the sort key is the edge, one of PROFILE, AFF#category#weddings, or EVENT#.... A single query returns a customer's entire neighborhood in one round trip. We designed the global secondary indexes up front for the inverse and secondary patterns: a GSI keyed by affinity to answer "who has this affinity" for audience building, index overloading so one GSI served several patterns, and sparse indexes so we only paid for the items that actually participated. Partition keys were chosen to spread load and avoid hot partitions, and we ran on-demand capacity so a marketing spike didn't require a capacity-planning meeting.
Pick the database for the questions you'll ask a thousand times a second, not for the entities on your whiteboard. Mosaic on DynamoDB wasn't a love of the tool. It was matching the store to the access pattern instead of paying the relational tax for a workload that was never relational.
Moving the data without stopping the business
We never had a maintenance window, and at this traffic we never would. The migration had to run live, which meant the two systems had to agree with each other for as long as both were standing.
So we ran dual sync in both directions. Writes through the new API propagated into the legacy tables. The monolith was still producing plenty of legacy writes, and those flowed back into the new stores through change capture. Both sides converged, and neither was ever left stale. On top of that we migrated on demand rather than big-bang: a profile materialized into the canonical model and its Mosaic neighborhood the first time it was touched, and a background sweep handled the cold long tail. Hot data migrated itself under real traffic; cold data moved on a schedule that nobody had to watch.
We refused to advance on a calendar. We advanced on health: the share of profiles materialized, reconciliation drift held near zero by continuously comparing checksums between legacy and new, the error budget intact, and latency inside its budget under production load. When those numbers were good, we moved. When they weren't, we stopped and fixed the thing the numbers were pointing at.
The strangler fig, one flag at a time
The new API sat in front of everything as a facade, and behind it a feature-flag layer decided, per cohort and per percentage, whether a given read or write went to the legacy path or the new one. Internal users first, then one percent, five, twenty-five, a hundred. Every ramp was gated on the same health metrics, and every ramp was instantly reversible. A bad signal was a flag flip, not a redeploy and not an incident bridge at 2 a.m.
The decisive move was flipping the system of record. For a long stretch the legacy database was the master and the new stores were the replica. Once Mosaic and the profile services were carrying production traffic cleanly, we reversed the direction: the new stores became the source of truth, and we kept syncing back into the legacy tables. That reverse sync is the part people skip, and it is the part that makes the whole thing workable. Dozens of downstream jobs, reports, and features still read the old tables directly, and we were not going to block the cutover on every one of them migrating at once.
So for a while the legacy database ran as a read-through shadow of the new source of truth, and every consumer got a deprecation window and a clean API to move to on their own schedule. The strangler fig grows around the host tree before the host is gone. The reverse sync was that phase, and it is what kept every old reader working while the new system took over.
The final cut
A migration is not finished when the new system works. It's finished when you delete the old one. Until then you have two systems, twice the failure modes, and twice the bill. Once a healthy majority of users and internal systems were on the API and the reverse-sync consumers had dwindled to a known, shrinking list, we set a date, published the timeline, and chased the stragglers by name. We watched access to the legacy tables fall to zero, turned off the reverse sync, and decommissioned the tables. The monolith's claim on identity was gone, and there was no path back to it. That is the only real proof a migration happened.
From one proof to a pattern
User, Profile, and Mosaic were the proof of concept, and it landed cleanly enough that the approach became the playbook rather than a one-off. We ran the same sequence against media and the services around it: extract a bounded context, define a canonical contract over an anti-corruption layer, build stateless cloud-first services on purpose-built storage, dual-sync, ramp behind flags, flip the source of truth, retire the old path. One capability at a time, we pulled the monolith apart into API-driven, cloud-native services.
The pattern repeated because the hard parts were never the new services. They were the boundaries, the contracts, and the nerve to keep two systems in lockstep long enough to move every last reader before turning the old one off. None of the ingredients are unusual: bounded contexts, an anti-corruption layer, purpose-built storage, dual-sync, a strangler facade. The hard part is doing it at scale, under load, without a window, and being willing to leave the temporary bridge standing until the very last consumer has crossed it.