How I built a Warehouse Picking Optimizer
I built a warehouse optimizer from scratch, solo, before LLMs: barcode scans, stock selection, pick routes, and inventory updates. In microseconds, it computes virtually optimal constrained routes that'd require hours to solve with a mathematically exact solver.
TL;DR
What shipped: I was the sole engineer on a barcode-driven warehouse picking system for Vivaldi srl. Operators would scan an order, and the Node.js service would call Rust/WASM optimizer modules around generated floor data, distance lookup, stock-location choice, and route ordering. The same system would also handle inventory search, Excel/CSV import-export, and error reporting on a shared 4 GB air-gapped Debian server.
How I measured it: during the engagement I checked the optimizer with before-and-after route comparisons on real orders and tracked solve times in production. Later, out of my own interest and research, I solidified those checks into a benchmark suite: six scenarios that the exact solver could finish within its time limit, each with 12–20 order lines across 15–40 stocked locations, several placing the same item in more than one location. On this set, solve time moved from milliseconds to microseconds, and the final routes were effectively identical in length to the exact optimum that can take hours to prove.
- Role
- Sole engineer
- Production constraint
- Shared 4 GB air-gapped Debian server
- Workflow
- Barcode scan → stock selection → ordered pick route
- Route-core solve time
- 4.91 ms → 30.3 µs
- Route quality
- 11.53% longer → effectively exact
- Distance model
- 17×31 compile-time grid, 97 positions, 36.8 KB baked tables
- Runtime boundary
- Rust/WASM optimizer called from Node.js
On this page
Context
Vivaldi’s ERP would print pick lists, not routes. A worker would scan an order, receive a flat list of parts and quantities, and still have to decide how to walk the warehouse. That would be tolerable for small orders. It would break down on batch picks spanning 9 aisles plus a smaller side section, where backtracking became part of the job.
From September 2019 to February 2022 I was the sole engineer on the system that closed that gap: a React client, a Node.js API (Node.js 16 in the last deployed version), internal services, and Microsoft SQL Server shared with the ERP. Operators would drive everything through USB barcode scanners. Beyond routing, the system would handle bidirectional inventory search (position to item, item to positions), export warehouse snapshots to Excel, process bulk imports from Excel/CSV with typed parse and semantic errors, and batch log entries before emailing them to administrators. The Node.js service would also call Rust/WASM optimizer modules, so the routing core could move away from brittle native dependencies without changing the operator workflow.
The operational constraints shaped everything. The server was a shared Debian box with 4 GB of RAM and no outbound internet access. Deployment was manual, three systemd units, no CI/CD, and, at the customer’s explicit request, no Docker. Every dependency with a native build step became a liability, because the C++ toolchain had to exist on the production server just to install the app.
The optimizer was measured two ways. During the engagement I validated the production path against real orders: routes reviewed on the floor, before-and-after comparisons when the optimizer changed, and solve times tracked on every run. Later, out of my own interest and research, I solidified that early evidence into a proper benchmark suite: representative solver profiles in Rust/WASM, compile-time floor-grid validation, generated distance tables, and an exact route-ordering referee for scenarios small enough to solve provably. The exact solver never belonged in the live request path. Its job was to grade the fast heuristic, and some solves needed hours of offline proof work. That split let the system stay practical on a 4 GB server while still giving me defensible numbers: solve time moved from 4.91 ms to 30.3 µs, and the finished routes became effectively identical in length to the provably shortest routes on the benchmark set.
How a Location is Addressed
Every location in the warehouse has a compact code like E5.2. Everything before the dot is the floor position (aisle E, rack 5), and the number after the dot is the shelf tier. Separating the two let me optimize walking distance and ergonomics independently.
Try the warehouse model: play the route once, then click E5 or H2 to see why walking distance and shelf height are separate problems.
Route manifest · 8 picks
- 00StartEntrance
- 01A2.5pick
- 02B3.2pick
- 03A5.2pick
- 04C7.3pick
- 05E5.1pick
- 06I5.4pick
- 07H1.3pick
- 08H2.6pick
- 09ReturnEntrance
From Order to Route
A single item can be stored in multiple slots, so the problem is not just routing. Given a set of requested items with known storage locations and stock levels, the solver must choose which slots to visit, how many units to take from each, and in what order, so that the operator’s tour from the door and back is as short as possible.
The customer added two constraints on top of pure distance. First, prefer ground-level shelf tiers, to reduce reaching and bending. Second, drain nearly-empty slots before touching fuller ones: if an order requests 10 units of an item available at A1.1 (8 in stock) and B2.1 (50 in stock), take the 8 from A1.1 first, so inventory stays consolidated instead of leaving single-digit remnants scattered across the floor.
Without optimization, a batch order might send the operator to C6, across the warehouse to I5, back to H2, then west again for what was missed. An optimized route starts near the door and sweeps through adjacent aisles. The business did not need an optimization score. It needed a pick list an operator could follow on the spot, appearing within seconds of the barcode scan, or the tool would break the workflow it was supposed to speed up.
What Shipped
The production optimizer finished as a five-stage pipeline:
- choose stock locations using depletion and shelf-height rules
- reduce shelf slots to floor positions for walking distance
- use a generated floor model and measured rack-to-rack walking costs
- call the route-ordering core from the Node.js service, with Rust/WASM modules where the native boundary had become too brittle
- reattach shelf information so ground-level picks appeared first where possible
That shipped under deadline and fit the operator workflow. Its hard part was operational: the same route logic had to run on an air-gapped 4 GB server, avoid fragile install steps, and still return a pick list quickly enough that an operator trusted the scan.
Before any of that runs, the greedy selection stage needs to know which slots are still available and what quantities they hold. That comes from Microsoft SQL Server, the database shared with the ERP, through a repository layer whose only job is handing back plain typed records. Everything downstream of it, grid reduction, distance matrix, TSP solve, and regrouping, is a pure function over those records. Nothing below the repository layer knows SQL Server exists. That boundary is what let me unit-test the whole optimization core against fixtures, with no database to spin up.
The route-ordering boundary changed because the native C++ TSP dependency was a liability. The first version was a pragmatic call under deadline, not an abdication. I cared enough about the underlying algorithm to reimplement its core idea, simulated annealing, for a university algorithms assignment, and I also explored a genetic-algorithm approach to TSP for another exam. But in production the native dependency meant compiling C++ on an air-gapped 4 GB server at every install, and stack traces across the FFI boundary were useless. The Rust/WASM module gave the Node.js service a smaller, typed route-core boundary.
In daily use the system held up: operators would follow the routes, and a solve would come back well within the seconds an operator would tolerate after a scan. But production evidence alone leaves a gap: it tells you whether people can use the route, not how far that route is from the shortest possible one. The benchmark suite I later solidified exists to answer that second question without putting an exact solver in front of an operator.
The Rest of the System
Route optimization was the reason the project existed, but most of the code served everything around it, and a solver without that surrounding product would have been a demo.
Barcode input everywhere. USB barcode scanners are keyboards with extra steps: they type the scanned code and press Enter. Every workflow in the client was designed around that, starting with the order-code scan that would kick off a pick. I unpack the trick, and where it breaks, in Barcode scanners are keyboards with extra steps.
Excel in, Excel out. A full warehouse snapshot would export to Excel, giving the team an offline audit trail. The reverse path mattered more: bulk imports from a canonical spreadsheet would be parsed and validated with typed errors (a malformed quantity is a different failure from a slot that does not exist), streamed to the database through a gRPC updater service, and applied atomically so a bad file could not leave the warehouse half-updated.
An error pipeline instead of silence. On an air-gapped server nobody watches logs. Application errors would flow from the logger through gRPC to a separate emailer service that would batch them and mail administrators. The emailer would run as its own systemd unit, so a crash there could not take the API down with it.
Route Quality and Runtime
The route core needed two properties that tend to fight each other: it had to answer quickly after a barcode scan, and it had to avoid plausible-looking routes that were quietly bad. The full benchmark suite came together after delivery, out of my own interest and research, and doubled as my deep dive into Rust and WebAssembly, the same exploration that later took me to Prisma. I kept representative solver profiles side by side in the same Rust/WASM codebase, so consecutive stages differ in exactly one decision and can be measured against the same exact solver. The numbers isolate one decision at a time: distance storage, slot representation, local route repair, and the WebAssembly boundary back into the Node.js service.
Computing the True Optimum
Every quality number on this page is a gap against the shortest route for the same positions the greedy selector already chose, not against a slightly better heuristic. I am not solving the full joint problem of slot choice plus route order in these benchmarks. I am using an exact referee to grade the route-ordering stage honestly. The ILP is far too slow to ship, but shipping is not its job. The exact solver’s job is simple: for the same chosen slots, find the shortest possible order to visit them and return to the door. With binary variables indicating that the route travels directly from position to position , and the precomputed walking distance, the objective is the plain tour cost
The classic difficulty with TSP as an integer program is subtour elimination. The Miller-Tucker-Zemlin (MTZ) formulation1 handles it with ordering variables. Degree constraints
give each node exactly one predecessor and one successor, but nothing yet prevents the solution from splitting into disjoint cycles. MTZ adds an integer position variable per non-depot node, with and , and the constraints
Whenever edge is used, : positions strictly increase along the tour. A subtour that avoids the depot would need its positions to increase forever around a cycle, a contradiction, so only one tour through the depot survives.
A picker’s route is an open path, not a cycle, so the formulation adds a virtual end node : the edge is forced and free, the edge is forbidden, and when scoring round trips the edge carries the cost of walking from pick back to the door. That last detail matters more than it looks: a solver that ignores the walk back prefers tours that end in the far corner of the warehouse.
I validated the exact solver the boring way: on 8-node instances, brute force over all orderings and assert the ILP matches the exhaustive minimum, for both the open-path and round-trip variants. The exact solver can run for hours, so scenarios that hit the timeout are quarantined into a separate stress pack, and the headline quality number only includes routes the ILP solved exactly. An honest quality curve needs an honest denominator.
The Ladder
Every stage builds the route the same way, with the nearest-neighbor rule: start at the door, always walk to the closest remaining pick. What changes from stage to stage is how distances are stored and queried, until the last two stages, which improve the finished route. Distances come in two flavors throughout: a uniform lens, where every floor tile costs the same, and a weighted lens using physical meters. All figures below are cold-start means on the exact-solvable scenario pack, uniform lens, per-order solves of roughly 10–30 picks, measured on Apple Silicon.
Mean gap is the average extra route length versus the exact shortest route for the same picked slots. A gap of 0.00% means the benchmarked routes matched the exact answer at the reported precision.
| Stage | What changed | Mean solve | Mean gap vs optimal | Allocations/solve |
|---|---|---|---|---|
| MVP | grid parsed at runtime, per-query Dijkstra memoized under (String, String) keys | 4.91 ms | 11.53% | ~6,800 |
| JPS detour | per-query Jump Point Search, no memoization (uniform-only) | 1.00 ms | 11.53% | ~5,500 |
| Precomputed | build-time Dijkstra tables, u32 entries, label-hash lookup | 45.8 µs | 11.53% | 97 |
| Compressed | u16 tables + bit-packed SlotId fast path | 25.5 µs | 11.53% | 97 |
| Route refined | return-leg-aware 2-opt | 27.0 µs | 0.00% | 97 |
| Current | + or-opt relocation | 30.3 µs | 0.00% | 97 |
The MVP did what most first versions do: it worked, and it hid its costs in the allocator. Every distance query keyed a HashMap with a pair of heap-allocated strings. A fully warm long-lived process held about 717 KB of memoized distances, roughly 20× the size of the tables that eventually replaced it, and every cold solve paid around 6,800 heap allocations.
The JPS detour. Jump Point Search had shipped in the original system via the pathfinding library, and I also tested it in the Rust/WASM route core. It was, however, the wrong tool, for three reasons. First, JPS’s symmetry pruning is only correct on uniform-cost grids. The moment distances become physical meters (rack bays 2.7 m, corridors at 1.2 m), its core assumption breaks, so it could never serve the weighted distance lens at all. Second, the problem shape is wrong: JPS accelerates single-shot queries on large open maps, but this solver needs all-pairs distances among 97 fixed positions on a 527-cell grid, which wants one precomputation, not a clever per-query search. Third, warehouse grids are corridor-dense, so jump points appear at nearly every cell and the pruning saves little expansion. The visualizer below runs both A* and JPS on the real floor plan, so you can watch how much the pruning actually skips.
A* expands 177 nodes from A2 to I5 in this uniform-cost example.
JPS expands 13 nodes from A2 to I5 in this uniform-cost example.
The measurements added a fourth reason the table above hides. JPS looks 5× faster than the MVP, but that is a cold-start comparison. Because JPS recomputes every query from scratch, it cannot benefit from a warm process. In a long-lived process with the same heuristic, the string-keyed memo answered warm solves in about 35 µs while JPS still took about 951 µs, every order, forever. The clever online search loses to a hash map.
Precomputation is where the latency story actually turned: 1.00 ms to 45.8 µs, not from a smarter algorithm but from moving the work to a phase where its cost is irrelevant.
Compiling the Floor Plan
The single source of truth for the floor plan is grid.txt, a 17×31 annotated ASCII grid: 96 rack labels plus the door, walkable cells, and shelf counts. A build.rs script parses it at compile time and runs Dijkstra’s shortest-path algorithm, 8-directional, from all 97 sources, twice: once with tile-uniform costs and once with physical-meter weights. The generated code bakes both all-pairs tables into the binary, so a runtime distance lookup is one array index. Codegen guardrails fail the build if any position is unreachable from any other (a disconnected floor plan) or if a scaled distance overflows u16. A malformed warehouse cannot compile.
# Warehouse floor plan, 17 rows x 31 columns
#
# Cell types:
# 0 walkable floor (corridor)
# 1 shelf/obstacle (not walkable)
# A1:6 rack label : total pallet positions (named pickup point, walkable)
# @ entry door (implicit tour start)
# $ depot (reserved for future use)
# ! exit (reserved for future use)
#
# Slot naming: "A4.3" = aisle A, rack 4, slot 3
# Each rack has (N/3) columns × 3 floors.
# Slots numbered column-by-column, bottom to top:
# col 1: .1 (ground), .2 (mid), .3 (high)
# col 2: .4 (ground), .5 (mid), .6 (high)
# col 3: .7 (ground), .8 (mid), .9 (high)
# col 4: .10 (ground), .11 (mid), .12 (high)
# Ground level = (slot - 1) % 3 == 0 → {1, 4, 7, 10}
#
# Entry door: bottom-left corner (@)
[ 0, 0, 0, 0, 0, B1:6, 1, 1, C1:6, 0, 0, 0, D1:6, 1, 1, E1:6, 0, 0, 0, F1:6, 1, 1, G1:6, 0, 0, 0, H1:12, 1, 1, I1:12, 0 ]
[ A1:6, 0, 0, 0, 0, B2:6, 1, 1, C2:6, 0, 0, 0, D2:6, 1, 1, E2:6, 0, 0, 0, F2:6, 1, 1, G2:6, 0, 0, 0, H2:12, 1, 1, I2:12, 0 ]
[ 1, A2:6, 0, 0, 0, B3:6, 1, 1, C3:6, 0, 0, 0, D3:6, 1, 1, E3:6, 0, 0, 0, F3:6, 1, 1, G3:6, 0, 0, 0, H3:12, 1, 1, I3:12, 0 ]
[ 1, A3:6, 0, 0, 0, B4:6, 1, 1, C4:6, 0, 0, 0, D4:6, 1, 1, E4:6, 0, 0, 0, F4:6, 1, 1, G4:6, 0, 0, 0, H4:12, 1, 1, I4:12, 0 ]
[ 1, A4:6, 0, 0, 0, B5:6, 1, 1, C5:6, 0, 0, 0, D5:6, 1, 1, E5:6, 0, 0, 0, F5:6, 1, 1, G5:6, 0, 0, 0, H5:12, 1, 1, I5:12, 0 ]
[ 1, A5:6, 0, 0, 0, B6:6, 1, 1, C6:6, 0, 0, 0, D6:6, 1, 1, E6:6, 0, 0, 0, F6:6, 1, 1, G6:6, 0, 0, 0, H6:9, 1, 1, I6:9, 0 ]
[ 1, A6:6, 0, 0, 0, B7:6, 1, 1, C7:6, 0, 0, 0, D7:6, 1, 1, E7:6, 0, 0, 0, F7:6, 1, 1, G7:6, 0, 0, 0, H7:9, 1, 1, I7:9, 0 ]
[ 1, A7:6, 0, 0, 0, B8:6, 1, 1, C8:6, 0, 0, 0, D8:6, 1, 1, E8:6, 0, 0, 0, F8:6, 1, 1, G8:6, 0, 0, 0, 0, 1, 1, 0, 0 ]
[ 1, A8:6, 0, 0, 0, B9:6, 1, 1, C9:6, 0, 0, 0, D9:6, 1, 1, E9:6, 0, 0, 0, F9:6, 1, 1, G9:6, 0, 0, 0, 0, 1, 1, 0, 0 ]
[ 1, A9:6, 0, 0, 0, B10:6, 1, 1, C10:6, 0, 0, 0, D10:6, 1, 1, E10:6, 0, 0, 0, F10:6, 1, 1, G10:6, 0, 0, 0, 0, 0, 0, 0, 0 ]
[ 1, A10:6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, D11:9, 1, 1, E11:9, 0, 0, 0, F11:9, 1, 1, G11:9, 0, 0, 0, 0, 0, 0, 0, 0 ]
[ 1, A11:9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, D12:9, 1, 1, 0, 0, 0, 0, F12:9, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]
[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]
[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]
[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]
[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, J5:9, J4:9, J3:9, J2:9, J1:9, 0 ]
[ @, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0 ] This idea came to me while I was preparing for a Functional Programming exam at university. Parsing a small warehouse DSL felt like a good use case for Haskell’s pattern matching, so I wrote the first grid-updater generator there. It produced the TypeScript grid module for the delivered system. The Rust/WASM route core folded the same idea into build.rs, where the compiler enforces what the Haskell tool could only generate.
Squeezing the Tables: SlotId
The u32 tables weighed 73.6 KB. I cut that in half by observing that no scaled distance on this floor plan exceeds 65,535, so u16 entries were enough: 36.8 KB total, and 32 entries per 64-byte cache line instead of 16. Then I removed label hashing by packing slot labels like G11.12 into three small bounded components inside a u16:
u16 layout (MSB → LSB): [ 4 unused | aisle:4 | rack:4 | shelf:4 ]
"G11.12" → aisle_idx=6, rack_idx=10, shelf_idx=11 → (6 << 8) | (10 << 4) | 11 = 0x06AB = 1707Decoding is three branchless shift-and-mask operations, and the round trip is lossless:
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SlotId(u16);
impl SlotId {
#[inline]
pub fn aisle(&self) -> u8 { ((self.0 >> 8) & 0xF) as u8 + b'A' }
#[inline]
pub fn rack(&self) -> u8 { ((self.0 >> 4) & 0xF) as u8 + 1 }
#[inline]
pub fn shelf(&self) -> u8 { (self.0 & 0xF) as u8 + 1 }
}
impl fmt::Display for SlotId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}{}.{}", self.aisle() as char, self.rack(), self.shelf())
}
} SlotId is a Copy newtype over u16: no heap, single-instruction hashing, and its natural integer ordering matches lexicographic slot ordering for free, because the aisle occupies the highest 4-bit field. The old representation was a four-field struct dragging a heap-allocated original_key: String through every pick, sort, and comparison, around 32 bytes plus an allocation, for data that fits in 2 bytes. The string form still exists where humans need it (Display reconstructs "G11.12" on demand at output boundaries), but it stopped being the working representation.
I considered a flat index (aisle * 192 + rack * 16 + shelf), which packs even denser. I kept the fixed 4-bit fields instead: decoding takes shifts and masks where the flat index takes a division and a modulo, and the hex value remains readable: 0x06AB is aisle 6, rack 10, shelf 11 at a glance. When a route dump misbehaves, that last property earns its keep. As a bonus, a SlotId crosses the WASM boundary as a bare JavaScript number, and the same 12-bit arithmetic decodes it on the TypeScript side, with no serialization format to disagree about.
Closing the Quality Gap
Storage work changed nothing about route quality. The mean gap sat at 11.53% through four stages because the routes themselves were still greedy. Always walking to the closest remaining pick tends to strand the operator in a far corner, with the door on the other side of the floor. The classic repair is 2-opt: take two legs of the route, reconnect them the other way around, keep the change if the total walk shrinks, and repeat until nothing improves. The textbook version compares only the forward path. Mine also counts the walk back to the door in every comparison. That one adjustment closed the mean gap to 0.00% on the exact-solvable pack. A final or-opt pass, which pulls a short run of one to three stops out of the route and reinserts it wherever the total shrinks, polishes the remaining scenarios for about 3 µs more per solve. On the weighted lens the story repeats: 7.03 ms at 8.83% mean gap for the MVP, 29.5 µs at 0.00% for the current stage, and the JPS stage recorded explicitly as unsupported.
Matching the exact solver with a fast heuristic is less magic than it sounds: at 10–30 picks per order these instances are small, and the exact solver exists precisely to say when small stops being easy. The useful sentence is concrete: on the exact-solvable pack, the mean gap rounds to 0.00% in microseconds.
The Wasm Boundary
The optimizer compiles to WebAssembly, and tsify generates the TypeScript types directly from the Rust structs, so the two sides cannot drift. The JavaScript wrapper around the module is about 18 lines. The boundary returns typed errors: malformed slot labels are rejected during deserialization, and labels that parse but reference racks missing from the floor plan surface as a catchable JS exception with a message naming the offending position, instead of a panic that aborts the WASM instance.
By the Numbers
| Metric | Value |
|---|---|
| Engagement | Sep 2019 – Feb 2022, sole engineer |
| Runtime | Node.js 16 in the last deployed version, with experimental Rust/WASM optimizer modules |
| Server | shared Debian box, 4 GB RAM, air-gapped, no Docker (by customer request) |
| Floor plan | 17×31 grid (527 cells), 97 positions (96 racks + door) |
| Order size | roughly 10–30 picks per solve |
| Solve time, MVP → current | 4.91 ms → 30.3 µs (~160×) |
| Mean gap vs exact optimum | 11.53% → 0.00% (uniform), 8.83% → 0.00% (weighted) |
| Heap allocations per solve | ~6,800 → 97 |
| Distance lookup state | a 717 KB warm HashMap replaced by one index into 36.8 KB compile-time tables |
SlotId | aisle/rack/shelf packed into 12 bits of a u16 |
| Exact solver validation | brute force over all 5,040 permutations of 8-node instances |
| Exact solver cap | hours, with timeout-prone scenarios quarantined from the headline average |
What I’d Do Differently
Build the exact solver sooner. Validating against real orders and tracking solve times catches regressions, but it cannot say how much better a route could have been. The rough checks I had during the engagement caught regressions, but the exact solver and scenario packs came years later, and they were small enough to have been built upfront as optimizer infrastructure rather than polish. They sharpen every tuning decision that follows.
Design the error boundary before the happy path. The first WASM boundary was panic-based: malformed input aborted the entire instance, which in a long-lived server process is the worst possible failure mode. Typed errors at the boundary cost little. Panicking internals are fine. A panicking public surface is not.
Treat the compile-time-frozen layout as the scale trade-off it is. Baking the floor plan into the binary gives guardrails I love (an unreachable rack is a compile error) and lookups that cost one array index. But it means a layout change requires a recompile and a redeploy. For one warehouse with a stable floor plan and one engineer, that trade-off is right. For a product serving many warehouses it would be wrong, and the honest answer is that the architecture encodes the business context it was built in.