Skip to main content

ERP Architecture for Developers

The ERP is not a bolt-on — it is built on the same DDD layering as the rest of the plugin, in dedicated directories under wp-ecommerce-core/src/. This page is a reference map of how those layers connect and how the ERP stays in sync with commerce events. It is not a "build your own entity" tutorial; it exists so you can navigate the ERP code, call its services, and recognize the extension points.

For the generic DDD explanation (layer responsibilities, dependency direction) see Architecture → DDD Layers. This page is the ERP-specific instance of that pattern.


The four layers

Every ERP domain (invoicing, inventory, purchasing, accounting, CRM, HR) is implemented as the same vertical slice through four layers:

Presentation\Api\*ApiController → thin REST controller (routes + gate + shape)
│ resolves

Application\Services\*Service → business logic, validation, orchestration
│ uses

Infrastructure\Repositories\*Repository → SQL, persistence, extends AbstractErpRepository
│ reads / writes

Domain\Entities\* → plain data objects (getters, constructed from arrays)
LayerDirectoryCount (approx.)Role
Domain entitiessrc/Domain/Entities/48Plain PHP data objects (Invoice, Warehouse, JournalEntry, Employee, Lead, …). No base class, no persistence logic — constructed from an array, expose typed getters.
Repositoriessrc/Infrastructure/Repositories/37Own all SQL for a table/aggregate. ERP repositories extend AbstractErpRepository (below). Return / accept entities.
Application servicessrc/Application/Services/~28Business logic + validation + cross-repository orchestration (e.g. WarehouseService, AccountingService, PurchasingService, InvoiceService, MyDataService, FinancialReportService, HrService, CrmService). Pull dependencies lazily via the LazyResolvesServices trait.
API controllerssrc/Presentation/Api/*ApiController.phpThin: register routes, run the access gate as the permission_callback, resolve the service, and shape the response. No business logic.

Support code that isn't a domain slice lives under src/Erp/: ErpDatabaseManager (DB routing), ErpActivationService / ErpMigrator / ErpDatabaseManager (schema + lifecycle), ErpUserService (roles/RBAC), ErpRouteHandler (the /erp/ SPA), ErpDataMigrationService, and Erp/Integration/ (the sync listeners below).

How the layers wire together (DI)

Services and repositories are resolved through the plugin Container. Controllers don't new a service — they resolve it, typically lazily so a controller can register its routes without instantiating its whole dependency graph:

// inside an ERP controller
private function getService(): WarehouseService
{
return $this->lazyResolve(WarehouseService::class, $this->warehouseService);
}

Anywhere outside a controller, the global helper wpec_resolve(SomeClass::class) returns a container-built instance. Bindings for ERP services/repositories are registered in the plugin Bootstrap. See Dependency Injection for the container itself.


Optional separate ERP database (F146)

By default the ERP tables live in the same database as the storefront (wp_ec_* prefix). A store can optionally route the ERP to a separate database on the same MySQL server — configured via the /erp/test-connection + /erp/save-credentials endpoints and migrated with /erp/migrate-data/*.

AbstractErpRepository is what makes this transparent to the rest of the code:

  • It binds $this->wpdb to the ERP DB connection when ErpDatabaseManager::isConfigured() is true, and to the main global $wpdb otherwise.
  • Repositories that JOIN storefront tables use the base helpers mainTable() / wpUsersTable(), which return fully-qualified db_name.table references when ERP-routed so cross-database JOINs work.
  • When no separate DB is configured (the default), the base behaves identically to the pre-F146 global $wpdb pattern — no behaviour change, no migration needed.

Constraints to remember: the separate ERP DB must be on the same MySQL server as the main DB (cross-server JOINs don't work in standard MySQL), and AbstractErpRepository tracks transaction depth statically across all ERP repositories (it detects — and warns on — accidental nested transactions rather than silently breaking atomicity).

The practical takeaway for a developer: extend AbstractErpRepository for any ERP-side persistence and use its table helpers for cross-DB JOINs; you get the routing, connection selection, and transaction safety for free.


Commerce ↔ ERP integration listeners

The ERP must react to storefront events — a product is saved, a warehouse is created, a customer registers — and propagate the change to the ERP side (which may be a different database). That sync is done by a small set of integration listeners under src/Erp/Integration/, wired through a single bootstrap with a public extension filter.

The contract

interface IntegrationListenerInterface
{
// Called once during plugin init; the listener subscribes to its hook(s) here.
public function register(): void;
}

Concrete listeners extend AbstractIntegrationListener, which supplies the shared scaffold:

  • safely(string $context, callable $fn, string $level = 'error') — runs a side-effect and swallows + logs any Throwable to the plugin.erp.integration channel, so an ERP-side write failure never fails the originating storefront request (the degraded-mode contract). Listeners wrap only the side-effect body in safely(); short-circuit guards (instanceof / routing / policy) stay inline above it. Handlers that must propagate an exception (e.g. a pre-transition validation gate) deliberately do not use safely().
  • logInfo(string $message) — an informational log on the same channel.
  • The LazyResolvesServices trait, so each listener gets lazy DI without re-declaring it.

The shipped listeners

ListenerReacts toKeeps in sync
ProductInventorySyncProduct save / variant transitionsProduct ↔ stock-level records; enforces the simple→variable pre-transition gate
WarehouseInventorySyncWarehouse create/changeWarehouse ↔ stock-level rows
CustomerContactSyncCustomer register / updateCustomer ↔ CRM contact
ProductCostSyncProduct cost changesProduct cost ↔ inventory valuation

Registration + the extension point

IntegrationBootstrap::register() is the single entry point (called once on plugin init). It resolves each listener from the container, verifies it implements IntegrationListenerInterface, and calls register() — with per-listener error isolation so one broken listener can't cascade-kill the others.

The listener list is filterable, which is the public extension seam for adding or removing sync behaviour:

add_filter('wpec_erp_integration_listeners', function (array $classes): array {
$classes[] = \My\Erp\PriceListSync::class; // must implement IntegrationListenerInterface
return $classes;
});

Each class named by the filter must implement IntegrationListenerInterface, and its container binding must be registered so it can be resolved. A listener should no-op when ErpDatabaseManager::isConfigured() === false if its work only makes sense for separate-DB installs.


Recognizing the extension pattern

You are not expected to hand-build a 48-entity ERP. But when you need to extend it, the shape is consistent:

  • Add ERP data / an operation → follow the vertical slice: a Domain\Entities\* data object, an Infrastructure\Repositories\*Repository extending AbstractErpRepository, an Application\Services\*Service for the logic, and a thin Presentation\Api\*ApiController that gates via the LicenseGatedErp trait (the ERP gate) and resolves the service. Register the bindings in Bootstrap.
  • React to a commerce event from the ERP → write an IntegrationListenerInterface implementation (extend AbstractIntegrationListener), wrap side-effects in safely(), and add it via the wpec_erp_integration_listeners filter.
  • Gate a new ERP route → reuse LicenseGatedErp::erpModulePermissionCheck('erp_<module>', $request) as the permission_callback (or requireErpAdmin() for config/admin routes). See the ERP access gate.
First-party surface

The ERP internals (entities, repositories, services, the DDD bindings) are first-party: they evolve between releases and are documented here so you can build against your own store and understand the code. The two stable, intentionally-public seams are the REST API (gated as described) and the wpec_erp_integration_listeners filter. Pin to a store version for anything mission-critical.

Where to go next