overview.md 19.5 KB

Architecture overview

This document distills the vibe_erp architecture for someone who has not read the full spec. The complete design lives at ../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md. Read that when you need the full reasoning, the foundational decisions table, the v1.0 cut line, or the risk register.

What vibe_erp is

vibe_erp is an ERP/EBC framework — not an ERP application — targeting the printing industry, sold worldwide and deployed self-hosted-first. The whole point of the project is reusability across customers: any printing shop, with its own workflows, forms, roles, and rules, should be onboardable by configuration and plug-ins, without forking the core.

The reference printing-shop business documented under raw/业务流程设计文档/ is one example customer, treated as a fixture and an executable acceptance test, never as a specification. No part of its workflow is hard-coded into the core.

Clean Core philosophy

vibe_erp adopts SAP S/4HANA's Clean Core vocabulary: extensions never modify the core. The core is stable, generic, upgrade-safe, and domain-agnostic. Everything customer-specific lives in metadata rows or in plug-ins. This is enforced, not aspirational:

  1. Core stays domain-agnostic. No printing-specific entity, field, status, or workflow step belongs in the framework core. "Plate", "ink", "press", "color proof" live in plug-ins or in configuration.
  2. Workflows are data, not code. State machines, approval chains, and form definitions are declarative, so a new customer is onboarded by editing definitions, not source.
  3. Extensibility seams come first. Before any feature is added, the extension point it hangs off of must exist. If no seam exists, the seam is designed first.
  4. The reference customer is a test, not a requirement. Anything implemented for the reference plug-in must be expressible by a different printing shop with different rules, without code changes.
  5. Single-tenant per instance, isolated database. vibe_erp deliberately does NOT support multiple companies in one process. Each running instance serves exactly one company against an isolated Postgres database. Hosting many customers means provisioning many independent instances, not multiplexing them. There are no tenant_id columns, no row-level filters, no Postgres Row-Level Security policies anywhere in the framework. Customer isolation is a deployment concern, not a code concern.
  6. Global / i18n from day one. No hard-coded user-facing strings, currencies, date formats, time zones, address shapes, or tax models.

Two-tier extensibility

vibe_erp offers two extension paths, both first-class. The same outcome can often be reached through either path; Tier 1 is preferred when expressive enough.

Tier 1 — Key user, no-code

Business analysts customize the system through the web UI. Everything they create is stored as rows in the metadata tables, tagged source = 'user', and preserved across plug-in install/uninstall and core upgrades.

Capability Stored in
Custom field on an existing entity metadata__custom_field → JSONB ext column at runtime
Custom form layout metadata__form (JSON Schema + UI Schema)
Custom list view, filter, column set metadata__list_view
Custom workflow metadata__workflow → deployed to Flowable as BPMN
Simple "if X then Y" automation metadata__rule
Custom entity (Doctype-style) metadata__entity → auto-generated table at apply time
Custom report metadata__report
Translations override metadata__translation

No build, no restart, no deploy. The OpenAPI spec, the AI-agent function catalog, and the REST API auto-update from the metadata.

Tier 2 — Developer, pro-code

Software developers ship a PF4J plug-in JAR. The plug-in:

  • Sees only org.vibeerp.api.v1.* — the public, semver-governed contract.
  • Cannot import org.vibeerp.platform.* or any PBC's internal classes.
  • Lives in its own classloader, its own Spring child context, its own DB schema namespace (plugin_<id>__*), its own metadata-source tag.
  • Can register: new entities, REST endpoints, workflow tasks, form widgets, report templates, event listeners, permissions, menu entries, and React micro-frontends.

Extension grading

Borrowed from SAP. The plug-in loader knows these grades and acts on them.

Grade Definition Upgrade safety
A Tier 1 only (metadata) Always safe across any core version
B Tier 2, uses only api.v1 stable surface Safe within a major version
C Tier 2, uses deprecated-but-supported api.v1 symbols Safe until next major; loader emits warnings
D Tier 2, reaches into internal classes via reflection UNSUPPORTED; loader rejects unless explicitly overridden; will break

A guiding principle: anything a Tier 2 plug-in does should also become possible as Tier 1 over time. Tier 2 is the escape hatch where Tier 1 is not yet expressive enough.

Modular monolith of PBCs

vibe_erp is a modular monolith: one Spring Boot process per instance, one Docker image per release, but internally divided into strict bounded contexts called Packaged Business Capabilities (PBCs). Each PBC is its own Gradle subproject, its own table-name prefix, its own bounded context, its own public API surface.

The v1.0 core PBCs:

pbc-identity     pbc-catalog       pbc-partners
pbc-inventory    pbc-warehousing
pbc-orders-sales pbc-orders-purchase
pbc-production   pbc-quality       pbc-finance

These names are illustrative core capabilities. None is printing-specific. The reference printing-shop plug-in lives entirely outside pbc/ under reference-customer/plugin-printing-shop/, built and CI-tested but not loaded by default.

Per-PBC layout

Every PBC follows the same shape:

pbc-orders-sales/
├── api/                ← service contracts re-exported by api.v1
├── domain/             ← entities, value objects, domain services
├── application/        ← use cases / application services
├── infrastructure/     ← Hibernate mappings, repositories
├── http/               ← REST controllers
├── workflow/           ← BPMN files, task handlers
├── metadata/           ← seed metadata (default forms, rules)
├── i18n/               ← message bundles
└── migrations/         ← Liquibase changesets (own table prefix)

The dependency rule

The single rule that makes "modular monolith now, splittable later" real instead of aspirational. Enforced by the Gradle build; CI fails on violations.

api/api-v1            depends on: nothing (Kotlin stdlib + jakarta.validation only)
platform/*            depends on: api/api-v1 + Spring + libs
pbc/*                 depends on: api/api-v1 + platform/*  (NEVER another pbc)
plugins (incl. ref)   depend on:  api/api-v1 only

PBCs never import each other. Cross-PBC interaction goes through one of two channels:

  1. The event bus. A PBC publishes a typed DomainEvent. Any other PBC (or any plug-in) subscribes via EventListener. The default bus is in-process; an outbox table in Postgres is the seam where Kafka or NATS can plug in later without changing PBC code.
  2. Service interfaces declared in api.v1.ext.<pbc>. When a synchronous call is genuinely needed, it goes through a typed interface in api.v1.ext, not through a direct class reference.

If you find yourself wanting to add a pbc-foo → pbc-bar Gradle dependency, the seam is wrong. Design an event or an api.v1.ext.bar interface instead.

The api.v1 contract

api.v1 is the only stable contract in the entire codebase. Everything in org.vibeerp.api.v1.* is binary-stable within the 1.x line. Everything not in api.v1 is internal and may change in any release.

Package layout:

org.vibeerp.api.v1
├── core/         Id<T>, Money, Currency, Quantity, UnitOfMeasure, Result<T,E>
├── entity/       Entity, AuditedEntity, FieldType, CustomField, EntityRegistry
├── persistence/  Repository<T>, Query, Page, Transaction, PersistenceExceptions
├── workflow/     WorkflowTask, TaskHandler, TaskContext
├── form/         FormSchema
├── http/         @PluginEndpoint, RequestContext
├── event/        DomainEvent, EventListener, EventBus (with Subscription)
├── security/     Principal, PrincipalId, Permission, PermissionCheck
├── i18n/         MessageKey, Translator, LocaleProvider
├── plugin/       Plugin, PluginContext, PluginManifest, ExtensionPoint,
│                 PluginEndpointRegistrar, HttpMethod, PluginRequest,
│                 PluginResponse, PluginEndpointHandler, PluginJdbc, PluginRow
└── ext/          Typed cross-PBC facades (ext.identity.IdentityApi,
                  ext.catalog.CatalogApi)

api.v1 is published as api-v1.jar to Maven Central, so plug-in authors can build against it without pulling the entire vibe_erp source tree.

Upgrade contract

Change Allowed within 1.x?
Add a class to api.v1 yes
Add a method to an api.v1 interface (with default impl) yes
Remove or rename anything in api.v1 no — major bump
Change behavior of an api.v1 symbol in a way plug-ins can observe no — major bump
Anything in platform.* or pbc.*.internal.* yes — that is why it is internal

When in doubt, keep things out of api.v1.

Topology

One Spring Boot process per instance, one Docker image per release, one mounted volume (/opt/vibe-erp/) for config/, plugins/, i18n-overrides/, files/, logs/. Customer extensions live outside the image. Upgrading vibe_erp = swapping the image; extensions and config stay put.

┌──────────────────────────────────────────────────────────────────────┐
│                          Customer's network                          │
│                                                                      │
│  Browser (React SPA) ─┐                                              │
│  AI agent (MCP, v1.1)─┼─► Reverse proxy ──► vibe_erp backend (1 image)│
│  3rd-party system    ─┘                       │                      │
│                                                │                      │
│   Inside the image (one Spring Boot process):  │                      │
│     ┌─────────────────────────────────────┐   │                      │
│     │  HTTP layer (REST + OpenAPI + MCP)  │   │                      │
│     ├─────────────────────────────────────┤   │                      │
│     │   Public Plug-in API  (api.v1.*)    │◄──┤  loaded from         │
│     │   — the only stable contract        │   │  ./plugins/*.jar     │
│     ├─────────────────────────────────────┤   │  via PF4J            │
│     │   Core PBCs (modular monolith)      │   │                      │
│     ├─────────────────────────────────────┤   │                      │
│     │   Cross-cutting:                    │   │                      │
│     │   • Flowable (workflows-as-data)    │   │                      │
│     │   • Metadata store (Doctype-style)  │   │                      │
│     │   • i18n (ICU MessageFormat)        │   │                      │
│     │   • Reporting (JasperReports)       │   │                      │
│     │   • Job scheduler (Quartz)          │   │                      │
│     │   • Audit, security, events         │   │                      │
│     └─────────────────────────────────────┘   │                      │
│                                                ▼                      │
│                                       PostgreSQL  (mandatory)        │
│                                       File store  (local or S3)      │
└──────────────────────────────────────────────────────────────────────┘

The only mandatory external dependency is PostgreSQL. Optional sidecars for larger deployments — Keycloak, Redis, OpenSearch, SMTP relay — are off by default.

Single-tenant per instance

vibe_erp is deliberately single-tenant per instance. One running process serves exactly one company against an isolated Postgres database. Hosting many customers means provisioning many independent instances, each with its own DB. There are no tenant_id columns, no row-level filters, no Postgres Row-Level Security policies anywhere in the framework. Customer isolation is a deployment concern, not a code concern.

This is the choice in CLAUDE.md guardrail #5. The rationale: most ERP/EBC customers will not accept a SaaS where their data shares a database with other companies, and per-instance isolation is dramatically simpler than per-row tenant filtering. The cost is that hosting many customers requires real provisioning infrastructure (one container + one DB per customer); the benefit is that the framework itself stays simple, the audit story is trivial, and GDPR data residency is automatic — the customer chose where Postgres lives.

Schema namespacing

PBCs and plug-ins use table name prefixes, not Postgres schemas:

identity__user, identity__role, identity__user_credential
catalog__item, catalog__uom
inventory__stock_item, inventory__movement   (future PBC)
orders_sales__order, orders_sales__order_line   (future PBC)
plugin_printingshop__plate, plugin_printingshop__ink_recipe  (reference plug-in)
metadata__entity, metadata__permission, metadata__menu, metadata__custom_field, ...
platform__event_outbox
flowable_*  (Flowable's own tables, untouched, when wired)

This keeps Hibernate and migrations all in one logical schema (public), avoids search_path traps, and gives clean plug-in uninstall semantics: drop everything matching plugin_<id>__*.

Data sovereignty

  • Self-hosted is automatically compliant — the customer chose where Postgres lives.
  • Hosted is a separate "operator console" concern (post-v1.0): provision a fresh vibe_erp instance + dedicated Postgres per customer. The framework code is unchanged.
  • PII tagging on field metadata drives auto-generated DSAR exports and erasure jobs (GDPR Articles 15/17). The metadata seeder ships the tag; the eraser is future work (P3.x).
  • Append-only audit log records the principal and timestamp of every save via the JPA listener.

Custom fields via JSONB

Every business table has:

ext       jsonb   not null  default '{}',
ext_meta  text    generated

Custom fields are JSON keys inside ext. A GIN index on ext makes them queryable. The metadata__custom_field table describes the JSON shape per entity. The form designer, list views, OpenAPI generator, and AI-agent function catalog all read from this table.

Why JSONB and not EAV: one row, one read, indexable, no migrations needed for additions, no joins. EAV is the wrong tool.

For the rare hot-path custom field, an operator can promote a JSON key to a real generated column via an auto-generated Liquibase changeset. This is an optimization, not the default.

The metadata store

metadata__entity         metadata__form           metadata__permission
metadata__custom_field   metadata__list_view      metadata__role_permission
metadata__workflow       metadata__rule           metadata__menu
metadata__report         metadata__translation    metadata__plugin_config

Every row carries source (core / plugin:<id> / user) plus timestamps. The source column makes uninstall and upgrade safe: removing a plug-in cleans up its metadata via a single DELETE WHERE source = 'plugin:<id>', and user-created metadata is sacred and never touched by core or plug-in upgrades. The current metadata loader (P1.5) implements loadCore() and loadFromPluginJar(pluginId, jarPath) and exposes the seeded data at the public GET /api/v1/_meta/metadata endpoint.

Every consumer reads from this store: the form renderer, list views, OpenAPI generator, AI-agent function catalog, role editor. There are no parallel sources of truth.

The workflow engine

vibe_erp embeds Flowable (BPMN 2.0). Workflows are data: .bpmn files at design time, flowable_* tables at runtime. Two authoring paths exist, both first-class:

  • Tier 1 (visual designer in the web UI, v1.0): business analysts draw workflows in a BPMN designer. The result is stored as a row in metadata__workflow, deployed to Flowable, and tagged source = 'user'.
  • Tier 2 (.bpmn files in a plug-in JAR): plug-in authors ship BPMN files in their JAR. The plug-in lifecycle deploys them on plug-in install.

Workflows interact with the system through:

  • Service tasks that call typed TaskHandler implementations. A plug-in registers a TaskHandler via @Extension(point = TaskHandler::class); the workflow references it by id. There is no scripting language to invent — the TaskHandler is just Kotlin code behind a typed interface.
  • User tasks that render forms from metadata__form definitions. The form is referenced by id; rendering goes through the same code path used by Tier 1 forms.

The temptation to invent a vibe_erp-only workflow language must be rejected. BPMN 2.0 via Flowable is the standard, and the standard is the contract.

Cross-cutting concerns

Concern Approach
Security PermissionCheck declared in api.v1.security; plug-ins register their own permissions, auto-listed in the role editor
Transactions Spring @Transactional at the application-service layer; plug-ins use api.v1.persistence.Transaction, never Spring directly
Audit created_at, created_by, updated_at, updated_by, version on every entity, applied by a JPA listener that reads created_by from PrincipalContext (bound by the JWT auth filter)
Events Typed DomainEvents on every state change; in-process bus by default; outbox table in Postgres for cross-crash reliability and as the seam where Kafka/NATS plugs in later
AI-agent surface Same business operations exposed through REST are exposable through an MCP server; v1.1 ships the MCP endpoint, v1.0 architects the seam
Reporting JasperReports; templates shipped by core or by plug-ins, customer-skinnable
i18n ICU MessageFormat, locale-aware formatting for dates, numbers, and currencies; no string concatenation in user-facing code

Where to read more