# 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. **Multi-tenant in spirit from day one.** Even single-tenant deployments use the same multi-tenant code path. 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**, scoped to their tenant, 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/ Tenant, Locale, Money, Quantity, Id, Result ├── entity/ Entity, Field, FieldType, EntityRegistry ├── persistence/ Repository, Query, Page, Transaction ├── workflow/ WorkflowTask, WorkflowEvent, TaskHandler ├── form/ FormSchema, UiSchema ├── http/ @PluginEndpoint, RequestContext, ResponseBuilder ├── event/ DomainEvent, EventListener, EventBus ├── security/ Principal, Permission, PermissionCheck ├── i18n/ MessageKey, Translator, LocaleProvider ├── reporting/ ReportTemplate, ReportContext ├── plugin/ Plugin, PluginManifest, ExtensionPoint └── ext/ Typed extension interfaces a plug-in implements ``` `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. ## Multi-tenancy vibe_erp is **multi-tenant in spirit from day one**, even when deployed for a single customer. The same code path serves self-hosted single-tenant and hosted multi-tenant deployments. ### Schema namespacing PBCs and plug-ins use **table name prefixes**, not Postgres schemas: ``` identity__user, identity__role catalog__item, catalog__item_attribute inventory__stock_item, inventory__movement orders_sales__order, orders_sales__order_line production__work_order, production__operation plugin_printingshop__plate_spec (reference plug-in) metadata__custom_field, metadata__form, metadata__workflow flowable_* (Flowable's own tables, untouched) ``` This keeps Hibernate, RLS policies, and migrations all in one logical schema (`public`), avoids `search_path` traps, and gives clean uninstall semantics. ### Tenant isolation — two independent walls - Every business table has `tenant_id`, NOT NULL. - Hibernate `@TenantId` filters every query at the application layer. - Postgres Row-Level Security policies filter every query at the database layer. Two independent walls. A bug in one is not a data leak. Self-hosted single-customer deployments use one tenant row called `default`. Hosted multi-tenant deployments use many tenant rows. **Same code path.** ### Data sovereignty - Self-hosted is automatically compliant — the customer chose where Postgres lives. - Hosted supports per-region tenant routing: each tenant row carries a region; connections are routed to the right regional Postgres cluster. - PII tagging on field metadata drives auto-generated DSAR exports and erasure jobs (GDPR Articles 15/17). - Append-only audit log records access to PII fields when audit-strict mode is on. ## 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 per tenant. 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 `tenant_id`, `source` (`core` / `plugin:` / `user`), `version`, `is_active`. The `source` column makes uninstall and upgrade safe: removing a plug-in cleans up its metadata, and user-created metadata is sacred and never touched by an upgrade. 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`, `tenant_id` on every entity, applied by a JPA listener | | 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)