Erp Customization Strategy

Production-grade guide to erp customization strategy covering architecture patterns, implementation strategies, testing approaches, and operational best practices for enterprise engineering teams.

An ERP customization strategy defines how an organisation systematically extends its core ERP system to meet unique business processes, integrating with other systems and evolving over time. It matters when the standard ERP features are insufficient and teams must balance flexibility, maintainability, and change velocity across multiple environments. This strategy governs how changes are created, versioned, tested, deployed, and managed across development, testing, and production, ensuring that customisations remain reliable and scalable through multiple releases.

Defining the Scope of Customization

Customization strategy begins with identifying what constitutes a customization. A customization is any deviation from the ERP’s out-of-the-box behavior that is not part of a standard update or configuration. This includes new fields, business logic, workflows, user interfaces, integrations, and data models.

The boundary between configuration and customization is defined by a simple rule: if a change can be expressed in the standard configuration UI (e.g., setting a default value, enabling a workflow, assigning a user to a role), it is configuration. Everything beyond that—code, metadata, or schema changes—is customization.

To codify this, a customization.yaml file is placed at the root of each customization module:

name: PurchaseOrderApproval
version: 1.2.0
type: extension
dependencies:
  - workflow-engine
  - document-management
description: "Custom approval workflow for POs with multi-level routing and email notifications"
source:
  repository: [email protected]:company/erp-customizations.git
  branch: main
  path: modules/purchase-order-approval

When the ERP system loads a module, it reads this file and applies the following logic:

A common failure mode: missing dependencies in customization.yaml causes the system to load modules out of order, leading to missing services and runtime exceptions like:

ERROR: ModulePurchaseOrderApproval failed to start: Cannot find service 'workflow-engine'
  Caused by: Module 'workflow-engine' not loaded
  Stack trace:
    at ModuleLoader.load (module-loader.js:42)
    at ModuleManager.start (module-manager.js:118)

Managing Custom Code and Extensions

Custom extensions are written in a standard module format, with a consistent directory structure:

src/
  main/
    java/
      com/
        company/
          erp/
            extensions/
              PurchaseOrderApprovalExtension.java
  resources/
    META-INF/
      services/
        com.company.erp.Extension
    application.properties
  test/
    java/
      com.company.erp.extensions.PurchaseOrderApprovalExtensionTest.java

The Extension interface is a standard Java interface defined in the ERP core:

public interface Extension {
    void initialize(ExtensionContext context);
    void start();
    void stop();
    void onEvent(String eventName, Map<String, Object> payload);
}

When the ERP boots, it discovers all Extension implementations via the META-INF/services directory. The ExtensionContext provides access to system services:

public interface ExtensionContext {
    ServiceRegistry getServices();
    Configuration getConfiguration(String module);
    void registerEventSubscriber(String eventName, Consumer<Map<String, Object>> handler);
    void publishEvent(String eventName, Map<String, Object> payload);
}

Each extension is expected to call registerEventSubscriber during initialize to listen for key business events:

public class PurchaseOrderApprovalExtension implements Extension {
    private final ExtensionContext context;

    public PurchaseOrderApprovalExtension(ExtensionContext context) {
        this.context = context;
    }

    @Override
    public void initialize(ExtensionContext context) {
        context.registerEventSubscriber("PO.Created", this::onPurchaseOrderCreated);
        context.registerEventSubscriber("PO.Approval.Requested", this::onApprovalRequested);
    }

    private void onPurchaseOrderCreated(Map<String, Object> event) {
        String poId = (String) event.get("poId");
        context.publishEvent("PO.Approval.Requested", Map.of("poId", poId, "approver", "finance-team"));
    }

    // ... other methods
}

A silent failure occurs when the ExtensionContext is not properly injected: if the Extension implementation is not in a META-INF/services/ file, the ERP loads the class but does not call initialize, leading to unregistered event handlers and missed business logic.

Versioning and Deployment Pipeline

Customizations are versioned using semantic versioning (SemVer) and deployed via a standard CI/CD pipeline using git, docker, and curl.

Each customization module is built as a JAR file using Maven:

cd modules/purchase-order-approval
mvn clean package

The output is target/purchase-order-approval-1.2.0.jar.

Deployment is orchestrated via a deploy.sh script that performs the following steps:

#!/bin/bash
set -e

MODULE_NAME="purchase-order-approval"
VERSION="$1"
JAR_PATH="target/${MODULE_NAME}-${VERSION}.jar"
DEPLOY_URL="https://erp.example.com/api/v1/deployments"

# Validate JAR exists
if [ ! -f "$JAR_PATH" ]; then
    echo "Error: JAR file not found: $JAR_PATH"
    exit 1
fi

# Upload JAR
echo "Uploading $JAR_PATH to $DEPLOY_URL"
curl -X POST \
  -H "Content-Type: application/zip" \
  -H "Authorization: Bearer $DEPLOY_TOKEN" \
  --data-binary @${JAR_PATH} \
  "${DEPLOY_URL}/${MODULE_NAME}/${VERSION}"

# Register module in ERP
curl -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $DEPLOY_TOKEN" \
  -d '{
    "module": "purchase-order-approval",
    "version": "1.2.0",
    "config": {
      "approvalThreshold": 5000,
      "emailTemplate": "po-approval.html"
    }
  }' \
  "${DEPLOY_URL}/modules"

# Restart ERP to load new module
curl -X POST \
  -H "Authorization: Bearer $DEPLOY_TOKEN" \
  "${DEPLOY_URL}/services/erp/restart"

A common mistake: the deploy.sh script uploads the JAR but forgets to register the module in the ERP’s module registry, causing the module to be loaded but not activated. The ERP logs show:

WARNING: Module 'purchase-order-approval' is loaded but not registered
  Missing module metadata in database: module_id = 'purchase-order-approval'

To avoid this, a module-register service is added to the ERP’s REST API, and the deployment script always calls it after uploading.

Testing and Validation

Testing is layered: unit, integration, and end-to-end tests are defined using standard tools.

Unit tests are run with mvn test and must cover all extension classes. Integration tests are run with a test database and a test ERP instance, using psql to verify data and curl to simulate API calls.

A key test script verifies that a purchase order triggers the correct approval workflow:

#!/bin/bash
set -e

# Start test ERP instance
docker run -d --name test-erp \
  -p 8080:8080 \
  -e POSTGRES_DB=erp_test \
  -e POSTGRES_USER=erp_test \
  -e POSTGRES_PASSWORD=erp_test \
  erp-system:latest

# Wait for ERP to be ready
until curl -f http://localhost:8080/health; do
  sleep 2
done

# Create a purchase order
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "supplier": "supplier-a",
    "items": [
      { "product": "widget", "quantity": 10, "price": 150 }
    ],
    "total": 1500
  }' \
  http://localhost:8080/api/purchase-orders

# Verify approval event was published
RESPONSE=$(curl -s http://localhost:8080/api/events?name=PO.Approval.Requested)

if ! echo "$RESPONSE" | grep -q '"approver":"finance-team"'; then
  echo "Test failed: Approval event missing finance-team as approver"
  exit 1
fi

echo "All tests passed"

A frequent failure: the test ERP instance starts but the database is not initialized. The erp_test database exists, but the purchase_orders table is missing. The test fails with:

ERROR: relation "purchase_orders" does not exist
  at com.company.erp.dao.PurchaseOrderDao.findAll(PurchaseOrderDao.java:45)

This happens because the docker run command does not wait for the database to be ready, and the test script runs before the schema migration is applied.

To fix this, a schema-migration.sh script is run before starting the ERP:

#!/bin/bash
psql -h localhost -U erp_test -d erp_test -c "CREATE TABLE purchase_orders (id SERIAL PRIMARY KEY, supplier TEXT, total DECIMAL);"

Configuration and Runtime Management

Configuration is managed through a central configuration service. The ERP reads configuration from a config/ directory, where each .json file represents a configuration group.

Example: config/purchase-order.json:

{
  "approvalThreshold": 5000,
  "emailTemplate": "po-approval.html",
  "autoApprove": {
    "minAmount": 10000,
    "approver": "ceo",
    "when": "created"
  },
  "workflow": {
    "stages": [
      {
        "name": "Finance Review",
        "required": true,
        "assignee": "finance-team"
      },
      {
        "name": "Procurement Approval",
        "required": true,
        "assignee": "procurement-manager"
      },
      {
        "name": "Final Signoff",
        "required": false,
        "assignee": "executive-team"
      }
    ]
  }
}

The ERP system loads this configuration during startup and makes it available via a REST endpoint: GET /api/v1/configuration/purchase-order.

A configuration change is applied at runtime by sending a PATCH request:

curl -X PATCH \
  -H "Content-Type: application/json" \
  -d '{
    "approvalThreshold": 7500,
    "workflow": {
      "stages": [
        {
          "name": "Finance Review",
          "required": true,
          "assignee": "finance-team"
        },
        {
          "name": "Procurement Approval",
          "required": true,
          "assignee": "procurement-manager"
        }
      ]
    }
  }' \
  http://erp.example.com/api/v1/configuration/purchase-order

The ERP applies the changes immediately and logs:

INFO: Configuration updated for 'purchase-order'
  Changes:
    approvalThreshold: 5000 → 7500
    workflow.stages[2].required: false → true

A subtle failure: the workflow object is not fully replaced. Instead, the ERP merges the new workflow object with the existing one, but only at the top level. Nested objects are merged shallowly:

// Before
"workflow": {
  "stages": [
    { "name": "Finance Review", "required": true, "assignee": "finance-team" },
    { "name": "Procurement Approval", "required": true, "assignee": "procurement-manager" }
  ]
}

// PATCH
"workflow": {
  "stages": [
    { "name": "Finance Review", "required": false }
  ]
}

Result: only the first stage is updated; the second stage is not replaced, and the assignee field is preserved from the original. This leads to confusion when a new stage is added but not properly merged.

To fix this, a deepMerge flag is added to the configuration service, and the ERP uses it for all configuration updates.

Managing Change and Evolution

As the ERP evolves, customizations must adapt to new versions of the core system. The strategy includes:

The ERP checks for migrations during startup:

# During ERP startup
for migration in $(find migrations/*.sql | sort); do
    echo "Applying migration: $migration"
    psql -f "$migration" -d erp_prod
done

A silent failure: the migration script is not idempotent. If a migration runs twice, it creates duplicate columns, leading to duplicate column errors:

ERROR: column "approval_status" of relation "purchase_orders" already exists
  at com.company.erp.schema.MigrationRunner.apply(MigrationRunner.java:67)

To prevent this, all migrations are prefixed with a -- if not exists check:

-- migrations/upgrade-1.2.0-to-1.3.0.sql
ALTER TABLE purchase_orders
  ADD COLUMN IF NOT EXISTS approval_status TEXT DEFAULT 'draft';

ALTER TABLE purchase_orders
  ADD COLUMN IF NOT EXISTS approved_by UUID REFERENCES users(id);

Summary of Key Principles

The strategy is not tied to any specific ERP system. It is a pattern that works across platforms, from SAP to Oracle to in-house systems. The same principles apply whether the ERP is hosted on-premises or in the cloud.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.