WooCommerce Engineering 7–8 min read

PSR-4 & Dependency Injection in WooCommerce: Clean Architecture for Scale

A practical WooCommerce architecture guide to PSR-4, dependency injection, testable pricing rules and custom plugin design for complex stores.

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.

Clean Architecture layers: Domain at center, Infrastructure at outer

Infrastructure WC_Product subclasses wpdb repositories REST controllers DI container

Application Use cases DTOs · Ports Repository interfaces

Domain Entities · Value objects Domain services No WC_* imports

inward deps
Dependencies point inward: domain stays plain PHP; WooCommerce types live in infrastructure.

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

  1. Phase 1: Extract domain

    Move pure calculations out of functions.php into src/Domain/; behavior unchanged.

  2. Phase 2: Repositories

    Wrap $wpdb / WP_Query behind interfaces in Infrastructure\Persistence.

  3. Phase 3: Container

    Wire new features through PHP-DI; legacy hooks coexist.

  4. Phase 4: Tests

    Fast unit tests for domain/application; WP_UnitTestCase for 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.php in your plugin bootstrap.

Will a DI container slow WooCommerce?

Well-configured containers add negligible overhead versus manual new chains; 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_UnitTestCase for infrastructure that calls get_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.

WooCommerce engineering

PSR-4 migration planning, DI wiring, and testable domain modeling for complex stores.

Request architecture audit

From hook soup to typed, testable layers.

Technical note: These are illustrative PHP and WooCommerce architecture patterns, not a drop-in plugin. Validate your PHP, WooCommerce and Composer versions in staging before production. Reviewed: September 2026.

Share this article

Related posts

Discussion

0 comments

No comments yet.

Have a technical question, correction or a different interpretation? Add to the discussion.

Leave a reply