Building Testable, Maintainable E-commerce Systems Beyond Hooks and Globals
The WooCommerce technical debt trap
Typical WooCommerce customization stacks hooks and globals—fine for small catalogs, painful when rules compound. Debugging turns into archaeology: stack traces disappear into do_action() chains, state hides behind global $product, and automated tests drag in the full WordPress bootstrap.
Symptoms creep in: a pricing tweak works alone but fights dynamic coupons; checkout edge cases fail under concurrency; SQL multiplies because caches cannot see dependencies across procedural code. The underlying issue is coupling—UI, domain rules, and persistence smeared together.
The globals anti-pattern
global $woocommerce, $product, $post creates implicit dependencies static analysis cannot see. Refactors fail quietly because types appear only at runtime. DI replaces that with constructor-injected contracts—still wired through WordPress at the edges, but explicit in your code.
Clean Architecture layers for WooCommerce
Four rings, one rule: dependencies point inward. Inner layers stay framework-agnostic; outer layers talk to WooCommerce and WordPress.
Layer 1: Domain (pure PHP)
Rules that could run in a CLI or another framework. No WC_*, no wpdb.
// src/Domain/ValueObjects/Money.php
namespace Acme\WooEngine\Domain\ValueObjects;
final class Money
{
private function __construct(
private int $cents,
private string $currency
) {}
public static function fromDecimal(float $amount, string $currency): self
{
return new self((int) round($amount * 100), $currency);
}
public function currency(): string
{
return $this->currency;
}
public function add(Money $other): self
{
$this->assertSameCurrency($other);
return new self($this->cents + $other->cents, $this->currency);
}
public function subtract(Money $other): self
{
$this->assertSameCurrency($other);
return new self(max(0, $this->cents - $other->cents), $this->currency);
}
public function toDecimal(): float
{
return $this->cents / 100;
}
private function assertSameCurrency(Money $other): void
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Currency mismatch');
}
}
}
Layer 2: Application (use cases)
Orchestrates domain objects; declares ports (interfaces) infrastructure will implement.
// src/Application/Contracts/ProductRepositoryInterface.php
namespace Acme\WooEngine\Application\Contracts;
use Acme\WooEngine\Domain\Entities\CustomProduct;
interface ProductRepositoryInterface
{
public function findById(int $id): ?CustomProduct;
public function save(CustomProduct $product): void;
}
// src/Application/Contracts/DiscountRepositoryInterface.php
namespace Acme\WooEngine\Application\Contracts;
interface DiscountRepositoryInterface
{
/** @return list<object{amount: float}> */
public function findActiveForUser(int $userId): array;
}
// src/Application/Services/PricingEngine.php
namespace Acme\WooEngine\Application\Services;
use Acme\WooEngine\Application\Contracts\DiscountRepositoryInterface;
use Acme\WooEngine\Domain\ValueObjects\Money;
final class PricingEngine
{
public function __construct(
private DiscountRepositoryInterface $discounts
) {}
public function calculateFinalPrice(Money $base, int $productId, int $userId): Money
{
$total = 0.0;
foreach ($this->discounts->findActiveForUser($userId) as $d) {
$total += $d->amount;
}
return $base->subtract(Money::fromDecimal($total, $base->currency()));
}
}
Layer 3: Infrastructure (WordPress / WooCommerce)
Implements ports with wpdb, WC_Product, REST, etc.
// src/Infrastructure/Persistence/WpdbProductRepository.php
namespace Acme\WooEngine\Infrastructure\Persistence;
use Acme\WooEngine\Application\Contracts\ProductRepositoryInterface;
use Acme\WooEngine\Domain\Entities\CustomProduct;
final class WpdbProductRepository implements ProductRepositoryInterface
{
public function __construct(private \wpdb $db) {}
public function findById(int $id): ?CustomProduct
{
$row = $this->db->get_row($this->db->prepare(
"SELECT * FROM {$this->db->posts} WHERE ID = %d AND post_type = %s",
$id,
'custom_product'
), ARRAY_A);
return $row ? $this->hydrate($row) : null;
}
private function hydrate(array $row): CustomProduct
{
return new CustomProduct(
id: (int) $row['ID'],
name: $row['post_title'],
meta: (string) get_post_meta((int) $row['ID'], '_custom_data', true)
);
}
public function save(CustomProduct $product): void
{
// Persist via wp_insert_post / meta — omitted
}
}
PSR-4 autoloading
Composer maps namespaces to src/; no manual require_once trees.
{
"name": "acme/woocommerce-engine",
"type": "wordpress-plugin",
"require": {
"php": "^8.1",
"php-di/php-di": "^7.0"
},
"autoload": {
"psr-4": {
"Acme\\WooEngine\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Acme\\WooEngine\\Tests\\": "tests/"
}
}
}
acme-woo-engine/
├── composer.json
├── phpunit.xml
├── src/ # Acme\WooEngine\
│ ├── Domain/
│ ├── Application/
│ │ ├── Contracts/
│ │ └── Services/
│ └── Infrastructure/
│ ├── Persistence/
│ ├── WooCommerce/
│ └── WordPress/
├── tests/
│ ├── Unit/Domain/
│ └── Integration/
└── woocommerce-engine.php
Dependency injection with PHP-DI
Bind interfaces to concrete classes; wrap WordPress globals as factories where needed.
// src/Infrastructure/Container/ContainerFactory.php
namespace Acme\WooEngine\Infrastructure\Container;
use Acme\WooEngine\Application\Contracts\ProductRepositoryInterface;
use Acme\WooEngine\Infrastructure\Persistence\WpdbProductRepository;
use DI\ContainerBuilder;
final class ContainerFactory
{
public static function build(): \Psr\Container\ContainerInterface
{
$builder = new ContainerBuilder();
$builder->addDefinitions([
\wpdb::class => \DI\factory(static function (): \wpdb {
global $wpdb;
return $wpdb;
}),
ProductRepositoryInterface::class => \DI\autowire(WpdbProductRepository::class),
]);
return $builder->build();
}
}
Plugin bootstrap
<?php
/**
* Plugin Name: WooCommerce Engine (PSR-4 / DI)
* Version: 2.0.0
*/
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Acme\WooEngine\Infrastructure\Container\ContainerFactory;
use Acme\WooEngine\Infrastructure\WooCommerce\ProductTypeRegistrar;
$container = ContainerFactory::build();
add_action('woocommerce_loaded', static function () use ($container): void {
$container->get(ProductTypeRegistrar::class)->register();
});
add_action('rest_api_init', static function () use ($container): void {
$container->get(\Acme\WooEngine\Infrastructure\Rest\PricingController::class)->registerRoutes();
});
Custom product type (illustrative)
Keep WC_Product subclasses thin; inject PricingEngine from the container—avoid calling ContainerFactory::build() inside the product constructor in production (service locator anti-pattern). Prefer factory registration that passes dependencies explicitly.
// Sketch: resolve PricingEngine once, inject via factory / setter
final class SubscriptionBoxProduct extends \WC_Product
{
public function __construct($product = 0, private ?PricingEngine $pricing = null)
{
parent::__construct($product);
$this->product_type = 'subscription_box';
}
public function get_price($context = 'view')
{
if ($this->pricing === null) {
return parent::get_price($context);
}
$base = Money::fromDecimal((float) parent::get_price('edit'), get_woocommerce_currency());
$final = $this->pricing->calculateFinalPrice($base, $this->get_id(), get_current_user_id());
return (string) $final->toDecimal();
}
}
Testing advantage
PricingEngine depends on DiscountRepositoryInterface—PHPUnit can inject a mock and run rules without loading WordPress:
public function test_applies_discounts(): void
{
$mock = $this->createMock(DiscountRepositoryInterface::class);
$mock->method('findActiveForUser')->willReturn([
(object) ['amount' => 10.0],
(object) ['amount' => 5.0],
]);
$engine = new PricingEngine($mock);
$result = $engine->calculateFinalPrice(Money::fromDecimal(100, 'USD'), 1, 1);
self::assertSame(85.0, $result->toDecimal());
}
Migration path: hooks to architecture
-
Phase 1: Extract domain
Move pure calculations out of
functions.phpintosrc/Domain/; behavior unchanged. -
Phase 2: Repositories
Wrap
$wpdb/WP_Querybehind interfaces inInfrastructure\Persistence. -
Phase 3: Container
Wire new features through PHP-DI; legacy hooks coexist.
-
Phase 4: Tests
Fast unit tests for domain/application;
WP_UnitTestCasefor integration paths.
For a real store, architecture should follow the business rule it protects: pricing, catalogue eligibility, customer roles, fulfilment, subscriptions or an external integration. See WooCommerce engineering for delivery work and custom plugin development when the correct product is a maintained extension rather than another theme-level snippet.
Performance notes
- Compiled container: PHP-DI can compile definitions for production to cut reflection cost.
- Lazy services: Defer heavy repos until first use where the container supports it.
- Centralized queries: Repositories are the place to batch-load and cache hydrate.
Frequently asked questions
- Does PSR-4 conflict with WordPress coding standards?
-
No. PSR-4 is autoloading layout; you can still follow WPCS for spacing and naming inside files. Composer’s autoloader is loaded from
vendor/autoload.phpin your plugin bootstrap. - Will a DI container slow WooCommerce?
-
Well-configured containers add negligible overhead versus manual
newchains; compilation and avoiding per-request rebuilds matter. Profile your stack—bottlenecks are usually SQL and plugins, not DI resolution alone. - How do I test code that calls WordPress functions?
-
Keep unit tests on domain/application (no WP). Use integration tests with
WP_UnitTestCasefor infrastructure that callsget_post_meta, cart APIs, etc. - Can this coexist with other WooCommerce plugins?
-
Yes. Adapters register hooks (
woocommerce_product_class, checkout actions) at the boundary; core WooCommerce and third-party plugins keep working. - Is this overkill for small shops?
-
Often yes when you have a small catalogue, flat pricing and few integrations—core WooCommerce plus a small, well-contained extension can be enough. When rules, bundles, B2B tiers or connected systems grow in complexity, PSR-4 and DI become easier to justify. Pair the decision with a custom plugin development plan rather than adding business logic to a theme.
WooCommerce architecture review
PSR-4, DI, and clean layers for stores that outgrew hook soup.
PSR-4 migration planning, DI wiring, and testable domain modeling for complex stores.
From hook soup to typed, testable layers.
Discussion
0 comments
No comments yet.
Have a technical question, correction or a different interpretation? Add to the discussion.