The architecture decision reached me the way most architectural shifts reach people inside large organizations: already made. It came from WMS leadership. It was a decision at the level of the domain, not one made by the wider logistics area or by a company-wide architecture forum. I was a developer in one of the product squads, with no architectural authority. The message was simple. This was the direction, applications would be built this way from then on, and we should start aligning. We were not invited to debate the direction. We were invited to implement it.
The decision itself was reasonable, and in several ways it was the right direction. The domain had multi-step operational flows that were awkward to model as isolated CRUD-style endpoints. The business needed more control over how flows progressed. Different products needed variations in how operational tasks evolved. Some flows had become complex enough that keeping their logic spread across local services and frontend applications was already making them fragile. Orchestrating those flows centrally promised one source of truth for progression, less duplicated decision logic and more flexibility for operational change.
But the knowledge and the operating model the new architecture needed had not grown at the same speed as the commitment to it. That gap was where I started learning what I did not understand yet.
A good pattern adopted without an operating model
We did not have enough people who understood orchestration patterns well. We did not share a stable view of where orchestration should live or what the client runtime should still own. We also did not know which kinds of logic become dangerous once they spread across client applications. There was no operating model for the new architecture. There was a direction, and there were deadlines.
So something predictable began to happen. The architecture was adopted in pieces. Several squads started building their own local approximations of it, instead of one coherent model. We saw partially orchestrated systems instead of central flow control. There were no clear boundaries for where business interpretation should live, so business logic leaked across layers. Sometimes it was in the service, sometimes in the BFF, and sometimes in whatever screen was in front of the user when the decision had to be made.
In my experience, this is a common way for systems to become fragile as they grow. The target architecture may be fine. The trouble is that it is only partly understood, and each team under product pressure implements a different part of it.
The architecture was official, but the execution was drifting. The platform layer was absorbing much of the ambiguity, and nobody was explicitly naming what was happening.
The BFF was already becoming something else
Before getting into what changed, here is how WMS applications were structured at the time. Each product application ran as a client-side frontend. It connected to a Backend-for-Frontend layer, the BFF, which sat between the product and the domain services behind it. The BFF talked to the backend, and the frontend talked to the BFF. In the original model, the job of that middle layer was practical and limited. It gathered data from the services the product needed, reshaped it for the client, and kept frontend code from coordinating directly across a fragmented backend. There was one BFF per product. In theory, it was clear enough.
That clarity did not last.
Then the BFF started doing more important work. It started composing data across domains and interpreting business state to decide how the client should proceed. It started deciding how a flow should run on the other side of the wire. Most engineers in the organization still described it as a convenience that gathered and reshaped data. The language people used to describe it had not changed, but the work it was doing had.
The Returns project gave the shift a concrete shape
For a while, the tension between the architectural direction and what the squads were actually doing was mostly frustration. It was the kind you carry in the back of your mind without being able to point to a specific failure. Then a project came along where the conditions were different, and what had been background noise became impossible to ignore.
The project was a Returns flow inside WMS. Formally I was a developer on it, but in practice I acted as its tech lead. I had to understand the business demand from end to end, contribute heavily to the backend architecture decisions, and then design and build the entire client layer and BFF myself. It was not a comfortable scope. Most of the architectural decisions were mine to make. The organizational direction was still unsettled, the squads understood the orchestration model unevenly, and there was no shared example of what a well-built system in the new model looked like.
There was a version of this project where I stayed inside the boundaries of what was formally mine. I could have implemented what was asked, left the hard architectural questions to someone with a clearer mandate, and shipped on time. I understood that option. I also understood that it would leave the architectural debt for someone else to clean later, or for nobody to clean at all. In a domain like WMS, that meant operators would eventually absorb it, in ways that would never appear on any roadmap. So I did not take that option. It was a deliberate choice. It also meant that I was, again, absorbing organizational uncertainty into my personal scope. I made the project work by carrying weight the structure had not formally assigned to anyone.
Before the architecture, it helps to understand what the business was trying to solve. The Returns operation needed different flows for different product lines, and those variations changed faster than engineering release cycles could follow. Every time a product line needed a different return flow, it needed a deploy, a coordination window and a lead time the business could not always afford. The cost of that delay was operational. Flows that should have followed business decisions were blocking them instead. The need for configuration at runtime came from the business, as a constraint the previous model could not meet.
The requirement arrived in the first discovery meeting as a single sentence. At first I did not hear it as an architectural constraint. The Returns flow had to be configurable by product. That did not mean changing a label. It meant that the actual operational steps of a return had to change in production, without engineering redeploying code for every variation. The team did not choose that. It was a constraint we had to design around.
I wrote it down. I think I nodded. I probably said something about needing to understand the full scope of the configuration before committing to a technical approach. The product owner kept talking. My mind was already somewhere else. I was testing the phrase "configurable by product without redeployment" against every implementation shape I knew, and watching each one fail the same test.
In that meeting, I understood that this was a product requirement for operational flexibility.
In the hour after it, I understood that the implementation approach I knew could not deliver what the requirement asked for, at least not in a way that would hold.
I had built enough of these systems to know the difference between something that works for the current scope and something that works when the scope changes. The Returns requirement had already told us that the scope would change. That was the whole point: the business needed to change the flow without engineering. Any implementation that put flow logic in the frontend or in static service configuration would be wrong before the first product variation arrived. I could build it that way and finish in three weeks, or I could build it the right way and take longer. The project plan had budget for three weeks.
By the end of that week, I understood that the requirement had moved the source of truth for flow behavior out of the client application completely. It had also moved it out of static service logic. It was asking for a process model, close to a BPMN definition of the operational flow. BPMN is a standard notation for describing a business process as steps, decisions and transitions. Decision points, transitions and the conditions for what came next would be data that the business could change without a release cycle.
From a product point of view, that is powerful. It lowers the cost of operational change, and the business no longer depends on engineering release cycles. But it is a different kind of system, and you cannot build it the way you build a screen that renders a list and calls an endpoint. The flow logic does not live in a component or in a local state machine. It lives upstream of everything the client can touch, and by the time the runtime is involved it has become execution instructions. The client stops being the place that decides what happens. It becomes the place that executes what something else decided.
I remember thinking about that difference for a few days before I could explain it clearly to anyone. The technical direction felt right, but it was hard to describe in the language the team was using. Everyone was still talking about pages, screens and components. I was trying to describe something closer to a runtime that interpreted plans.
The first time I tried to explain it to the product owner, I talked about why we needed a process model. I explained what it meant for the backend to hold the flow definition, and why the client could not keep deciding what happened next. I watched their eyes follow the explanation without reaching what I needed them to understand. They were sharp. The problem was that I had not yet found the frame that made the architectural choice readable as a product choice. I was talking about layers, boundaries and compilation. They were thinking about product lines, release cycles and configuration. It was the same conversation, and we were not speaking the same language.
I found the translation eventually. I learned to start with a different question. Instead of asking where the flow logic should live, I asked who should be able to change it, how fast, and without depending on what. Put that way, the answer was obvious. The business could not wait for engineering releases to adjust a return flow for a product line. The business needed to own that configuration, and engineering's job was to build a system that made that ownership possible. That framing moved the conversation from architectural preference to product constraint, and the organization knew how to reason about product constraints.
We delivered within the three weeks, with the model the requirement needed. I did what I had learned to do in that company. I built the best version the time allowed, accepted some technical debt and made the risks of those decisions visible. The business set the timeline for the product, and the technical work had to fit inside it. The culture of the company pushed the same way: put something in production early, learn from it, then correct it or evolve it.
The debt was specific. The BFF absorbed work that belonged to a separate backend: it started to orchestrate flows and talk to services in ways that went beyond what a BFF should do. There was no time to build that other backend, so we put everything in layers inside the same repository. Strict rules decided which layer could call which, to limit the coupling and keep a future split possible. We documented only the basics, especially inside the repositories. Some client components were born with a degree of coupling, and with little thought for rendering performance. There was no resilience layer in the BFF or on the client, and no time even to plan one. Observability was the basic level that the boilerplate and the company's internal infrastructure already provided, with no dashboards of our own.
One entrypoint, one interpretable path
One of our first architectural choices inside Returns was that every meaningful interaction in the system would go through a single event entrypoint. The reason was practical. With one path, the system had exactly one place where the meaning of an action could be understood, logged, enriched and routed.
On the client side, this simplified the interaction model. There was no growing list of ad hoc endpoints, and no screen reinvented its own way of talking to the backend. The client sent an event, and the system interpreted it.
That pipeline was what every Returns interaction went through, from the moment the client emitted an event to the moment the flow engine decided what should happen next. The client runtime handed an event to the BFF. The BFF orchestrated across the domains it needed, enriched the data and produced a structured response. An orchestration layer, in the same repository as the BFF, sat between the BFF and the Returns service and delegated responsibility as the flow required. The Returns service talked to the flow engine, which held the BPMN-like process definition. Each layer did one thing. The value of the pipeline was in the shared path that every interaction followed, more than in the number of layers.
The choice was specific to Returns, and it stayed that way for a while. Later, as other WMS projects adopted orchestration more seriously, they borrowed the single-entrypoint pattern as a template.
The BFF as a compilation boundary
Think of the difference between a translator and a compiler. A translator converts text from one language to another. The meaning stays the same, and only the form changes. A compiler takes a description of intent and produces instructions that a runtime can execute. Its output is a plan.
That was the point where I stopped seeing the BFF as a simple proxy.
The backend could return domain-oriented responses. The client runtime could not safely work with raw domain responses alone, because its job was to execute the UI those responses described, and not only to display them. The risk of this direction is in the gap between those two representations. In Returns, the BFF became the layer that closed that gap.
So the BFF did more than translate shapes. It decided how domain meaning became interface execution. It chose which components should appear and which contracts the client runtime would interpret. It also decided how much backend flexibility to normalize before it reached the client. It took process semantics as input and produced execution instructions as output, which is what a compiler does. Seen that way, the phrase "the platform layer just passes data through" was too weak to describe what was happening. The client runtime executed a model, and the BFF compiled it.
I introduced a TypeScript-based builder system to make UI composition safe earlier in the pipeline, before the payload reached the client runtime. A dynamic client runtime becomes dangerous very quickly if every guarantee waits until runtime. That was not acceptable for a system that would run inside warehouses. There, a rendering failure is not a broken webpage. It is an operator who has stopped, with physical work waiting in line.
The builders did more than generate JSON shapes. They encoded rules about composition itself. They defined which components could contain which children, which props were required, which combinations were valid and which structures were not allowed to exist at all. A button could not accept any tree. A header could define a more specific contract for the children it could hold. Those rules were type constraints, enforced by the compiler on anyone who built a payload inside the BFF. We no longer trusted that any dynamic payload would happen to produce safe UI at runtime. We compiled a limited representation of what the runtime was allowed to execute, and the compiler refused to emit anything outside that grammar.
One detail of how this was built mattered more than it seemed at the time. Because of delivery pressure, the builder system lived inside the BFF project, not in a separate library. But from the first day, I wrote it in an isolated layer, structured as if it would one day become an independent npm package. It was a constraint I imposed on myself on purpose. It kept the builders from coupling to BFF-specific concerns, and it kept the contract explicit about what belonged to the builders and what belonged to the BFF around them. That is why the builders had a disciplined shape, even before they became a standalone library. Building for a future I might never reach, and keeping boundaries clean when I could have let them blur, taught me what it meant to build under conditions that mattered.
On the client side, the runtime stopped rendering predefined screens. It went through a structured payload, resolved components dynamically, injected props and composed the UI tree at runtime. The behavior of the flow came from interpreting an execution description that the system had produced upstream. The industry calls this approach server-driven UI. At that point, the old idea of page ownership starts to weaken. A page is no longer a locally defined view module. It becomes an execution surface inside a larger orchestrated system.
That changes what matters technically. Contracts, composition rules and versioning matter more, and local assumptions become more dangerous. The boundary between "client logic" and "system logic" becomes much more strategic. The client runtime is no longer a self-contained layer. It takes part in a distributed execution model, and it interprets only the last step of it.
The trade-offs were real
None of this was free.
The BFF became critical infrastructure. Its availability and correctness mattered more than in a typical aggregation layer, because the client could no longer work without it. If the BFF could not compile a valid execution plan, the client had nothing to interpret. Debugging became harder. Behavior was now spread across a process model, a transformation pipeline, typed builders and a runtime interpreter, and a problem could start in any of them. The cognitive load on engineers grew. Working on this system meant understanding orchestration, contract design, runtime interpretation and data composition across domains, not just components and API calls.
At the time, I saw those costs as recoverable. They were the price of moving from a presentation layer to an execution model. I believed they became unrecoverable only when a system was built this way without the discipline to keep the boundaries clean. That happens when the BFF becomes a dumping ground, when contracts drift, and when the runtime hides errors instead of failing explicitly. Inside Returns, we tried to make the design itself enforce those boundaries, with typed builders, isolated layers and a single interpretable path. It worked while I was on the project.
What Returns proved — and what it did not
Returns worked. It went into production and handled real operational flows. The progression of a return could change through configuration, without a deploy. The configuration had its own screen, but only the technical team had access to it. The plan was to hand it to the operation. We kept it with us while the product expanded step by step, because new cases could appear that nobody had mapped. It started with a single type of product, a high-value phone. A returned phone had to be inspected by specific rules, and those rules changed from country to country. Within a short time, it also covered other electronics, each with its own inspection rules and its own sequence of screens. Most of those additions needed no deploy of the BFF, the client or several of the backend services. Under the previous model, that would have been impossible. While I was on the project, it was close to a case-study result.
Looking back, it proved that this way of thinking about the platform layer could work. Dynamic execution did not have to become chaos. A flow defined by the backend did not have to make the runtime unpredictable. A client runtime could be deterministic if the system around it was explicit enough, the contracts were strong enough and the design itself enforced the boundaries.
That was an important lesson. The architectural direction was viable, and the client runtime could support this model. Whether other teams could sustain it was a different question, and Returns did not answer it.
The problem was that this direction was not becoming the common language of the wider system. We had produced one working example of a more mature model. Other squads inside WMS were still building incompatible approximations of similar needs. Some were under different product pressure. Some had adopted only parts of the orchestration pattern, without the operating model. Some had simply not been in the room when the decisions that made Returns coherent were made.
Returns proved the pattern could work. It also showed something harder. Once flow progression no longer lives mainly in page code, the client runtime becomes part of the execution model. From then on, knowing how to build one correct system is different from having a system that helps many teams build correctly together.
There was also something Returns did not prove, and I learned it only after I left. I moved on to the role the next chapter describes, and I did not stay to maintain or evolve the product. What I know about what came next is limited. The product later went through rework and simplification, because it had become too complex to maintain. Keeping it running needed engineers with a lot of experience in this kind of system. The flexibility that let business rules change without a deploy, and the decoupling that let the product grow, came at a very high price. Mike Hadlow called a version of this risk the configuration complexity clock. A system made configurable to avoid changing code can become as hard to change as the code it replaced. When I designed it, I judged the design by what it let the business do. I gave less weight to what it would ask of the people who maintained it after me.
The shift I only understood later
While I was inside Returns, I thought the lesson was architectural. Choose the right patterns and put logic in the right place. Enforce boundaries with types, keep the runtime thin and keep the BFF disciplined. That was only part of it.
The real change was that the platform layer, in a domain like WMS, had stopped being an interface concern. It had become part of how the business executed work. Most of the organization was still using the old language, including most of the teams building the other applications around me. They still talked about components, pages and screens. The system had already moved to contracts, interpretation, boundaries and execution models.
That is why one correct project could not fix anything structural. The gap had already moved upstream of the code. It lived in how the organization thought about the platform layer, and in how teams made decisions about it without agreeing on what it was.
I had understood this for longer than I had a structure to act on it. Closing the gap needed a change in where the authority to decide actually sat. It also needed a formal role that matched the scope of the problem, and not only the instinct to see it.
Returns was a successful project in a fragmented domain. That difference matters more to me looking back than it did at the time. The success was local: one team, one project and one set of architectural decisions made carefully under real constraints. The fragmentation was systemic. The rest of the domain kept building around us, under different conditions, with different levels of understanding and no shared language for what we were all building.
What I did not yet understand was what that formal role would demand. I was about to move from seeing the problem to being formally accountable for it, across squads that did not report to me, in a domain I had not built. Neither WMS nor the Returns project had prepared me for what that required.
Next: Chapter 3 — The Browser Prompt