# 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`](../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___*`), 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.`.** 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, Money, Currency, Quantity, UnitOfMeasure, Result ├── entity/ Entity, AuditedEntity, FieldType, CustomField, EntityRegistry ├── persistence/ Repository, 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___*`. ### 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: ```sql 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:` / `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:'`, 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 `DomainEvent`s 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 - The full architecture spec: [`../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md`](../superpowers/specs/2026-04-07-vibe-erp-architecture-design.md) - The architectural guardrails (the rules that exist because reusability across customers is the entire point of the project): `CLAUDE.md` at the repo root. - Plug-in API surface: [`../plugin-api/overview.md`](../plugin-api/overview.md) - Building your first plug-in: [`../plugin-author/getting-started.md`](../plugin-author/getting-started.md)