Skip to main content

The hidden friction of running a hybrid B2B trade portal

·1096 words·6 mins
Tech Web Development B2B

We recently rolled out version 1 of our new B2B trade portal. On paper, the architecture was a practical compromise. Our legacy WooCommerce setup held over seven years of complex variation SKU mappings that we simply weren’t willing to manually rekey or migrate into a new system. By keeping WordPress as the back-end source of truth and building a decoupled, custom web application for the customer-facing portal, we avoided a high-risk database migration. We also saved our team of four back-office admins from having to learn a completely new inventory tool.

But split systems are messy. While decoupling solved our immediate frontend scaling concerns, it introduced logic drift and cache lag. We traded database migration pain for recurring coordination overhead. When you split an architecture like this, you aren’t just running two codebases; you are committing to keeping their hidden assumptions aligned.

Local environment friction and the CLI workaround
#

Illustration for Local environment friction and the CLI workaround Even our local environments felt this split. Our local Docker stack runs isolated containers for the custom portal app and WordPress. Early on, I needed to reset a local WordPress admin account password to test a new checkout flow, but our production-like WordPress container didn’t bundle wp-cli on its standard system path.

This blocked our QA team from running the initial database test seed, which stole a couple of hours from our test seeding sequence before we worked around it. Instead of rebuilding the local base image, we resolved it by spinning up a transient helper container to run our administrative tasks:

docker run --rm \
  --volumes-from local-wordpress-container \
  --network container:local-wordpress-container \
  wordpress:cli wp user update admin-user --user_pass="temporary-dev-password"

It was an annoying detour. It showed us that local environments end up absorbing the same split-system friction as production, even for basic administrative tasks.

How our shipping business logic split in two
#

Illustration for How our shipping business logic split in two One of our first real headaches showed up as a shipping calculation bug during staging testing. A tester checking out with a heavy crate destined for postcode 3000—inside the Melbourne CBD—was quoted $220.00 by the WooCommerce backend. However, our new portal frontend stubbornly displayed $187.00.

We tracked this down to formula drift. Because we had built the frontend as a separate app, our custom WordPress API connector (Trade_Mapping_API::calculate_shipping()) ended up with its own replicated freight logic. That code fell out of sync when we updated our active core shipping plugin (slated_shipping). The API connector was still running an outdated formula of $140 base plus $30 per crate, while the real business logic had evolved. To fix this, we stopped trying to keep both versions in sync and refactored the API to delegate calculations to the main engine whenever it was active:

// In trade_mapping_plugin/includes/class-trade-mapping-api.php
if (class_exists('SSC_Shipping_Calculator')) {
    $quote = SSC_Shipping_Calculator::calculate_quote($postcode, $items);
} else {
    // Legacy fallback only if the primary calculator is missing
    $quote = $this->run_legacy_formula($postcode, $items);
}

This fixed the calculation mismatch behind the scenes, but it left us with an awkward client-side problem. If a customer has their quote drawer open while the rate updates, the UI will still show the stale estimate until they refresh the page. We spent some time debating whether to build a polling mechanism to fix this, but ultimately decided to accept it as a known limitation for version 1. The trade-off felt reasonable: even if a user sees a stale price in their browser drawer, the final quote submission re-resolves the trusted price server-side at the moment of creation anyway. The business is safe, even if the UI can occasionally look slightly out of sync.

The high cost of stale pricing caches
#

Illustration for The high cost of stale pricing caches Our portal relies on an admin-triggered CSV import that maps raw supplier data directly to WooCommerce variation SKUs. If an administrator updates a price or maps a new SKU in WordPress, customers need to see that change on the front-end portal immediately.

We initially relied on standard cache TTLs to clear out old data on the front-end. That was a mistake: in B2B commerce, a cached trade price running below the current wholesale rate is a margin exposure we can’t easily track after the fact.

To fix this, we built a direct cross-app cache invalidation bridge. We added a lightweight invalidation endpoint on the portal side (public/api/trade-cache-invalidate.php) and wrote our WordPress administrative plugin to ping it whenever an admin saves a record:

// Triggered in class-trade-mapping-admin.php on save or bulk update
$response = wp_safe_remote_post($portal_url . '/api/trade-cache-invalidate.php', [
    'headers' => [
        'Authorization' => 'Bearer ' . $shared_api_key,
        'Content-Type'  => 'application/json'
    ],
    'body' => json_encode(['action' => 'clear_catalog_cache'])
]);

Now, when an admin uploads a CSV or remaps a SKU, the plugin immediately hits the API and clears the portal’s file-based catalog cache. Setting this up meant keeping track of an extra environment variable for the portal URL in our WordPress settings, but it eliminated the window between an admin update and a price-correct front end.

Dealing with version skew during rolling deployments
#

Illustration for Dealing with version skew during rolling deployments When we deploy updates across a split stack, we cannot assume every environment receives an atomic update. We learned this when our staging site suddenly threw a fatal PHP error:

Fatal error: Uncaught Error: Undefined constant Trade_Mapping_DB::OPTION_PORTAL_APP_URL

We were deploying a new portal URL setting field. The UI class (Trade_Mapping_Admin) had been updated to reference this new database key constant, but the database schema class (Trade_Mapping_DB) had not yet finished its update in that specific container’s PHP runtime. It was a classic version-skew bug.

To prevent transient errors during rolling deployments, I added a compatibility helper method in the UI class. It checks if the constant exists, and if not, gracefully falls back to the literal string key:

private static function get_portal_url_option() {
    if (defined('Trade_Mapping_DB::OPTION_PORTAL_APP_URL')) {
        return get_option(Trade_Mapping_DB::OPTION_PORTAL_APP_URL);
    }
    // Fallback during mixed-version deployments
    return get_option('trade_portal_app_url');
}

This kind of defensive coding isn’t elegant, but it keeps the admin interface alive while our containers complete their rolling updates.

Was the hybrid path worth it?
#

Despite the coordination headaches, the hybrid path was the right call for us. We delivered a fast, responsive checkout interface for our trade clients without touching the complex database mappings our back office relies on every day.

But we paid for that performance in coordination overhead. In retrospect, we underestimated how much time we would spend keeping our split logic in sync. If we were starting over, I would allocate a lot more time in the early sprints to design cache invalidation webhooks, handle version skew, and map out exactly where our business calculations should live. Splitting the system saved us from a massive migration, but it meant we had to become much more disciplined operators.

Related

The SPA pendulum: why we stopped building everything as an application
·1459 words·7 mins
Tech Architecture Web Development Engineering Decisions
For a decade, SPAs were the default answer regardless of the problem. Most teams paid for that overcorrection slowly. Here is how the trade-offs actually look, what the shift back to SSR is really about, and how to make the architecture call at the start of a project.
WordPress staging that actually mirrors production: what it took to get there
·1336 words·7 mins
Tech WordPress DevOps Infrastructure
Most WordPress staging setups test the wrong layer. This is what I learned building a production-fidelity workflow on a real Plesk server — including a Cloudflare incident that looked exactly like a WordPress problem until it didn’t.
Plugin drift comes from duplication, not carelessness — here's how we fixed it
·938 words·5 mins
Tech WordPress Git DevOps
Three sites, three slightly different copies of the same plugin, no clear canonical version. This is how we untangled it using Git subtree and wp-config.php constants — and what the process revealed about how plugin drift actually happens.