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.php—Thin: 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​