How We Reorganized an E-commerce Checkout Without Stopping Sales
Table of contents
- What Atlas Is, and Why It Needed to Grow
- The Pain, Without Romanticizing It
- First Decision: Each Domain Is a Sub-App
- Second Decision: Give Each Screen a Brain, a Body, and a Memory
- The Move That Makes the Difference: Stop Asking, Start Preventing
- Escaping Unstable Dependencies
- What It All Cost
- The Numbers, So It’s Not Just Gut Feeling
- The Real Test: The Cart Arrives
- What I’d Take to Any Team
There is a type of code nobody writes on purpose. It just happens.
When I opened the payment page file in our checkout for the first time, it was nearly 200 lines long. A quick glance revealed everything crammed inside: an API call fetching order data, three direct accesses to state stores, an if statement deciding which screen to navigate to, and an analytics event fired right in the middle. One file doing the work of four.
Nobody sat down and decided “I’m going to mix everything in here.” It was the sum of dozens of reasonable small decisions, each made under deadline, each one “just one more little thing right here.” The result is what we call, without any affection, a big ball of mud.
And the detail that makes this story interesting: this wasn’t a weekend side project. It was the checkout of one of Brazil’s biggest pet e-commerce platforms. Real money flowing through it, all the time. We needed to fix the house without ever turning off the lights.
This article is about how we did it. I’ll call the system Atlas to avoid mentioning internal names, but everything I describe really happened, in production, with people buying dog food while we were swapping the plane’s engine mid-flight.
What Atlas Is, and Why It Needed to Grow
Atlas is the checkout. That final, most delicate piece of any e-commerce platform: the person has already decided to buy, put things in the cart, and now needs to go through the funnel of personal data, address, shipping, and payment until the purchase is complete.
It’s a microfrontend. Meaning it’s not the whole site — it’s a separate application responsible only for this flow, communicating with the main platform (I’ll call it “the monolith”) and with a bunch of other services: payment providers, loyalty programs, discount clubs, shipping calculators.
Technically, it’s a Nuxt 4 application with Vue 3, state managed by Pinia, and communication via GraphQL. If you’re not a developer, you don’t need to memorize these names — they’re just tools. What matters is this: every page in Atlas requires a logged-in user and a valid order, and each step talks to the backend to advance the funnel. It’s a stateful, sequential, and critical flow. Getting this wrong isn’t a cute little bug on the homepage — it’s a sale that doesn’t happen.
And there was something else on the horizon. Until then, Atlas handled only the checkout. But the product decision had already been made: the cart would come here too. Meaning Atlas was going to stop being “the checkout application” and become “the checkout and cart application” — and possibly other domains after that.
That changes everything. Organizing a house where one family lives is one thing. Organizing a house knowing it needs to become an apartment building in six months is another. The question stopped being “how do I make this code cleaner?” and became “how do I prepare this code to receive new neighbors without everyone tripping over each other?”
The Pain, Without Romanticizing It
Before talking about the solution, it’s worth being honest about the size of the problem. It wasn’t “oh, the code could look nicer.” These were concrete pains that showed up every week.
Everything lived at the root. Atlas was born with Nuxt’s default structure: pages, components, and logic all thrown together at the project root. It works beautifully when you have a small domain. It becomes a nightmare when you need to add a second domain next to it and nothing separates what belongs to whom.
The layers were stuck to each other. Pages imported state stores directly. It seems harmless, but it creates an invisible dependency: the day someone changes the internal structure of a store, that effect leaks into all the pages that use it — with no warning, no barrier. You only find out when something breaks.
Business logic had leaked into the stores. This was the clearest symptom that the boundaries had been lost. A store, which should only hold state, had gained a function that called the API. Another had a function that decided navigation and reloaded the page. Stop and think about that: the place responsible for “keeping track of what’s on screen” had learned to “go fetch data” and to “change screens.” Completely different responsibilities, in the wrong place.
Almost nothing was properly testable. When a page combines data fetching, state, navigation, and analytics in a single file, you can’t test an isolated rule. To verify something as simple as “when there’s no registered address, send the user to the registration screen,” you were forced to mount the entire page and simulate half a dozen side effects. A test that’s too much work to write is a test that never gets written.
And worst of all: nothing prevented the pattern from repeating. We had documentation saying “don’t do this.” But documentation is a request. On the day of the deadline, the next dev — maybe me — would open a page, copy the old pattern sitting right there, and move on. The mess wasn’t a state; it was a tendency. It reproduced itself.
This last point is what matters most to this story. Because it changed the nature of the solution we went after. Cleaning wasn’t enough. We needed something that would prevent the mess from coming back.
First Decision: Each Domain Is a Sub-App
The first turning point was stopping thinking in “folders” and starting to think in “domains.”
Nuxt has a feature called layers. The idea is easy to explain with an analogy: instead of a big apartment where everything shares the same rooms, you build a building where each floor is a complete, self-contained unit. Each layer has its own pages, components, state, and tests. The project root becomes just the doorman: it assembles the building and decides the order of the floors, but doesn’t keep anyone’s furniture.
We split Atlas into two layers right away:
- shared — the infrastructure every domain uses, but that doesn’t belong to any specific domain. It’s where the “electricity and plumbing” of the building lives: the layer that talks to the backend, global error handling, monitoring. No business logic lives here.
- checkout — the real domain. Cart, address, shipping, payment, discount club. All the logic specific to completing a purchase.
And the key insight: the root “composes” these layers with a single configuration that lists which floors exist and in what order.
// root configuration (simplified)
extends: [
'corporate-layer', // auth and design system, external
'./layers/shared', // cross-cutting infrastructure
'./layers/checkout', // the checkout domain
]
Remember the cart that was coming? Here’s why this decision was worth gold. In the old world, adding the cart would mean opening the existing code and stitching new things into the middle of what was already there — high risk of breaking the checkout that was already working. In the layers world, adding the cart becomes creating a new folder and adding a line to this list. The checkout doesn’t even need to know it got a neighbor.
None of this is free, and it would be dishonest to pretend otherwise. Layers have a cost: there’s a learning curve to knowing which floor each thing goes on, and configuration is spread out (each layer has its own). But it’s a cost you pay once, at the beginning, in exchange for not paying the cost of refactoring every time the product grows. For a system we knew would grow, the math was easy.
Second Decision: Give Each Screen a Brain, a Body, and a Memory
Resolving the separation between domains was half the work. The other half was cleaning up the mess inside each domain — that 200-line file doing everything.
For that we adopted a pattern we internally call CAL (Composable Abstraction Layer). It’s a well-known approach for anyone who has worked with interfaces: separating things into three clear roles. If you want to understand the pattern itself in more depth, I wrote about it in detail in Composable Abstraction Layer: the missing pattern between Pinia and its Vue components.
I like to explain it by thinking of a person:
- The View is the face. Pages and components — what appears on screen. The face makes no decisions. It shows what it’s told to show and reports when someone clicked something. That’s it.
- The Composable is the brain. This is where the reasoning lives: fetching data for the step, deciding what to do when the user submits a form, choosing which screen to navigate to, firing analytics events. Every decision happens here.
- The Store is the memory. It only holds state. It doesn’t fetch anything, doesn’t decide anything, doesn’t navigate. Ask it “what’s on screen right now?” and it answers. More than that, it doesn’t do.
The rule that ties everything together is absolute and easy to remember: the face only talks to the brain. A page never talks directly to the memory or fetches data on its own. It always goes through the composable.
In practice, the difference is striking. Before, the page was a cauldron:
// BEFORE — the page does everything
const orderStore = useOrderStore() // talks directly to memory
const data = await useQuery(deliveryQuery) // fetches data on its own
if (data.step === 'complete') {
navigateTo('/success') // decides navigation
}
sendAnalytics('delivery_viewed') // fires analytics
// ...180 more lines
After, the page became a one-line script — it asks the brain for everything ready-made and only worries about rendering the screen:
// AFTER — the page (the face) only consumes the brain
const {
loading,
deliveryStep,
plannedSteps,
handleUpdateDelivery,
} = await useDeliveryStepPage()
All the intelligence — the fetching, the navigation if, the analytics — went inside useDeliveryStepPage. The page no longer knows how things happen. It only knows what to show. And the store went back to being just memory: we removed the function that called the API and the function that navigated, and sent each one to the brain they belonged in.
The most tangible gain showed up in tests. To test the brain, you no longer need to mount the entire screen. You swap the data layer for a fake version, call the function directly, and verify it made the right decision. That test that “was too much work” became three lines. And a test that’s cheap to write is a test that actually exists.
The Move That Makes the Difference: Stop Asking, Start Preventing
Here comes the part I enjoy telling the most, because it’s the part most people forget.
We could have stopped at the previous section. Layers organized, code separated into face, brain, and memory, everything looking nice. But remember what was the pain that really kept us up at night? Not the mess itself. It was the fact that the mess reproduced itself. Documentation hadn’t held the line before. Why would it now?
The answer was moving the rule from the realm of goodwill into the realm of automation. We taught the linter — the tool that automatically checks code with every change — to block violations immediately. If someone writes a page that talks directly to memory, or fetches data on its own, the build bot rejects it. It doesn’t pass. No distracted reviewer lets it through, no “I’ll fix it later.”
// the linter now rejects this inside pages and components:
// - calling a store directly
// - fetching data directly (useQuery / useMutation)
// - making a direct HTTP request ($fetch)
The phrase I carry from this phase is roughly this: a convention that depends on human discipline is a convention that will die on a Friday at 6pm. Every team is disciplined when nobody is in a hurry. When they are, nobody needs to police anyone. The robot does the tedious work, and humans discuss what actually matters.
Escaping Unstable Dependencies
There’s a more technical decision worth recording because it reveals a philosophy.
To communicate with the backend, we had two choices. The first was to use a ready-made module, very popular, that makes this bridge with minimal effort — but which had been giving us headaches: beta versions, breaking updates, fragile error handling. The second was to use a simpler, more stable library for the actual call, and build the thin layer on top ourselves.
We chose the second. At first glance it seems counterintuitive — why write more code when something ready exists? The reason is simple: we didn’t want to tie the health of our checkout to the health of a dependency we don’t control. The ready-made module was comfortable until the day it decided to stop working with the next version of the framework. Then the problem would become ours, at the worst possible time.
Alongside this came a second rule: the client — the code running in the user’s browser — never calls the backend directly. Every call goes through an intermediate layer running on our own server, a pattern known as BFF (Backend for Frontend). Think of it as a receptionist: the browser makes a request to the receptionist, and the receptionist is the one who talks to the internal services, putting in the right credentials and handling the tedious details. The browser never needs to know the building’s internal corridors.
Both decisions come from the same principle: depend on things you control, and put clear boundaries between your code and the outside world. Short-term comfort rarely justifies long-term risk in a system that needs to stay up.
What It All Cost
I distrust any architecture article that only tells the good side. Every decision has a price, and hiding the price is marketing, not engineering. So here are ours.
More files per screen. In the new pattern, each checkout step became three files: the page, the brain, and the brain’s test. For someone used to a single file, it feels like bureaucracy. In practice, it’s the opposite: everything belonging to one screen is together in one directory. Deleting the screen means deleting the folder. But it’s a real cost of “more things to open.”
Layers that feel redundant. Some bridges between the brain and the memory are very thin — they almost feel like code for nothing. They exist on purpose: they’re the point where we guarantee the face will never depend on the memory’s internal details. It’s an intentional indirection. It takes a bit of patience to understand why it’s there.
The migration was long and uncomfortable. We didn’t stop everything to rewrite — that would have been suicide in a production system. The transition was done in phases, and for a good while new code lived side by side with old code. It was a period when the project had two accents at the same time, and that’s confusing. But it was the price of not stopping sales.
To soften this cost, we automated the creation of repetitive files. Instead of the developer building a new screen’s skeleton by hand — and getting the pattern wrong — a tool generates everything already in the correct format. The boilerplate cost becomes the cost of running a command.
The Numbers, So It’s Not Just Gut Feeling
All this conversation would be bar talk if it couldn’t be measured. It could. When comparing before and after the reorganization, the numbers tell the same story as the day-to-day feeling:
| Dimension | Before | After |
|---|---|---|
| Lines of code per page (average) | ~200 | ~80 |
| Test files in the project | ~54 | 109 |
| Total number of tests | ~636 | 791 |
| Stores with side effects | 2 | 0 |
| Automated barriers in the build | 0 | 3 |
The average page shrank to less than half — not because we threw code away, but because we removed from the screen what was never meant to be there. The number of tests went from around 636 to nearly 800, and that wasn’t willpower: it was a direct consequence of the code becoming testable. And the two stores doing things they shouldn’t became zero. The mess that used to reproduce itself now hits an automatic wall every time it tries to come back.
The Real Test: The Cart Arrives
Every architecture looks great the day you finish writing it. The real test is what happens when the world changes and you need to touch it again.
For Atlas, that test is the cart. The domain we knew was coming since the beginning of this story.
In the Atlas with 200-line files, everything glued at the root and business logic hidden inside stores, adding the cart would be a risky project: open the checkout that’s already working, stitch new code into the middle of it, hope nothing breaks a sale. The kind of task that keeps any tech lead up at night.
In today’s Atlas, the cart is a new layer. A folder, a line in the building’s floor list, and it’s born isolated — with its own pages, its own state, its own tests — without touching a single line of the checkout. The automated barriers ensure it’s born already following the correct pattern, without anyone needing to remember anything. The receptionist that talks to the backend is already there, ready to be reused.
We didn’t fix the house just to make it look pretty. We turned the house into a building that accepts new floors. And that’s why, when the cart arrives, it will come through the front door — not by breaking through the checkout’s wall.
What I’d Take to Any Team
If you’re left with only four ideas from this story, make them these:
Good architecture is what makes the next change cheap. Don’t measure it by the elegance of the diagram. Measure it by how much it hurts to add the next thing. If it barely hurts, you got it right.
Convention without tooling is just a request. Human discipline fails exactly when there’s pressure. If a rule truly matters, make the build reject violations. Stop asking, start preventing.
Separate who displays from who decides from who remembers. Face, brain, and memory. It’s an old pattern because it works. Dumb screens, logic in one place, state that only holds state — and suddenly everything becomes testable.
Tell the price, not just the prize. Every architecture decision has trade-offs. The team that knows the cost of its own choices is the team that knows when to go back. Hiding the cost doesn’t make it disappear; it just makes the surprise arrive later.
We swapped the plane’s engine mid-flight. Nobody on the ground noticed — sales kept happening the whole time. And when the next engine needs to go in, this time there’ll be a proper place for it to fit.
That, to me, is what architecture should be: not the perfect castle you admire from afar, but the house that stays good to live in even as the family grows.