Production engineering guide for erp multi entity setup covering patterns, implementation strategies, and operational best practices.
Erp Multi Entity Setup enables a single ERP instance to manage multiple legal or operational entities—such as subsidiaries, divisions, or regional offices—each with its own chart of accounts, financial calendars, master data, and business processes. It matters when an organisation grows beyond a single-company ERP, typically during mid-scale expansion or acquisition integration. This setup is essential for centralised reporting, shared services, and cross-entity workflows, but it requires careful configuration to avoid data silos, duplicated effort, and reconciliation overhead.
Entities are defined through a tenant or entity table with the following core fields:
entity_id (UUID, primary key)name (string, 64 characters)code (string, 10 characters, unique)parent_entity_id (foreign key to entity_id)is_active (boolean, default true)financial_calendar_id (foreign key)chart_of_accounts_id (foreign key)Each entity must have a unique code and may inherit configuration from a parent. The parent_entity_id enables a hierarchical structure—e.g., US-CHI, US-DET, US-ATL all under US.
INSERT INTO entity (entity_id, code, name, parent_entity_id, is_active, financial_calendar_id, chart_of_accounts_id)
VALUES
('1a2b3c', 'US', 'United States', NULL, true, 1, 101),
('4d5e6f', 'US-CHI', 'Chicago Branch', '1a2b3c', true, 1, 102),
('7g8h9i', 'US-DET', 'Detroit Branch', '1a2b3c', true, 1, 103);
Data is partitioned by entity using a tenant_id column on all transactional and master data tables. This column is used in joins, filters, and views. The following tables must include tenant_id:
customer (tenant_id, customer_id, name, address, credit_limit)vendor (tenant_id, vendor_id, name, payment_terms)item (tenant_id, item_id, name, unit_of_measure, cost_price)sales_order (tenant_id, order_id, customer_id, order_date, total_amount)All queries must filter or join on tenant_id. A common failure is missing tenant_id in a critical table: for example, purchase_order is not partitioned, leading to orders being visible across entities but not scoped to one.
Each entity requires its own business process configuration, stored in a business_process table with entity_id and process_name. Key fields include:
process_name (string, e.g., InvoiceApproval, PayrollRun)definition_json (JSON, contains workflow steps and routing rules)active (boolean, default true)A process is activated per entity by setting active = true and populating definition_json with a standard workflow schema:
{
"version": 1,
"steps": [
{
"name": "Submit",
"type": "form",
"assignee": "approver",
"condition": "total_amount > 10000"
},
{
"name": "Approve",
"type": "approval",
"required_approvals": 2,
"escalation_days": 3
},
{
"name": "Post",
"type": "system",
"action": "journal_entry.create",
"trigger": "after_approval"
}
]
}
The tenant_id is passed through all process invocations. A common mistake is reusing the same business_process record across entities without copying it per entity_id, causing one entity’s payroll rules to apply to all.
Transfers between entities use a transfer_order table with source_entity_id and destination_entity_id. Each transfer is processed independently per entity, but the total value is tracked at the parent level.
INSERT INTO transfer_order (transfer_id, source_entity_id, destination_entity_id, status, created_at)
VALUES ('t123', 'US-CHI', 'US-DET', 'confirmed', '2024-03-15 10:00:00');
A transfer triggers:
stock_movement in the source entitypurchase_order in the destination entityjournal_entry in both entities to record inventory transfer costThe transfer is considered complete only when the destination confirms receipt. Failure to update status in both systems causes reconciliation gaps.
Master data—such as item, customer, vendor—can be shared across entities. Shared data is marked with is_shared = true in the master table. A master_data_sync job runs every 5 minutes, pulling changes from the shared entity and pushing to all tenant entities.
The job uses a change data capture (CDC) table:
CREATE TABLE master_data_change (
change_id SERIAL PRIMARY KEY,
entity_id UUID NOT NULL,
table_name TEXT NOT NULL,
operation TEXT CHECK (operation IN ('create', 'update', 'delete')),
record_id UUID NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMP WITH TIME ZONE,
processed_by TEXT
);
A sync_job script processes all unprocessed changes:
#!/bin/bash
set -euo pipefail
DB_HOST="db-prod.example.com"
DB_NAME="erp"
SYNC_INTERVAL=300 # seconds
while true; do
psql -h "$DB_HOST" -d "$DB_NAME" -c "
SELECT entity_id, table_name, operation, record_id, payload
FROM master_data_change
WHERE processed_at IS NULL
ORDER BY change_id
LIMIT 100
" | while IFS=$'\t' read -r entity_id table_name operation record_id payload; do
# Fetch entity-specific configuration
config=$(curl -s -H "Authorization: Bearer $API_KEY" \
"https://api.erp.example.com/tenants/$entity_id/config")
# Apply changes based on table name
case "$table_name" in
"item")
# Update item in target entity
curl -X PUT \
-H "Content-Type: application/json" \
-d "$payload" \
"https://api.erp.example.com/tenants/$entity_id/items/$record_id"
;;
"customer")
curl -X POST \
-H "Content-Type: application/json" \
-d "$payload" \
"https://api.erp.example.com/tenants/$entity_id/customers"
;;
*)
echo "Unhandled table: $table_name"
exit 1
;;
esac
# Mark change as processed
psql -h "$DB_HOST" -d "$DB_NAME" -c "
UPDATE master_data_change
SET processed_at = NOW(), processed_by = 'sync-job'
WHERE change_id = (SELECT change_id FROM master_data_change WHERE entity_id = '$entity_id' AND record_id = '$record_id')
"
done
sleep "$SYNC_INTERVAL"
done
A failure mode: if the sync_job crashes before updating processed_at, the same change is processed twice, leading to duplicate records or inconsistent states.
All reporting queries must include tenant_id in the WHERE clause and GROUP BY clauses. A common mistake is querying sales_order without filtering by tenant_id, leading to aggregated totals across all entities, which misrepresents performance.
To generate a report for a single entity, use:
SELECT
e.code AS entity_code,
e.name AS entity_name,
COUNT(so.order_id) AS order_count,
SUM(so.total_amount) AS total_revenue
FROM sales_order so
JOIN entity e ON so.tenant_id = e.entity_id
WHERE so.order_date >= '2024-01-01'
AND so.tenant_id = 'US-CHI'
GROUP BY e.entity_id, e.code, e.name;
A missing tenant_id in the JOIN causes data from other entities to leak into the report.
Consolidation combines financial data from all entities under a parent. The process uses a consolidation_run table and a consolidation_job script:
CREATE TABLE consolidation_run (
run_id SERIAL PRIMARY KEY,
parent_entity_id UUID NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status TEXT CHECK (status IN ('queued', 'running', 'completed', 'failed')),
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE
);
A consolidation_job aggregates data from each child entity:
#!/bin/bash
# consolidation-job.sh
set -euo pipefail
PARENT_ENTITY="US"
START_DATE="2024-01-01"
END_DATE="2024-12-31"
# Create run record
run_id=$(psql -h "$DB_HOST" -d "$DB_NAME" -c "
INSERT INTO consolidation_run (parent_entity_id, start_date, end_date, status)
VALUES ('$PARENT_ENTITY', '$START_DATE', '$END_DATE', 'queued')
RETURNING run_id
" | tail -n 1)
# Get all child entities
children=$(psql -h "$DB_HOST" -d "$DB_NAME" -c "
SELECT entity_id FROM entity WHERE parent_entity_id = '$PARENT_ENTITY'
" | tail -n +3)
# For each child, fetch financials and insert into consolidated table
for child in $children; do
# Fetch financials from child entity
financials=$(curl -s "https://api.erp.example.com/tenants/$child/financials?from=$START_DATE&to=$END_DATE")
# Insert into consolidation table
psql -h "$DB_HOST" -d "$DB_NAME" -c "
INSERT INTO consolidated_financials (run_id, entity_id, period, revenue, expenses, net_income)
VALUES ($run_id, '$child', '2024', 125000, 80000, 45000)
"
done
# Mark run as completed
psql -h "$DB_HOST" -d "$DB_NAME" -c "
UPDATE consolidation_run
SET status = 'completed', completed_at = NOW()
WHERE run_id = $run_id
"
A silent failure: if the consolidation_job runs but the consolidation_run status is never updated to completed, subsequent jobs may process the same period twice.
Configuration values are stored in a config table with entity_id, key, value, and scope (e.g., system, module, user). When a config is read, the system looks for:
entity_id IS NULL)entity_id = X)entity_id = X AND scope = 'inventory')A common error: a config key inventory.default_warehouse is set at the global level but overridden at the US-CHI level, yet the US-DET entity uses the global value. The system must explicitly merge configurations, and the order of lookup matters.
API endpoints expect a X-Tenant-ID header. A service call to GET /api/v1/invoices without X-Tenant-ID: US-CHI returns invoices for the default tenant, not the intended one. The header must be propagated through all microservices.
A failure: a POST /api/v1/sales-order without X-Tenant-ID creates an order in the wrong entity, with no error logged or alert. The system assumes the tenant is derived from the request body, but the tenant_id field is optional and defaults to NULL.
Each entity.code must be unique within the system. A duplicate code causes data drift: two entities with code = 'US' but different entity_ids, leading to confusion in reports and transfers. A validation job checks for duplicates:
SELECT code, COUNT(*) AS count
FROM entity
GROUP BY code
HAVING COUNT(*) > 1;
The job must be scheduled daily. A missing UNIQUE(code) constraint on entity leads to silent duplication, especially during data migration.
When an entity is deactivated (is_active = false), the system:
A failure mode: a deactivated entity still receives incoming transfers, but the transfer order is not confirmed, leading to unprocessed inventory. The system must enforce is_active checks in all business logic, including validation in APIs and background jobs.
Key Takeaways
tenant_id consistently across all data tables. tenant_id in joins, filters, and aggregations. is_active = false, not just by disabling them. entity.code uniqueness and use it for reporting and navigation. X-Tenant-ID through all API layers. consolidation_run table for cross-entity reporting.This page was rewritten on 17 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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy