Skip to content

Architecture Overview

  1. High-Level Architecture
  2. Core Bounded Contexts
  3. Capability Abstractions (Business Roles)
  4. Hexagonal Architecture Structure
  5. Cross-context dependencies in core
  6. Module Organization
  7. Data Flow
  8. Technology Stack

See ADR-001 for the decision rationale.

OpenLinker follows a Hexagonal Architecture (Ports and Adapters) pattern, organized as a modular monorepo. The system is designed to be:

  • Modular: Clear separation between core domain and integrations
  • Extensible: Easy to add new platforms without modifying core logic
  • Testable: Domain logic isolated from infrastructure concerns
  • Maintainable: Business capabilities abstracted from concrete implementations
┌─────────────────────────────────────────────────────────────────┐
│ Frontend/UI │
│ (Separate Application) │
└────────────────────────────┬────────────────────────────────────┘
│ HTTP REST API (JWT)
┌────────────────────────────▼────────────────────────────────────┐
│ Core API (OpenLinker) │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Interfaces Layer (HTTP/REST) │ │
│ │ - Controllers (REST endpoints) │ │
│ │ - Request/Response DTOs │ │
│ │ - Authentication & Authorization │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Application Layer (Use Cases) │ │
│ │ - ProductSyncService │ │
│ │ - InventorySyncService │ │
│ │ - OrderSyncService │ │
│ │ - OfferSyncService │ │
│ │ - MappingServices │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Infrastructure Services │ │ │
│ │ │ - IdentifierMappingService │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Domain Layer (Business Logic) │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Products │ │ Inventory │ │ Orders │ │ │
│ │ │ Domain │ │ Domain │ │ Domain │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ │ │
│ │ │ Listings │ │ │
│ │ │ Domain │ │ │
│ │ └──────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Capability Ports (Interfaces) │ │ │
│ │ │ - ProductMasterPort │ │ │
│ │ │ - InventoryMasterPort │ │ │
│ │ │ - OrderSourcePort │ │ │
│ │ │ - OrderProcessorManagerPort │ │ │
│ │ │ - OfferManagerPort │ │ │
│ │ │ - PricingAuthorityPort (future) │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Infrastructure Layer (Adapters) │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ PrestaShop │ │ Allegro │ │ InPost │ │ │
│ │ │ Adapters │ │ Adapters │ │ Adapters │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Adapters Implementing Capability Ports │ │ │
│ │ │ - PrestashopProductMasterAdapter │ │ │
│ │ │ - PrestashopInventoryMasterAdapter │ │ │
│ │ │ - PrestashopOrderSourceAdapter │ │ │
│ │ │ - PrestashopOrderProcessorAdapter │ │ │
│ │ │ - AllegroOrderSourceAdapter │ │ │
│ │ │ - AllegroOfferManagerAdapter │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Infrastructure Layer (Persistence) │ │
│ │ - PostgreSQL (TypeORM) │ │
│ │ - Redis (Caching, Event Bus) │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────┬────────────────────────────────────┘
│ HTTP/API Calls
┌────────────────────┼────────────────────┐
│ │ │
┌───────▼──────┐ ┌────────▼────────┐ ┌─────▼──────────┐
│ PrestaShop │ │ Allegro │ │ Other │
│ API │ │ API │ │ Platforms │
└──────────────┘ └─────────────────┘ └────────────────┘

Frontend-specific conventions for the separate UI application are documented in docs/frontend-architecture.md.


The system is organized into the following core bounded contexts:

  • Responsibility: User authentication, authorization
  • Key Entities: User, Role, Permission
  • Location: apps/api/src/auth/ or libs/core/src/auth/
  • Responsibility: Product catalog management, product mapping between platforms
  • Key Entities: Product, ProductMapping, ProductVariant
  • Location: libs/core/src/products/
  • Capability: Uses ProductMasterPort abstraction
  • Barcode Storage: EAN/GTIN are stored on ProductVariant (not Product), as variants are the canonical offer-link targets.
  • Simple Products: Products without combinations produce a deterministic synthetic variant to ensure a stable mapping target.
  • Responsibility: Inventory synchronization, stock level management
  • Key Entities: Inventory, InventoryAdjustment, InventoryMapping
  • Location: libs/core/src/inventory/
  • Capability: Uses InventoryMasterPort abstraction
  • Variant-keyed master inventory (#822, ADR-010): MasterInventorySyncService keys each inventory_items row to the product’s canonical variant (a simple product’s deterministic synthetic variant), not the bare product — so the variant-keyed availability read (getAvailabilityByVariantIds, used by the bulk offer wizard) finds stock. Resolution precedence: adapter-supplied Inventory.variantId → the product’s lone variant when it has exactly one → else product-level (productVariantId = NULL).
  • Per-combination master stock (#823): the master sync iterates InventoryMasterPort.listInventory(productId) — one Inventory per variant — and writes one variant-keyed row each. The PrestaShop adapter enumerates stock_availables for the product and emits one entry per combination (resolving each combination id to its ProductVariant), or the synthetic variant for a simple product, so multi-variant products now carry correct per-variant stock. Marketplace-agnostic: any inventory master expresses per-variant stock the same way. The Allegro destination half (one auto-grouped offer per variant) remains #824.
  • Per-connection stock safety buffer (#1844): an operator-configurable reserve held back per destination connection so a fast-moving item cannot oversell between syncs. The published quantity is max(0, masterStock - reserve). The reserve lives on Connection.config.stockSafetyBuffer (JSONB — no schema/migration change) and defaults to 0, which preserves the pre-#1844 pass-through behaviour (master is authoritative, including 0). Two pure, marketplace-neutral helpers own the policy (readStockSafetyBuffer / applyStockSafetyBuffer in @openlinker/core/identifier-mapping, mirroring the parseTriggerModel config-coercion precedent). It is applied at both stock write sites and applied once per flow: the publish/create builders (OfferBuilderService, ProductPublishBuilderService) stamp the buffered command.stock, and the inventory write-back choke point (InventorySyncService.updateOfferQuantities, reached by both the offer and shop-product propagation fan-outs) buffers every written-back quantity. The buffer is orthogonal to the FE bulk stock policy (use-master / cap / flat, #726); it is the destination-side floor applied after the operator’s chosen source quantity.
  • Responsibility: Order ingestion, synchronization, and lifecycle management
  • Key Entities: Order, OrderMapping, OrderStatus, IncomingOrder
  • Location: libs/core/src/orders/
  • Capabilities:
    • OrderSourcePort — cursor-based order-event ingestion from marketplaces and shops (listOrderFeed + getOrder({externalOrderId}))
    • OrderProcessorManagerPort — destination-shop order creation (createOrder, the only base-port method); post-create status/tracking and lifecycle reads live in composable sub-capabilities (OrderFulfillmentUpdater, OrderStatusWriteback, …). The canonical OL-owned lifecycle state machine is deferred (#1032).
  • Responsibility: Customer identity resolution, customer projections, multi-origin identity management
  • Key Entities: CustomerProjection, CustomerAddressProjection, DestinationAddressMapping
  • Location: libs/core/src/customers/
  • Key Features:
    • Customer identity resolution with email fallback mode
    • Multi-origin customer identity (same email across platforms → same internal customer)
    • Customer projections (Model C) for debugging and retry support
    • Configurable PII storage (hash-only mode for privacy compliance)
    • Address reuse tracking via destination address mappings
  • Identity Modes:
    • external_only: Only use external buyer ID mapping (no email fallback)
    • email_fallback: Use email hash fallback if external mapping not found (may merge customers with shared emails)
  • Provisioning Model: Destination-owned (Model A) - customers created in destination platform (e.g., PrestaShop)
  • Projection Model: Lightweight internal storage (Model C) - non-authoritative projections for debugging
  • Responsibility: Marketplace offer/listing management, offer lifecycle, offer-to-product mapping
  • Key Entities: Offer, Listing, OfferMapping, OfferStatus, OfferCreationRecord
  • Location: libs/core/src/listings/
  • Capability: Uses OfferManagerPort abstraction for offer operations (listing, quantity + field updates, offer creation, category directory, seller-policy discovery)
  • Key Features:
    • Creating and updating offers on marketplaces
    • Managing offer quantities based on inventory
    • Offer-to-product mapping
    • Offer status synchronization
    • Price management for marketplace offers
  • Offer mappings are populated via the marketplace.offers.sync job (pre-sync pipeline).
  • Allegro offer sync uses GET /sale/offer-events with persisted cursor key allegro.offers.lastEventId.
  • Offer status sync (#816): the marketplace.offer.statusSync job refreshes the live marketplace publication status (active | activating | inactivating | inactive | ended) of mapped offers into the offer_status_snapshots table — the steady-state counterpart to OfferCreationRecord’s one-shot creation lifecycle. It reuses the OfferStatusReader capability, enumerates OL’s own offer mappings paged by a rolling scan-offset cursor (allegro.offerStatus.scanOffset; Allegro has no bulk status endpoint), and writes a disjoint table from the creation poller (#447) so the two never race. See ADR-009.
  • Offer status read + post-terminal reconcile (#1760): the snapshot is the authoritative operator-facing live status. OfferStatusReadService exposes it per product (GET /listings/products/:productId/offer-status, rendered on the products drawer), and a manual POST .../offers/:externalOfferId/refresh-status force-reads one offer. Because the creation poller (#447) terminalises a record to draft/failed(POLL_TIMEOUT) when Allegro’s validator outruns its ~9-min budget — and Allegro may activate the offer minutes later — the poller then schedules a bounded, delayed marketplace.offer.refreshSnapshot job (~2/8/20 min) that re-reads live status via OfferStatusSyncService.refreshOne and upserts the snapshot, self-rescheduling while in-flight. The terminal creation record is never mutated (ADR-009 disjoint-tables invariant); the snapshot is the moving part, with the hourly sync as the backstop. Spun out of #1520.
  • Offer linking by barcode uses master-catalog scoping and links only on unique matches.
  • Destination-aware duplicate guard (#1837): publishing behaves asymmetrically per destination kind — the marketplace path (OfferCreationExecutionService) always calls createOffer, so a fresh run creates a duplicate offer; the shop path (ProductPublishExecutionService) resolves the ShopProduct mapping first and upserts the existing product (PUT /products/{id}). Neither is surfaced before the operator commits, so the unified publish flow warns pre-flight. PublishedVariantsService.getPublishedVariantIds(connectionId, variantIds) (POST /listings/published-variants) unions two connection-scoped reads — OfferMappingRepositoryPort (marketplace) + the new ShopProductMappingRepositoryPort (shop) — to report which variants already have a listing there; because a connection carries only one mapping kind the union is destination-kind-agnostic and can’t double-count. The FE (picker + wizard Review) renders an “already on {destination}” chip and a soft confirm before publishing (marketplace: “creates a duplicate offer” / “Publish anyway”; shop: “updates the existing product (upsert)” / “Update existing”), resolving the wording from the connection’s capabilities, never platformType. It is a warning only — never a hard block; making offer-create mapping-idempotent is out of scope.
  • Category parameter sections (#415 / #419): per-platform create-offer requests may split category parameters into offer-section and product-section payloads (body.parameters[] vs body.productSet[].product.parameters[] on Allegro — the latter carries Brand, Model, Manufacturer-code, etc., and mirrors the shape Allegro returns from GET /sale/product-offers/{offerId}). The neutral CategoryParameter.section: 'offer' | 'product' field carries this distinction through to adapters; the wizard renders both kinds in one unified list and the FE serializer (serializeAllegroParameters) splits them into two arrays at submit time. Adapters that cannot distinguish always emit 'offer'.
  • Public surface: @openlinker/core/listings exposes pure contracts (ports, types, capability guards, entities, exceptions, service interfaces, Symbol tokens) safe to value-import from any sibling package. Runtime wiring (ListingsModule + the 8 @Injectable service classes) lives on the @openlinker/core/listings/services subpath — kept separate to prevent runtime circular requires when sibling packages value-import from the main barrel (#337/#359). Cross-context ORM-entity access (host-only) is routed through @openlinker/core/<ctx>/orm-entities sub-barrels (#594) — see docs/engineering-standards.md § Import Aliases for the general rule.
  • Bulk-flow lifecycle (#726): a BulkOfferCreationBatch (#734) is the parent aggregate; four core application services own its phases — BulkOfferCreationSubmitService (intake, #736), OfferCreationEnqueueService / OfferCreationExecutionService (per-child enqueue + run, shared with single-offer), BulkOfferCreationProgressService (counter advancement gated at-most-once by bulk_batch_advancements, #737), and BulkOfferCreationRetryService (#742). All four delegate to the same single-offer primitives — there’s no parallel “bulk” pipeline. BulkOfferCreationRetryService reopens a terminal-state batch: per failed record it deletes the bulk_batch_advancements row, decrements failedCount lock-stepped to the record reset, then transitions completed | partially-failed | failed → running once after the loop. The per-record V2 payload is rebuilt from the persisted request snapshot + the parent batch’s sharedConfig.generateDescription / sharedConfig.descriptionTone (the snapshot itself doesn’t carry AI flags — they’re batch-scoped). Each retry wave uses a wave-distinct idempotency key (bulk:{batchId}:variant:{variantId}:retry:{retryWaveId}) to bypass the 7-day TTL on the original submit’s dedup gate.
  • Multi-variant expansion (#824): BulkOfferCreationSubmitService.submit treats each submitted id as a primary-variant id and, for a multi-variant product, expands it into one offer-creation job per sibling variant before persisting the batch (so totalCount matches the real fan-out). Each variant-offer sources its stock.available from per-variant master inventory (IInventoryQueryService.getAvailabilityByVariantIds, #823) — master is authoritative, including 0, so an out-of-stock variant lists as 0 rather than being backfilled with the operator’s bulk quantity (the operator quantity stays the source for single-variant / passthrough offers). Each offer self-links to its own Allegro catalog product by its own barcode (siblings drop the wizard-resolved productCardId). Allegro auto-groups the resulting offers into one buyer-facing listing from the Product Catalog (GTIN + distinguishing parameter) — the explicit variant-set API (/sale/offer-variants) was removed 14 Apr 2026 and is not used. Single-variant products and unknown ids pass through unchanged. Emitting OL variant attributes as explicit Allegro distinguishing parameters is a deferred follow-up (grouping already works off each variant’s own catalog product).
  • Neutral variant-grouping command field (#1065): OfferBuilderService.buildCreateOfferCommand stamps a platform-neutral CreateOfferCommand.variantGroup (OfferVariantGroup = an opaque groupId shared by every sibling — today the parent OL product id — plus this variant’s distinguishing attributes, flattened from ProductVariant.attributes) for a sibling of a multi-variant product (getVariantsByProductId(...).length > 1); single-variant / simple products leave it absent and list standalone. The field is the marketplace-neutral seam for explicit grouping: explicit-grouping adapters map it to their wire shape (Erli → externalVariantGroup + attributes, #986; groupId is BODY-ONLY and must never be path-routed), while auto-grouping adapters (Allegro) ignore it and group off their own catalog product (#824). No platform name leaks into core — the adapter owns the neutral→wire mapping.
  • Unified offer-creation FE entry (#1754): the frontend has a single offer-creation entry point — the bulk offer wizard. /listings “Create offer” opens a multi-select, paginated, searchable product picker (whole product = all variants, a single variant, or a mix across products) that routes into the wizard at bulk-create/wizard?productIds=…[&variantIds=…]&connectionId=…; a single selected variant renders the wizard’s flat single-offer path. The old per-platform single-offer wizards (AllegroCreateOfferWizard, erli-create-offer-wizard) and their offerCreationWizard plugin-contribution dispatch were removed — no backend/job change (the wizard already delegates to the same single-offer core primitives, #726).
  • Borrowed-taxonomy mapping reuse (#1045, ADR-023 §40/§83): a destination that borrows its taxonomy (Erli — accepts Allegro ids verbatim, ships no CategoryBrowser/CategoryParametersReader) reuses an operator’s existing PrestaShop→Allegro category and attribute mappings with zero re-authoring. It declares the owner taxonomy it consumes via the TaxonomyBorrower capability (getBorrowedTaxonomy(): TaxonomyOwner, e.g. 'allegro'); OfferBuilderService threads that value + the master sourceConnectionId into CategoryResolutionService and AttributeProjectionService, which fall back from a destination-keyed lookup to the owner’s provenance-matching rows (destination_taxonomy_provenance). Capability-driven, never platformType; the downstream source:"allegro" emission was already in place (#985/#1096) — #1045 wired the resolution half.
  • Operator attribute mapping rules (#1841): an operator-authored, deterministic (no AI) rule layer consumed by AttributeProjectionService, on top of the base attribute-mapping model (#1038). Three rule kinds — fixed (set a destination attribute to a constant), copy-remap (copy a source attribute with per-value remap, e.g. 36S -> 36), place-value (fill from product metadata: name / variant / manufacturer / ean / sku / weight). Rules live in the mappings context (AttributeMappingRule entity + attribute_mapping_rules table; kind-specific data in a config jsonb, scope + target in real columns) and are read through IMappingConfigService.getAttributeMappingRules. They are scoped (all optional, AND-combined) by source connection, destination category, manufacturer (equality), and product-name phrase (substring), and ordered by priority (lower first; a later rule wins for the same destination parameter name, and rules win over the base attribute mapping). Because integration is at the shared projection layer, the same rules serve BOTH marketplace parameters (owns/borrows paths) and WooCommerce/shop attributes (product-publish path) automatically. place-value reads product-derived AttributeProjectionInput.metadata, assembled by the buildProjectionMetadata helper at the offer-builder and product-publish-builder call sites (manufacturer derived from a well-known product feature). Operator-authored via connections/:connectionId/attribute-rules (GET/PUT/DELETE) and the connection Mappings page “Attribute Rules” tab (capability-gated to OfferManager / ProductPublisher destinations).
  • Bulk shop-publish per-item transport (#1831, ADR-024): a bulk shop-publish batch is N independent publish decisions that happen to share a connection + status. stock/price are already per-item (#1414); content was batch-shared and category placement + parameters were server-derived by the #1042 builder. #1831 adds optional per-item content / destinationCategoryIds / parameters to BulkShopPublishSubmitItemInput (+ the request DTO) that override the batch-shared / server-derived defaults, threaded end-to-end (submit → ProductPublishEnqueueService V1/V2 payload → shop.product.publish handler → ProductPublishExecutionServiceProductPublishBuilderService). Precedence in the builder: a defined per-item destinationCategoryIds (even empty ⇒ publish uncategorised) skips CategoryProvisioner provisioning; a defined per-item parameters (even empty) skips attribute projection + the required-parameter gate; per-item content (chosen at submit via item.content ?? batchContent) still merges over master-product fallbacks. Every override is optional — omitting a field keeps the pre-#1831 batch-shared / server-derived behavior (backward compatible). FE consumption is #1830.
  • Shop category browse (#1834, ADR-024): shops gain a read counterpart to CategoryProvisioner (the write path). ShopCategoryBrowser is a sub-capability of ShopProductManagerPort (browseCategories(parentId?) → neutral ShopCategory[], co-located isShopCategoryBrowser guard) — the shop-side sibling of the marketplace CategoryBrowser (on OfferManagerPort). Unlike a marketplace’s closed leaf-gated taxonomy, a shop lets a product be placed in any node, so ShopCategory carries no leaf flag. It is advertised-without-dispatch (declared in the WooCommerce manifest supportedCapabilities for host/FE discovery, resolved by narrowing the dispatched ProductPublisher adapter with the guard — never getCapabilityAdapter('ShopCategoryBrowser'); not in CoreCapabilityValues). The WooCommerce adapter implements it over GET /products/categories (parent-scoped, paged at the WC max per_page=100); ShopCategoryBrowseService + GET /listings/connections/:id/shop-publish/categories expose it, and a self-contained FE ShopCategoryPickerModal + useShopCategoriesQuery back the publish edit flow’s category picker (mounted by #1830).
  • Shop-publish operational parity (#1845): brings the shop-publish path to parity with the mature offer path across four axes. (1) Partial-submit atomicityBulkShopPublishSubmitService mirrors BulkListingSubmitService: on a mid-fan-out enqueue failure it reconciles totalCount down to what actually reached the stream, deletes orphaned pre-created ListingCreationRecords, and level-triggers the terminal-status derivation (succeeded + failed === totalCount) so a stranded running batch can never occur (previously it flipped the whole batch failed while already-enqueued children kept running). (2) Status reconcile — the steady-state shop.product.statusSync job drains one page of a connection’s published/draft records per tick via ShopStatusSyncService, reads live shop-side status through the new ShopProductStatusReader sub-capability of ShopProductManagerPort, and upserts a shop_product_status_snapshots row (neutral ShopPublicationStatus = published | draft | unpublished | removed) — the shop-side counterpart to offer_status_snapshots (#816). WooCommerce maps its native publish/draft/pending/private/trash + a 404 onto the neutral union; scheduled per WC connection (ProductPublisher-gated, hourly). (3) RetryBulkShopPublishRetryService re-runs only the failed children, rebuilding each shop.product.publish V2 payload from a new per-item ListingCreationRecord.request snapshot (persisted at enqueue) under a wave-distinct idempotency key (mirrors BulkListingRetryService #742). (4) Duplicate handlingProductPublishExecutionService now swallows a first-publish DuplicateIdentifierMappingError only after verifying the winning ShopProduct mapping claims the same internal variant; a divergent mapping raises ShopProductMappingConflictException instead of silently mis-linking.
  • Shop global-attribute read + publish linkage (#1835, ADR-024): shops previously emitted every neutral publish parameter as a per-product free-text custom attribute ({name, options}), dropping valuesIds — which cannot power storefront filtering. ShopAttributeReader is a sub-capability of ShopProductManagerPort (listAttributes() + listAttributeTerms(attributeId) → neutral ShopAttribute[] / ShopAttributeTerm[], co-located isShopAttributeReader guard) that reads the shop’s store-wide global attributes (pa_*) and their predefined terms. Advertised-without-dispatch (declared in the WooCommerce manifest for discovery, resolved by narrowing the dispatched ProductPublisher adapter — not in CoreCapabilityValues). The WooCommerce adapter reads GET /products/attributes + GET /products/attributes/{id}/terms, and on publish links a real global attribute when a neutral OfferParameter carries valuesIds (term ids) plus a numeric id (the global-attribute id) — emitted as { id, options: <term names>, visible } — keeping free-text { name, options } as the fallback. ShopAttributeReadService + GET /listings/connections/:id/shop-publish/attributes[/:attributeId/terms] expose the reads, and a self-contained FE ShopAttributePicker (+ useShopAttributesQuery / useShopAttributeTermsQuery) backs the publish edit flow’s structured attribute picker (mounted by #1830).
  • Native WooCommerce variable products / variations (#1836, ADR-024): lifts the variable-product deferral from ADR-024 §Consequences. A multi-variant OL product now publishes as one shared WooCommerce type:'variable' parent + one products/{parentId}/variations entry per sibling — a single-variant / simple product keeps the pre-#1836 type:'simple' path unchanged. ProductPublishBuilderService populates the shop-publish sibling of the marketplace OfferVariantGroup (#1065) — PublishProductCommand.variantGroup: PublishProductVariantGroup (groupId = the OL product id, this variant’s own distinguishing attribute values, plus groupAttributeValues — the UNION of every sibling’s values per attribute name, since the parent must declare its full variation-axis option set up front) — whenever getVariantsByProductId(...).length > 1, mirroring the #1065 populate rule. ProductPublishExecutionService additionally resolves the parent’s ShopProduct mapping (keyed on groupId, distinct from the variant’s own mapping — no schema change, since product and variant internal ids are distinctly prefixed and coexist in the same ShopProduct entityType) and stamps it onto variantGroup.externalParentProductId; PublishProductResult.externalParentProductId reports the resolved (created-or-reused) parent id back so the execution service persists that mapping the first time it resolves, under the same concurrency-safe swallow-or-conflict handling (#1845) already used for the variant’s own mapping. The WooCommerce adapter’s parent body carries variation-flagged custom attributes ({name, options: <every sibling's value>, variation: true} — custom, not global pa_*, since ProductVariant.attributes are freeform) and a groupId-keyed slug (stable regardless of which sibling triggers the parent upsert); each sibling’s own variation body carries its price/sku/stock/image/barcode/weight/commerce fields plus its own attribute value (WC’s singular option, not the parent’s plural options).
  • Required-to-sell preflight (#1842): a publish can succeed on the destination while still being unbuyable there (a WooCommerce product missing weight/dimensions breaks live-rate shipping calc at checkout; zero stock with no backorder support can’t be purchased at all). checkRequiredToSell (@openlinker/core/listings) is a pure, side-effect-free function — no I/O, reads only fields already resolved onto a PublishProductCommand-shaped input (stock, weight, commerce.dimensions) — that reports RequiredToSellIssue[] (OUT_OF_STOCK / MISSING_WEIGHT / MISSING_DIMENSIONS, each carrying a severity). block gates the publish exactly like the builder’s existing price/unresolved-parameter gates; warn is a soft, operator-overridable signal. Every rule shipped today is warn — the severity field exists so a future hard-required rule (marketplace or shop) doesn’t need a shape change. ProductPublishBuilderService.buildPublishProductCommand runs the check on every shop publish and logs warn-level issues; the bulk shop-publish Review step (bulk-shop-review-step.tsx) runs the same stock rule client-side (a small FE-local mirror, checkShopLineSellability — no network call) against the resolved stock it already computes, surfacing an out-of-stock banner + per-row chip with a required “publish anyway” acknowledgement before submit (soft-block, not a late platform reject). The weight/dimensions rules are wired at the builder today; surfacing them in Review is blocked on the wizard gaining weight/dimensions input (tracked separately as WooCommerce field-completeness work in epic #1838) — the checker’s input shape already accepts them, so wiring is additive once that data exists. This is also the cross-cutting seam for a future marketplace-side check (e.g. delivery/return-policy completeness on CreateOfferCommand): same RequiredToSellIssue output shape, a different pure input projection — not built yet, no marketplace requirement is scoped.

See ADR-007 for the decision rationale.

  • Responsibility: Job scheduling and retry logic; workers execute jobs. Sync orchestration policies live in core application services (e.g., order ingestion, inventory propagation), not in worker handlers.
  • Key Services: SyncJobService, RetryService, SchedulerService
  • Location: libs/core/src/sync/ (core sync infrastructure), apps/worker/src/sync/ (job runners/handlers)
  • Status vs outcome (#391 / #400): sync_jobs.status (queued | running | succeeded | dead) tracks orchestration. sync_jobs.outcome ('ok' | 'business_failure' | null) tracks the business result, set only on the succeeded path. Each SyncJobHandler.execute() returns a SyncJobHandlerResult whose outcome the runner persists via markSucceeded(id, outcome) — atomic with the status flip. OfferCreationExecutionService is the first orchestrator to derive business_failure from a terminal-rejection branch; other handlers return 'ok' mechanically until they grow their own domain-failure semantics.
  • Responsibility: Event-driven communication between modules
  • Technology: Redis Streams (initial), RabbitMQ/Kafka (future)
  • Location: libs/core/src/events/

See ADR-004 for the decision rationale.

  • Responsibility: Centralized identifier mapping between external platform IDs and internal OpenLinker IDs
  • Key Services: IdentifierMappingService
  • Location: libs/core/src/identifier-mapping/
  • Key Features:
    • Generates unique internal identifiers for all entities (single seed across entire system)
    • Maps external platform identifiers to internal OpenLinker identifiers
    • Context-aware mapping (entity type, platform, etc.)
    • Used by adapters to replace external IDs with internal IDs during data transformation
  • Architecture: Core infrastructure service used by all adapters

See ADR-003 for the decision rationale.

  • Responsibility: Adapter registry, per-connection adapter resolution, capability validation, registries for cross-cutting per-adapter capabilities (connection-test, webhook provisioning, connection-config + credentials shape validation).
  • Key Services: IntegrationsService, AdapterRegistryService, ConnectionService, ConnectionTesterRegistryService, WebhookProvisioningRegistryService, ConnectionConfigShapeValidatorRegistryService, ConnectionCredentialsShapeValidatorRegistryService.
  • Location: apps/api/src/integrations/ (API layer), libs/core/src/integrations/ (core domain).
  • Webhook provisioning (#583): WebhookProvisioningPort defines install(connectionId, actorUserId?) returning a neutral WebhookProvisioningResult (webhooksConfigured, testPingTriggered, optional warning). Each integration package self-registers its adapter against WebhookProvisioningRegistryService in onModuleInit (today only PrestashopWebhookProvisioningAdapter at prestashop.webservice.v1). ConnectionService.installWebhooks is the single dispatch layer: it resolves the connection’s adapter via IntegrationsService.resolveAdapterMetadata, looks the provisioner up by adapterKey, and returns 400 if no provisioner is registered. ConnectionController.installWebhooks is a thin pass-through — the API package boots without any PrestaShop-specific binding, which keeps Modularity Thread E HIGH closed.
  • Connection config & credentials shape validation (#586 / #587): ConnectionConfigShapeValidatorPort and ConnectionCredentialsShapeValidatorPort define validate(payload): Promise<void>, throwing InvalidConnectionConfigException / InvalidCredentialsShapeException from libs/core/src/integrations/domain/exceptions/ with a flat list of { path, message } issues. Each integration package self-registers its validators against the host’s two registry services in plugin.register(host) — today Allegro registers a config-shape validator at allegro.publicapi.v1 (credentials shape is enforced deeper, at AllegroAdapterFactory.resolveCredentials), and PrestaShop registers both. ConnectionService.create / update / updateCredentials are the single dispatch layer: they call IntegrationsService.resolveAdapterMetadata, look the validator up by adapterKey, and short-circuit when no validator is registered (closing the previous platform-switch in apps/api/src/integrations/application/services/util/). The API maps the domain exceptions to BadRequestException({ message, errors }) at the boundary; plugins never depend on @nestjs/common. The structural ValidationErrorLike type in libs/core keeps class-validator out of the core runtime — the function consumes the shape, and plugins that opt into class-validator-based DTOs depend on it from their own package.json.
  • Static manifest export (#575): every adapter package exports its AdapterMetadata as a top-level const from the package barrel — allegroAdapterManifest from @openlinker/integrations-allegro, prestashopAdapterManifest from @openlinker/integrations-prestashop, erliAdapterManifest from @openlinker/integrations-erli (#980 — ships supportedCapabilities: []; #984/#993 add OfferManager/OrderSource together with the adapters that deliver them). The runtime plugin descriptor (createAllegroPlugin(deps).manifest) returns the same reference, so static and runtime views can’t drift. The static export is the seam for future host-side tooling — manifest-diff CLIs, capability-matrix dashboards, compatibility checks at boot — that needs to read adapterKey / supportedCapabilities / version without instantiating the plugin (which would require resolving its cross-package deps).
    • Advertised-without-dispatch sub-capabilities: a manifest capability is not always backed by a dispatchCapability table entry. Finer OfferManager sub-capabilities — Allegro’s CategoryBrowser / EanCategoryMatcher (#1367), Allegro’s OfferCreator / OfferEventReader and Erli’s OfferCreator (#1498) — are declared in supportedCapabilities purely so host-side discovery (the connection response) can tell them apart, and are resolved only by narrowing the dispatched OfferManager adapter with its is* guard (isCategoryBrowser, isOfferCreator, …), never via getCapabilityAdapter(connectionId, 'OfferCreator') directly. Calling getCapabilityAdapter / listCapabilityAdapters with an advertised-without-dispatch name still passes the manifest gate but then fails inside dispatchCapability (a generic Error, not AdapterNotFoundException — in the list path that aborts the whole listing instead of skipping the connection, integrations.service.ts). No current caller does this; the pattern is safe as long as call sites keep resolving these sub-capabilities through the guard, not the registry.
  • Type-safe capability dispatch (#573): @openlinker/plugin-sdk ships dispatchCapability<T>(capability, table, pluginName). Plugin createCapabilityAdapter implementations declare a typed dispatch table { OfferManager: () => offerManager, OrderSource: () => orderSource } and the helper returns the matching adapter cast to T. The cast lives in one place rather than once per case per plugin. True compile-time enforcement of capability ↔ T is blocked by the open string-set design (#576) — plugins can register new capability names at runtime, so a closed Record<Capability, AdapterPort> keyed by literal capability names can’t exist statically. The helper is the right size for the problem: it deletes the per-case as unknown as T boilerplate and gives plugin authors a uniform error message format (“{pluginName} adapter does not support capability: {capability}. Supported capabilities: …”, rendering <none> when the dispatch table is empty).
  • Responsibility: Structured logging, metrics, tracing
  • Contract: LoggerPort (log/debug/warn/error) shipped from @openlinker/shared/logging. The consumer-facing Logger factory (new Logger(ClassName.name)) delegates to a process-wide active backend.
  • Backends: ConsoleLoggerAdapter is the zero-dependency default that ships in the same package, so plugins compiled against @openlinker/shared get working logs without any host wiring. The NestJS-backed NestLoggerAdapter lives on the host-only subpath @openlinker/shared/logging/nest; hosts call installNestLogger() at the top of bootstrap() to swap it in (#589). Plugins never transitively pull @nestjs/common through the logger.
  • Tracing: OpenTelemetry (future).
  • Location: libs/shared/src/logging/ (port + default + factory); libs/shared/src/logging/nest/ (Nest-backed adapter, host-only).
  • Responsibility: Per-product, per-channel (or master) content fields with draft write-through and conflict detection. First field key: description.
  • Key Entities: ProductContentField
  • Location: libs/core/src/content/
  • Capability: Uses ContentPublisherPort for outbound publishing. Master path resolves ProductMasterPort via the integrations registry and calls updateProduct with a keyed patch. Channel path resolves an OfferManagerPort for the target connection, requires the OfferFieldUpdater sub-capability, walks the product’s variants → OfferMappingRepositoryPort.findMany → distinct external offer IDs, and issues one updateOfferFields per distinct offer (Allegro TEXT-section payload; idempotency key content:{productId}:{connectionId}:{publishTimestamp}).
  • AI suggestion: ContentSuggestionService composes IIntegrationsService (fetch product + variants) + IPromptTemplateService (render the offer.description.suggest template for the current channel) + AiCompletionPort (generate). Bound in the API layer (apps/api/src/content/content.module.ts) because AI_COMPLETION_PORT_TOKEN is only provided where AiIntegrationModule.register() is registered.
  • Storage: product_content_field table with two partial unique indexes (master vs channel) to honour Postgres’ NULL-distinct uniqueness for the nullable connection_id column.
  • Conflict model: optimistic — inbound reconcile sets has_conflict=true when an external version diverges while a draft is pending; re-saving the draft is treated as implicit acknowledgement and clears the flag.
  • Responsibility: Provider-agnostic LLM completions for content generation, plus editable prompt-template storage (versioned draft/publish lifecycle) consumed by the suggestion flow.
  • Key Port: AiCompletionPort (complete(input) → result).
  • Key Entity: PromptTemplate (libs/core/src/ai/domain/entities/prompt-template.entity.ts) — one row per (key, channel, version), stateful (draft | published | archived). The channel axis is open-world (#580): PromptTemplateChannel = string carries any platform connection’s platformType, mirroring the same open-world shape used for capability (#576) and platformType (#578/#579). Plugin authors register templates against new channels without editing core. The DTOs use @IsString() @IsNotEmpty() (matching the platformType precedent in CreateConnectionDto); the controller maps the 'master' query sentinel to null and forwards every other non-empty string verbatim. FE picker and list rendering are registry-driven via usePlugins().
  • Key Service: PromptTemplateService (libs/core/src/ai/application/services/prompt-template.service.ts) — CRUD, publish (archives previous published row transactionally), revert (clones a historical version into a new draft), render (renderTemplate pure helper substitutes {{dotted.path}} placeholders with strict-required / optional / passthrough semantics).
  • Location: libs/core/src/ai/.
  • Adapter package: libs/integrations/ai/ (workspace @openlinker/integrations-ai) — registers one VercelAiCompletionAdapter instance per supported provider (anthropic via @ai-sdk/anthropic, openai via @ai-sdk/openai) plus FakeAiCompletionAdapter for tests / offline dev. AI_COMPLETION_PORT_TOKEN resolves to MultiProviderAiCompletionAdapter, a router that reads the active provider on every call and delegates to the matching per-provider adapter. Anthropic-specific cache-control on the system message is gated to provider === 'anthropic' so the OpenAI request never carries a stray providerOptions.anthropic block.
  • Selection (#451 / #452): the active provider is a runtime setting persisted on the singleton ai_provider_active_setting table. Resolution: DB row → OL_AI_PROVIDER env (first-boot fallback) → 'anthropic' default. Admins switch the active provider via PUT /ai-provider-settings/active; the router reads the setting through-the-DB on every completion (no in-process cache — singleton-row PK lookup is sub-millisecond). Per-provider keys live at ref = ai-provider:{provider} in the encrypted integration_credentials table; CredentialsAiProviderAdapter retains a 60 s per-provider key cache.
  • Admin surface: PromptTemplatesController at apps/api/src/ai/http/prompt-templates.controller.ts; AiProviderSettingsController at apps/api/src/ai/http/ai-provider-settings.controller.ts exposes GET /ai-provider-settings, PUT /keys/:provider, DELETE /keys/:provider, PUT /active (all @Roles('admin')). FE admin UI at /ai/prompt-templates and /ai/provider-settings.
  • Storage: prompt_templates table with four partial unique indexes honouring NULL-distinct semantics on the nullable channel column (version uniqueness + “at most one published per (key, channel)”).
  • Telemetry: per-completion structured log { requestId, model, latencyMs, inputTokens, outputTokens, cachedInputTokens }; publish / revert actions log { templateId, key, channel, version, actor }.
  • Worker registration (#737): AiIntegrationModule.register() is registered in apps/worker/src/plugins.ts so the bulk-flow marketplace.offer.create handler can call ContentSuggestionService.suggestDescription() per-job for AI-generated offer descriptions. AI_COMPLETION_PORT_TOKEN resolves through PluginRegistryModule.forRoot({ plugins: workerPlugins }) in the worker’s AppModule. The suggestion binding lives at apps/worker/src/content/worker-content.module.ts (mirrors apps/api/src/content/content.module.ts) — it cannot live in libs/core/src/content/content.module.ts because that would force core to value-import @openlinker/integrations-ai, reversing the core → integration dependency direction.
  • Shop-publish AI descriptions (#1840): the outbound shop-publish path (shop.product.publish) reuses the same seam — the ShopProductPublishHandler (worker) calls ContentSuggestionService.suggestDescription({ channel: 'woocommerce', ... }) to fill content.description when the operator sets generateDescription on the single/bulk shop-publish submit (threaded via EnqueueProductPublishInputShopProductPublishPayload). Same placement + precedence as the offer flow: an explicit operator content.description override always wins (never overwritten), and any AI failure (variant lookup, missing template, LLM error) logs a warning and falls through to the master description. The offer.description.suggest prompt template is seeded per channel; #1840 adds the woocommerce seed alongside the existing allegro / prestashop rows. The FE toggle lives in the WooCommerce publish wizard’s Configure step; a per-row override awaits the #1830 publish edit modal (the payload + worker already honor a per-item explicit description).

See ADR-026 for the country-agnostic design.

  • Responsibility: Issue fiscal documents for orders, optionally submit them to a tax authority for clearance, and reconcile the authority-assigned identifier + receipt. Country-agnostic by construction — no NIP/KSeF/FA/VAT-specific vocabulary lives in libs/core; all national specifics are confined to the provider adapter package.
  • Key Entities: InvoiceRecord (neutral projection of an issued document), value types DocumentType, InvoiceStatus (pending | issued | failed), RegulatoryStatus (not-applicable | submitted | cleared | accepted | rejected).
  • Location: libs/core/src/invoicing/.
  • Capability: InvoicingPort (base — issueInvoice / getInvoice / upsertCustomer / getSupportedDocumentTypes) plus composable sub-capabilities in domain/ports/capabilities/, each with a co-located is* guard:
    • RegulatoryStatusReader — read the clearance status of a previously-submitted document.
    • RegulatoryTransmitter (extends RegulatoryStatusReader) — submit a document for clearance + read its status. getSupportedDocumentTypes() is value-level discovery (which neutral types a provider can issue), distinct from the method-bearing sub-capability.
    • RegulatoryDocumentReader (#1224) — retrieve the authority’s confirmation document (e.g. the PL UPO) or a rendered view for a cleared document, as a neutral RegulatoryDocument blob.
    • BankAccountsReader (#1303 follow-up) — list the seller’s payable bank accounts known to the provider (neutral InvoicingBankAccount), backing the live picker for Transfer invoices.
    • BankAccountDefaultSetter (extends BankAccountsReader, #1303 follow-up) — mark an account as the provider’s own default so it stays in sync with the account OL stamps on Transfer invoices.
    • RegulatoryResubmitter (#1356) — re-trigger transmission of an ALREADY-ISSUED document to the tax authority, for the operator “resend to KSeF” action on a document whose clearance ended in rejected. Kept flat (does not extends RegulatoryStatusReader) — it backs a NATIVELY-transmitting provider (e.g. inFakt) that needs an explicit resubmit kick from OL, distinct from RegulatoryTransmitter (OL itself holds the authority session).
    • PaymentStatusReader (#1354) — authoritative re-read of a document’s payment state; a provider payment webhook is only a trigger, core always re-reads via this capability rather than trusting the webhook body.
    • PaymentMarker (#1362) — push an authoritative “paid” state to the provider for an order settled elsewhere (e.g. a marketplace order the buyer paid off-platform, so the provider’s own bank-statement matching has nothing to reconcile against).
    • InvoiceEmailSender (#1353) — trigger the provider to render and email the already-issued invoice to the buyer.
    • CorrectionIssuer (#1229) — issue a correction (IssueCorrectionCommand) of an already-issued document. IssueCorrectionCommand.originalDocument is an OPTIONAL, caller-assembled reconstruction (buyer/currency/lines/clearance linkage) for adapters that cannot correct via a per-line delta and must resubmit a complete document (KSeF’s FA(3) KOR, #1288); adapters that only need deltas (Subiekt) ignore the field. Issuance-time line snapshot (#1297): InvoiceRecord persists a neutral issuedLineSnapshot ({ buyer, currency, lines }) whenever a document is issued (from the issue command) or corrected (the correction’s own post-correction lines) — captured in the core InvoiceService, so no adapter change. The HTTP controller assembles originalDocument from that snapshot on the document being corrected, so the originalLineNumber-indexed deltas diff against the lines as issued (and, for a correction-of-correction, against the prior correction’s own lines) even if the order changed since. Only records issued before the snapshot column existed fall back to rebuilding from the order’s current state (the pre-#1297 path, with its buyerTaxId: null + line-fidelity caveats).
  • KSeF (Polish national e-invoicing), @openlinker/integrations-ksef, adapterKey ksef.publicapi.v2, registry capability Invoicing. KsefInvoicingAdapter implements InvoicingPort, RegulatoryTransmitter, and CorrectionIssuer (#1288): FA(3) VAT + KOR documents are issued and cleared through the async submit→poll→UPO model (issueInvoice), and issueCorrection is a pure delegation into the same KOR path (KSeF has no delta-only correction primitive — every correction resubmits a complete FA(3) document). See the KSeF setup guide for the full flow, current limitations, and compliance caveats.
  • Infakt (Polish accounting SaaS), @openlinker/integrations-infakt, adapterKey infakt.accounting.v1, registry capability Invoicing (#1281). Infakt submits to KSeF on the seller’s behalf rather than OL building FA(3) XML itself, so InfaktInvoicingAdapter implements RegulatoryStatusReader (poll the KSeF-relay clearance status Infakt reports) rather than RegulatoryTransmitter. It also implements CorrectionIssuer, RegulatoryDocumentReader, RegulatoryResubmitter (re-trigger transmission of an already-issued document — the operator “resend” action for a rejected clearance), BankAccountsReader / BankAccountDefaultSetter, plus the accounting-specific PaymentStatusReader / PaymentMarker and InvoiceEmailSender sub-capabilities.
  • Subiekt nexo (via Sfera bridge), @openlinker/integrations-subiekt — the first InvoicingPort adapter (#753), implementing RegulatoryStatusReader, CorrectionIssuer, BankAccountsReader, and BankAccountDefaultSetter.

See ADR-002 for the decision rationale.

Instead of coding directly against specific systems (e.g., PrestaShop, Allegro), the core domain depends on business capability abstractions (ports). This allows:

  • Flexibility: Switch implementations without changing core logic
  • Testability: Easy to mock for testing
  • Clarity: Business intent is explicit in code

Purpose: Single source of truth for inventory/stock levels.

Interface:

interface InventoryMasterPort {
/**
* Get current inventory for a product
*/
getInventory(productId: string, locationId?: string): Promise<Inventory>;
/**
* Adjust inventory (increase or decrease)
*/
adjustInventory(adjustment: InventoryAdjustment): Promise<Inventory>;
/**
* Reserve inventory for an order
*/
reserveInventory(productId: string, quantity: number, orderId: string): Promise<void>;
/**
* Release reserved inventory
*/
releaseInventory(productId: string, quantity: number, orderId: string): Promise<void>;
/**
* Get available quantity (total - reserved)
*/
getAvailableQuantity(productId: string, locationId?: string): Promise<number>;
}

Current Implementations: PrestashopInventoryMasterAdapter, WooCommerceInventoryMasterAdapter

Future Implementations:

  • OpenLinkerInventoryMasterAdapter (OpenLinker’s own inventory system)
  • ShopifyInventoryMasterAdapter

Purpose: Single source of truth for product catalog. Manages product data, variants, attributes, and categories.

Interface:

interface ProductMasterPort {
/**
* Get product by ID
*/
getProduct(productId: string): Promise<Product>;
/**
* Get products with filters
*/
getProducts(filters?: ProductFilters): Promise<Product[]>;
/**
* Create a new product
*/
createProduct(product: ProductCreate): Promise<Product>;
/**
* Update an existing product
*/
updateProduct(productId: string, product: ProductUpdate): Promise<Product>;
/**
* Delete a product
*/
deleteProduct(productId: string): Promise<void>;
/**
* Get product variants
*/
getProductVariants(productId: string): Promise<ProductVariant[]>;
/**
* Create or update product variant
*/
upsertProductVariant(productId: string, variant: ProductVariantCreate): Promise<ProductVariant>;
/**
* Get product categories
*/
getProductCategories(productId: string): Promise<Category[]>;
/**
* Assign product to categories
*/
assignCategories(productId: string, categoryIds: string[]): Promise<void>;
/**
* Search products by query
*/
searchProducts(query: string, filters?: ProductFilters): Promise<Product[]>;
}

Current Implementations: PrestashopProductMasterAdapter, WooCommerceProductMasterAdapter

Future Implementations:

  • OpenLinkerProductMasterAdapter (OpenLinker’s own product catalog system)
  • ShopifyProductMasterAdapter

Usage Example:

@Injectable()
export class ProductSyncService {
constructor(
private readonly productMaster: ProductMasterPort, // ✅ Port interface
) {}
async syncProductToMarketplace(productId: string, marketplaceId: string) {
// Get product from master
const product = await this.productMaster.getProduct(productId);
// Map to marketplace format and publish
// ...
}
}

Purpose: Create orders on a destination shop. The base port carries only createOrder — the single method every order destination must implement. Post-create status/tracking writes, lifecycle reads, and option discovery are split into composable sub-capabilities (mirroring OfferManagerPort); a full OL-owned order-lifecycle state machine (updateOrderStatus / cancelOrder / processReturn / getOrders as authoritative operations) is deferred — see #1032.

Interface:

interface OrderProcessorManagerPort {
/**
* Create a new order on the destination shop.
*
* Returns the destination-native external order id (OrderRef.orderId),
* never an internal OpenLinker id (#909). Idempotency and the
* external↔internal mapping write are owned by OrderSyncService under a
* per-(order, destination) lock — the adapter creates unconditionally.
* Lines MUST be priced at the buyer-paid source price (#895, ADR-014).
*/
createOrder(order: OrderCreate): Promise<OrderRef>;
}

Sub-capabilities (in libs/core/src/orders/domain/ports/capabilities/):

Each is an independent interface + co-located is{Capability}(adapter) type guard. Destinations declare what they support via implements OrderProcessorManagerPort, OrderFulfillmentUpdater, …; call sites resolve the destination adapter via getCapabilityAdapter<OrderProcessorManagerPort>(connectionId, 'OrderProcessorManager'), narrow with the guard, and degrade gracefully (skip) when a destination doesn’t implement it.

CapabilityMethod(s)Notes
OrderFulfillmentUpdaterupdateFulfillment({ externalOrderId, status, trackingNumber? })Push a post-create status + tracking update to the destination order (#837). PrestaShop maps the neutral OrderStatus to its native state.
OrderStatusWritebackwrite(event: OrderLifecycleEvent)Relay-based, source-bound order-status round-trip — push a status change back to the originating marketplace (#1157, ADR-027).
FulfillmentStatusReadergetFulfillmentStatus({ externalOrderId })Read a destination order’s current fulfillment status.
DestinationOptionsReaderlistCarriers(), listOrderStatuses(), listPaymentMethods()Discover the destination’s option vocabulary for mapping.
SourceOptionsReaderlistOrderStatuses(), listDeliveryMethods(), listPaymentMethods()Discover a source’s option vocabulary for mapping.

Current Implementations: PrestashopOrderProcessorAdapter, WooCommerceOrderProcessorAdapter

Future Implementations:

  • OpenLinkerOrderProcessorAdapter (OpenLinker’s own order system)
  • ShopifyOrderProcessorAdapter

Purpose: Read-only, cursor-capable ingestion of orders from any source — marketplaces (Allegro event journal) and shops (PrestaShop date_upd watermark). Platform-neutral; the cursor is an opaque adapter-defined string.

Interface:

interface OrderSourcePort {
/**
* List incremental order feed items (event journal).
* `fromCursor` null = start from the beginning; `nextCursor` null = no more pages.
*/
listOrderFeed(input: OrderFeedInput): Promise<OrderFeedOutput>;
/**
* Hydrate a full order by source-native external id.
* Returns an IncomingOrder; identifier mapping happens in core services.
*/
getOrder(input: { externalOrderId: string }): Promise<IncomingOrder>;
}

Current Implementations: AllegroOrderSourceAdapter, PrestashopOrderSourceAdapter, WooCommerceOrderSourceAdapter

Future Implementations: ShopifyOrderSourceAdapter, ErliOrderSourceAdapter (#993 — plugin skeleton registered at erli.shopapi.v1, see ADR-025)

Purpose: Base capability contract for marketplace offer/listing management. Split out of the legacy MarketplacePort (#328); the previously-optional methods were extracted into distinct capability interfaces (#337). The base port carries only the single method every marketplace adapter must implement.

Interface:

interface OfferManagerPort {
updateOfferQuantity(cmd: UpdateOfferQuantityCommand): Promise<void>;
}

Sub-capabilities (in libs/core/src/listings/domain/ports/capabilities/):

Each is an independent interface + co-located is{Capability}(adapter) type guard. Adapters declare the capabilities they support via implements OfferManagerPort, OfferLister, OfferCreator, …; call sites narrow via the guard before invoking the method — after the guard TypeScript knows the method is present.

The table below is a curated highlight. The complete, code-synced inventory of all 36 sub-capabilities across every port (with descriptions and guards) lives in docs/capabilities.md.

CapabilityMethod
OfferListerlistOffers(input)
OfferEventReaderlistOfferEvents(input)
OfferQuantityBatchUpdaterupdateOfferQuantitiesBatch(cmd)
OfferFieldUpdaterupdateOfferFields(cmd)
OfferStockRestorerrestoreStockOnCancellation(targets)
CategoryBrowserfetchCategories(parentId?)
CategoryBarcodeMatchermatchCategoryByBarcode(barcode)
OfferCreatorcreateOffer(cmd)
OfferStatusReadergetOfferStatus(externalOfferId)
OfferSmartClassificationReadergetOfferSmartClassification(externalOfferId)
SellerPoliciesReaderfetchSellerPolicies()
CatalogProductReaderfindProductsByBarcode(input), getProduct(input)

Current Implementations: AllegroOfferManagerAdapter (implements every capability except OfferQuantityBatchUpdater); ErliOfferManagerAdapter (registered at erli.shopapi.v1, reconciliation-first posture per ADR-025, #984); WooCommerceOfferManagerAdapter (#1498 — base-port-only stock write-back to published shop products: updateOfferQuantityPUT /products/{id} with manage_stock: true. No offer-creation sub-capabilities; the propagation fan-out reaches it via ShopProduct mappings (second fan-out branch in InventoryPropagateToMarketplacesHandler), not Offer mappings. Write-back defaults OFF on new WC connections and is mutually exclusive with InventoryMaster per connection — the inventory master is never a write-back target).

Future Implementations: ShopifyOfferManagerAdapter, EbayOfferManagerAdapter.

  • PricingAuthorityPort: Manages pricing rules and catalog pricing. A capability-port-shaped PricingAuthorityPort is still future work, but its underlying pricing-resolution seam already exists (#1843): readPricingRule / applyPricingRule (libs/core/src/identifier-mapping/domain/types/pricing-rule.types.ts) are pure, marketplace-neutral helpers — mirroring the stockSafetyBuffer config-coercion precedent (#1844) — that resolve a destination price from the master catalog price via a per-connection Connection.config.pricingRule ({ type: 'passthrough' | 'markup' | 'margin', percent?, rounding?: 'none' | 'nearestWhole' | 'endingIn99' }, JSONB — no schema/migration change). markup is cost-plus (price = base * (1 + percent/100)); margin solves for the price whose margin over the base equals percent (price = base / (1 - percent/100), guarded for percent >= 100). Both OfferBuilderService and ProductPublishBuilderService call it as the sole source of command.price whenever no explicit per-item input.price is supplied (an explicit price always wins and is never re-priced) — replacing the prior raw product.price passthrough. A connection with no configured rule is untouched (byte-identical to the pre-#1843 passthrough). No FE control exists yet for editing config.pricingRule (same posture as stockSafetyBuffer today) — a follow-up.
  • ShippingProviderManagerPort: Orchestrates shipping and tracking
  • PaymentProcessorPort: Handles payment processing

Capability is open at the registry boundary (#576). The well-known set lives in CoreCapabilityValues as the closed CoreCapability type; adapter metadata (AdapterMetadata.supportedCapabilities), the IntegrationsService resolution methods, and the connection entity’s enabledCapabilities accept CoreCapability | string. Plugin adapters can register new capability names without a core PR — the runtime gate at IntegrationsService.getCapabilityAdapter validates against metadata.supportedCapabilities. The HTTP request DTOs remain strict on CoreCapabilityValues until a runtime-aware DTO validator follow-up lands.

This same getCapabilityAdapter seam — with its per-connection gating and encrypted credential isolation sitting below it — is the mount point for exposing OpenLinker as an MCP server (agents drive OL), where MCP tools become a new Interface-layer adapter over the existing application services and tools/list is dynamic and capability-declared (each tool declares a required capability/sub-capability and is registered iff an in-scope connection supports it — a base port backs several tools, a decomposed port one per sub-capability; connectionId as an argument, via the is{Capability} guards). See ADR-033 for the decision, security model, and phased plan, and ADR-034 for the auth layer (OL as an OAuth 2.1 Resource Server validating user-issued Personal Access Tokens; an OAuth Authorization Server is a deferred optional upgrade).


The IdentifierMappingService is a core infrastructure service responsible for managing the mapping between external platform identifiers (e.g., PrestaShop product ID, Allegro order ID) and internal OpenLinker identifiers. It ensures that all entities in the system have unique internal identifiers from a single unified seed, regardless of their origin platform.

  1. Generate Internal Identifiers: Creates new unique internal IDs for entities when they are first encountered from external platforms
  2. Map External to Internal: Provides mapping from external platform IDs to internal OpenLinker IDs
  3. Context-Aware Mapping: Handles mapping based on entity type (Product, Order, Offer, etc.), platform, and context
  4. Maintain Mapping Registry: Stores and retrieves mappings between external and internal identifiers

The system supports multiple integrations of the same platform (e.g., two PrestaShop stores). Each integration is represented by a Connection entity:

interface Connection {
id: string; // Unique connection ID
platformType: string; // 'prestashop', 'allegro', etc.
name: string; // Human-readable name
status: 'active' | 'disabled' | 'error' | 'needs_reauth';
config: Record<string, any>; // Connection-specific configuration
credentialsRef: string; // Reference to credentials storage
createdAt: Date;
updatedAt: Date;
}

Why connections?

  • Support multiple instances of the same platform (e.g., multiple PrestaShop stores)
  • Each connection has its own configuration and credentials
  • Mappings are connection-scoped, not platform-scoped
interface IdentifierMappingService {
/**
* Get or create internal identifier for an external entity
* If mapping exists, returns existing internal ID
* If not, generates new internal ID and creates mapping
*/
getOrCreateInternalId(
entityType: CoreEntityType | string,
externalId: string,
connectionId: string, // ✅ Connection ID (not platform ID)
context?: MappingContext
): Promise<string>;
/**
* Get internal identifier for an external entity
* Returns null if mapping doesn't exist
*/
getInternalId(
entityType: CoreEntityType | string,
externalId: string,
connectionId: string // ✅ Connection ID
): Promise<string | null>;
/**
* Get external identifier(s) for an internal ID
* Returns all connection-specific external IDs mapped to this internal ID
*/
getExternalIds(
entityType: CoreEntityType | string,
internalId: string
): Promise<ExternalIdMapping[]>;
/**
* Create explicit mapping between external and internal identifiers
* Used for manual mapping or when internal ID already exists
*/
createMapping(
entityType: CoreEntityType | string,
externalId: string,
connectionId: string, // ✅ Connection ID
internalId: string
): Promise<void>;
/**
* Batch get or create internal identifiers
* Optimized for processing multiple entities at once
*/
batchGetOrCreateInternalIds(
requests: IdentifierMappingRequest[]
): Promise<Map<string, string>>; // externalId -> internalId
}
interface MappingContext {
parentEntityType?: string;
parentInternalId?: string;
metadata?: Record<string, any>;
}
interface IdentifierMappingRequest {
entityType: CoreEntityType | string;
externalId: string;
connectionId: string; // ✅ Connection ID
context?: MappingContext;
}
interface ExternalIdMapping {
externalId: string;
platformType: string; // Denormalized from Connection
connectionId: string; // ✅ Connection ID
entityType: string;
}

Internal identifiers are generated from a single unified seed across all entity types:

  • Format: ol_{prefix}_{uuid} where prefix defaults to entityType.toLowerCase()
  • Examples: ol_product_fce2df4d853f4499b955a6bb1a212bd1, ol_variant_e4b98e91340a44edb4892905db8810b1, ol_order_xyz789, ol_offer_def456, ol_shipment_a3f24b09c4d1486789abcdef01234567 (#763)
  • Uniqueness: Guaranteed across all entities in the system
  • Database Storage: Internal IDs are stored as TEXT type in PostgreSQL (not UUID)
  • Prefix overrides: A small ENTITY_TYPE_ID_PREFIX map in identifier-mapping.types.ts overrides the default for entity types where the documented prefix diverges from the lowercased class name. Today: ProductVariant → variant (so IDs are ol_variant_*, not ol_productvariant_*).
  • Canonical Entities: Product, ProductVariant, InventoryItem use internal IDs as primary keys

Adapters are responsible for:

  1. Fetching data from external platforms
  2. Transforming data to OpenLinker unified schema
  3. Replacing external identifiers with internal identifiers using IdentifierMappingService

Example: PrestaShop Product Adapter

@Injectable()
export class PrestashopProductAdapter implements ProductMasterPort {
constructor(
private readonly identifierMapping: IdentifierMappingService,
private readonly httpService: HttpService,
private readonly connectionId: string, // ✅ Connection ID for this PrestaShop instance
) {}
async getProduct(productId: string): Promise<Product> {
// 1. Fetch product from PrestaShop API
const prestashopProduct = await this.httpService.get(
`/products/${productId}`
);
// 2. Transform to OpenLinker schema
const product: Product = {
// ... map PrestaShop fields to OpenLinker schema
name: prestashopProduct.name,
sku: prestashopProduct.reference,
// ...
};
// 3. Replace external ID with internal ID (using connectionId)
const internalId = await this.identifierMapping.getOrCreateInternalId(
'Product',
productId, // PrestaShop product ID
this.connectionId // ✅ Connection ID (not platform type)
);
// 4. Use internal ID in the returned product
return {
...product,
id: internalId, // Internal OpenLinker ID
externalIds: {
prestashop: productId, // Keep external ID for reference
},
};
}
}

Example: Allegro Order Source Adapter

@Injectable()
export class AllegroOrderSourceAdapter implements OrderSourcePort {
constructor(
private readonly connectionId: string,
private readonly httpClient: IAllegroHttpClient,
private readonly identifierMapping: IdentifierMappingPort,
) {}
async listOrderFeed(input: OrderFeedInput): Promise<OrderFeedOutput> {
// 1. Fetch incremental order events from Allegro
const response = await this.httpClient.get<AllegroOrderEventsResponse>('/order/events', {
queryParams: { from: input.fromCursor ?? undefined, limit: input.limit },
});
// 2. Dedupe by checkoutFormId, map to the neutral OrderFeedItem shape
const items = this.buildFeedItems(response.data.events);
// 3. Next cursor is Allegro-assigned (monotonic per seller)
const nextCursor = response.data.lastEventId ?? items.at(-1)?.eventKey ?? input.fromCursor ?? null;
return { items, nextCursor };
}
async getOrder(input: { externalOrderId: string }): Promise<IncomingOrder> {
// 1. Hydrate checkout-form from Allegro
const checkoutForm = await this.httpClient.get<AllegroCheckoutForm>(
`/order/checkout-forms/${input.externalOrderId}`,
);
// 2. Map to the neutral IncomingOrder DTO — identifier mapping happens
// downstream in OrderIngestionService, not in the adapter.
return this.toIncomingOrder(checkoutForm.data);
}
}

Mappings are stored in PostgreSQL:

// Connection entity
@Entity('connections')
class Connection {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
platformType: string; // 'prestashop', 'allegro', etc.
@Column()
name: string;
@Column()
status: string; // 'active', 'disabled', 'error'
@Column({ type: 'jsonb', nullable: true })
config: Record<string, any>;
@Column({ nullable: true })
credentialsRef: string | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
// Identifier mapping entity
@Entity('identifier_mappings')
class IdentifierMapping {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
entityType: string; // 'Product', 'Order', 'Offer', etc.
@Column()
internalId: string; // OpenLinker internal ID
@Column()
externalId: string; // External platform ID
@Column()
platformType: string; // ✅ Denormalized from Connection (for query performance)
@Column()
connectionId: string; // ✅ References connections.id
@Column({ type: 'jsonb', nullable: true })
context: MappingContext;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
// ✅ Unique constraint: entityType + platformType + connectionId + externalId
@Index(['entityType', 'platformType', 'connectionId', 'externalId'], { unique: true })
@Index(['entityType', 'internalId']) // Reverse lookup
}

Why denormalize platformType?

  • Query performance: Avoids JOINs for common queries
  • Index efficiency: Unique constraint includes platformType for faster lookups
  • Data integrity: platformType is immutable on Connection, safe to denormalize
  1. Unified Identity: All entities have consistent internal identifiers regardless of source
  2. Platform Agnostic: Core domain logic works with internal IDs only
  3. Traceability: Can always find external IDs from internal IDs and vice versa
  4. Adapter Responsibility: Adapters handle ID translation, keeping core domain clean
  5. Single Source of Truth: One service manages all identifier mappings

OpenLinker provides a customer identity resolution service that enables multi-origin customer identity management. This allows the same customer to be recognized across different platforms (e.g., Allegro, PrestaShop direct orders) based on email address.

External-Only Mode (OL_CUSTOMER_IDENTITY_MODE=external_only):

  • Only uses external buyer ID mapping (source connection scoped)
  • No email fallback
  • Each external buyer ID maps to a unique internal customer ID
  • Use Case: When email sharing is common (families, businesses) and you want to avoid incorrect customer merging

Email Fallback Mode (OL_CUSTOMER_IDENTITY_MODE=email_fallback, default):

  • Primary: External buyer ID mapping
  • Fallback: Email hash lookup to link customers across origins
  • Same email → same internal customer ID (across different platforms)
  • Use Case: Better user experience, same customer recognized across platforms
  • Risk: Shared emails (families, businesses) may incorrectly merge customers
  • Mitigation: Collision policy creates new customer if >1 match (no merge)

Customers are destination-owned: the destination platform (e.g., PrestaShop) is the source of truth for customer data. OpenLinker adapters are responsible for creating/updating customers in the external system.

Example: When an Allegro order arrives for a customer that doesn’t exist in PrestaShop:

  1. PrestaShop adapter provisions a guest customer (is_guest=1)
  2. Customer is created with valid password (5-72 chars, PrestaShop hashes internally)
  3. Customer ID is stored in identifier mappings for future reuse

OpenLinker stores lightweight, non-authoritative projections of customer data for:

  • Debugging: Track customer history across orders
  • Retry Support: Enable order retry without re-fetching from source
  • Future Routing: Support for future customer routing features

Projection Storage:

  • customer_projections: Customer email hash, optional PII (name, email)
  • customer_address_projections: Address hash, optional PII (address fields)
  • destination_address_mappings: Maps internal customer + address hash → destination address ID

PII Configuration (OL_STORE_PII):

  • true (default): Store raw PII (email, names, addresses)
  • false: Store only hashes (emailHash, addressHash) - no raw PII
  • Note: emailHash is always persisted regardless of PII setting

OpenLinker normalizes emails before hashing to handle platform-specific email formats:

Allegro Masked Emails:

  • Format: fixedPart+transactionId@allegromail.*
  • Normalization: Strip +... suffix before hashing
  • Example: 8awgqyk6a5+cub31c122@allegromail.pl8awgqyk6a5@allegromail.pl
  • Why: Transaction ID changes per order, but fixed part is stable per buyer

Addresses are reused across orders when identical (determined by hash):

  • Hash Components: address1, address2, city, postcode, countryIso2
  • Reuse Priority:
    1. Primary: Query destination_address_mappings table (fast, deterministic)
    2. Fallback: Query PrestaShop addresses and match by hash (recovery scenario)
  • Address Alias: Deterministic alias format: OL-{type}-{hash-prefix} (e.g., OL-shipping-a1b2c3)

When emailHash matches multiple customers (collision):

  • Policy: Create new internal customer (no merge)
  • Logging: Warning logged with emailHash and match count
  • Result: collisionDetected=true in resolution result
  • Rationale: Prevents incorrect customer merging (shared emails in families/businesses)

See ADR-011 for the domain entity behavior policy (anemic-by-default with pure read-only derivations).

Each domain module follows a standardized hexagonal structure:

libs/core/src/{domain}/
├── domain/ # Domain Layer (Pure Business Logic)
│ ├── entities/ # Domain Entities / Aggregates
│ │ ├── product.entity.ts
│ │ └── product-variant.entity.ts
│ ├── value-objects/ # Value Objects
│ │ ├── money.vo.ts
│ │ └── sku.vo.ts
│ ├── domain-services/ # Domain Services
│ │ └── product-mapping.service.ts
│ ├── domain-events/ # Domain Events
│ │ └── product-created.event.ts
│ └── ports/ # Ports (Interfaces)
│ ├── product-master.port.ts
│ ├── inventory-master.port.ts
│ ├── order-processor-manager.port.ts
│ ├── product-repository.port.ts # Repository ports (persistence contracts)
│ └── connection.port.ts
├── application/ # Application Layer (Use Cases)
│ ├── use-cases/ # Use Case Implementations
│ │ ├── sync-product.use-case.ts
│ │ └── map-product.use-case.ts
│ ├── services/ # Application Services
│ │ └── product-sync.service.ts
│ └── dto/ # Application DTOs
│ ├── product-sync.dto.ts
│ └── product-mapping.dto.ts
├── infrastructure/ # Infrastructure Layer
│ ├── persistence/ # Database
│ │ ├── entities/ # TypeORM Entities
│ │ │ └── product.orm-entity.ts
│ │ └── repositories/ # Repository Implementations
│ │ └── product.repository.ts
│ ├── adapters/ # External Adapters
│ │ ├── prestashop-product-master.adapter.ts
│ │ ├── prestashop-inventory-master.adapter.ts
│ │ └── prestashop-order-processor.adapter.ts
│ └── mappers/ # Data Mappers
│ └── product.mapper.ts
└── interfaces/ # Interface Layer
├── http/ # HTTP Controllers
│ ├── product.controller.ts
│ └── product.controller.spec.ts
├── events/ # Event Handlers
│ └── product-event.handler.ts
└── dto/ # Request/Response DTOs
├── create-product.dto.ts
└── product-response.dto.ts
interfaces → application → domain
↓ ↓
infrastructure → domain

Rules:

  • Domain has NO dependencies on NestJS, TypeORM, or any framework code
  • Domain depends only on ports (interfaces)
  • Application depends on domain and ports (never on infrastructure)
  • Infrastructure implements ports and depends on domain
  • Interfaces depend on application and infrastructure

Application services must never depend on concrete infrastructure repositories. Instead, they depend on repository ports (interfaces) defined in the domain layer.

Why:

  • Maintains proper dependency direction (application → domain, not application → infrastructure)
  • Enables easy testing (mock the port interface)
  • Allows swapping implementations (e.g., in-memory repository for tests)
  • Follows Dependency Inversion Principle

Pattern:

  1. Define repository port in domain layer:

    domain/ports/product-repository.port.ts
    export interface ProductRepositoryPort {
    findById(id: string): Promise<Product | null>;
    save(product: Product): Promise<Product>;
    // ... only methods needed by application services
    }
  2. Implement port in infrastructure layer:

    infrastructure/persistence/repositories/product.repository.ts
    @Injectable()
    export class ProductRepository implements ProductRepositoryPort {
    // Implementation using TypeORM
    }
  3. Inject port (not concrete class) in application service:

    application/services/product.service.ts
    @Injectable()
    export class ProductService {
    constructor(
    @Inject(PRODUCT_REPOSITORY_TOKEN)
    private readonly repository: ProductRepositoryPort, // ✅ Port interface
    ) {}
    }
  4. Bind in module with token:

    product.module.ts
    export const PRODUCT_REPOSITORY_TOKEN = Symbol('ProductRepositoryPort');
    providers: [
    ProductRepository,
    {
    provide: PRODUCT_REPOSITORY_TOKEN,
    useExisting: ProductRepository,
    },
    ]

ORM ↔ Domain Mapping:

  • Mapping lives in infrastructure persistence layer (repository or dedicated mapper)
  • Application services work only with domain entities, never ORM entities
  • Mapping methods (toDomain, toOrm) are private in repository (or extracted to mapper if reused)

Good:

// Repository handles mapping internally
@Injectable()
export class ProductRepository implements ProductRepositoryPort {
async findById(id: string): Promise<Product | null> {
const entity = await this.ormRepository.findOne({ where: { id } });
return entity ? this.toDomain(entity) : null; // Private mapping method
}
private toDomain(entity: ProductOrmEntity): Product { ... }
private toOrm(product: Product): ProductOrmEntity { ... }
}

Bad:

// Service imports infrastructure repository directly
import { ProductRepository } from '../infrastructure/persistence/repositories/product.repository';
// Service works with ORM entities
const ormEntity = await this.repository.findOrmEntity(id); // ❌

Repository Error Handling:

  • Repositories must throw domain errors, not infrastructure errors
  • Catch infrastructure-specific errors (TypeORM, database) and convert to domain exceptions
  • Application services handle domain errors, not infrastructure errors

Good:

// Repository throws domain error
@Injectable()
export class ProductRepository implements ProductRepositoryPort {
async insertMapping(mapping: IdentifierMapping): Promise<IdentifierMapping> {
try {
const saved = await this.ormRepository.save(this.toOrm(mapping));
return this.toDomain(saved);
} catch (error) {
// Convert infrastructure error to domain error
if (error instanceof QueryFailedError && error.message.includes('duplicate key')) {
throw new DuplicateIdentifierMappingError(...); // ✅ Domain error
}
throw error;
}
}
}
// Service handles domain error
@Injectable()
export class ProductService {
async createMapping(...) {
try {
await this.repository.insertMapping(mapping);
} catch (error) {
if (error instanceof DuplicateIdentifierMappingError) {
// Handle domain error - no infrastructure awareness
}
}
}
}

Bad:

// Repository port exposes infrastructure-specific error checking
export interface ProductRepositoryPort {
insertMapping(...): Promise<...>;
isUniqueViolationError(error: unknown): boolean; // ❌ Infrastructure-specific
}
// Service depends on infrastructure error types
catch (error) {
if (error instanceof QueryFailedError) { // ❌ Infrastructure awareness
// ...
}
}

Each bounded context in libs/core/src/<ctx> is an independently testable hexagonal cell, but contexts legitimately depend on each other — orders needs customers and identifier-mapping for identity resolution, content needs ai for suggestions, listings needs products to walk variant catalogs. The architecture supports those dependencies via a single, narrow contract surface between contexts. This section names that contract.

A file in libs/core/src/<ctx-A>/** may import from @openlinker/core/<ctx-B> (the top-level barrel of any sibling context) only the following kinds of symbols:

AllowedPatternExample
Service interfacesI*ServiceIIntegrationsService, IIdentifierMappingService
DI tokens (Symbol)*_TOKENINTEGRATIONS_SERVICE_TOKEN, EVENT_PUBLISHER_TOKEN
Capability ports*Port (single Port suffix)OfferManagerPort, OrderSourcePort, EventPublisherPort
Capability type-guardsis*isOfferCreator, isCategoryBarcodeMatcher
Domain entities, value objects, type aliasespublished in the barrelConnection, Product, Order, MarketplaceCursor
Domain exceptions*Exception, *ErrorConnectionNotFoundException, DuplicateIdentifierMappingError
Other as const value constantsUPPER_SNAKE_CASECORE_ENTITY_TYPE, OFFER_CREATION_STATUS
NestJS module classes*Modulefor imports: [...] only, never injected into servicesCustomersModule in orders.module.ts

The cross-context contract is explicitly forbidden for these symbol shapes:

ForbiddenPatternWhy
Repository ports*RepositoryPortIntra-context contract — exposes persistence concerns the source context controls. Cross-context callers go through I*Service.
ORM entities*OrmEntityTypeORM-decorated infrastructure detail (and ESLint-guarded under @openlinker/core/<ctx>/orm-entities separately).
Adapter classes*AdapterConcrete infrastructure; sibling contexts see behaviour through I*Service or capability ports, never the adapter directly.
Application DTOs*DtoOwned by the source context’s interface layer.
Default importsimport X from '@openlinker/core/<ctx>'Barrels have no default export.
Namespace importsimport * as X from '@openlinker/core/<ctx>'Reserved for barrel-purity tests; cross-context callers use named imports so the surface they touch is explicit.

The rule applies only to imports from the bare top-level barrel @openlinker/core/<ctx>. The three documented sub-barrel exceptions (/services, /orm-entities, /testing) are governed by their own ESLint rules — see docs/engineering-standards.md § Import Aliases.

  • Service interfaces are the seam. A context’s I*Service shape is the only thing sibling contexts can rely on staying stable. When products reorganises its repository layout, the consumers in orders / listings / inventory don’t break — they kept asking IProductsService for what they needed, and products is free to change how it answers internally.
  • Capability ports are part of the published contract. OfferManagerPort, ProductMasterPort, OrderSourcePort — these are the abstractions adapters implement. They cross context boundaries because they’re how the marketplace integrations express themselves to the rest of core. Repository ports, by contrast, are persistence concerns that no sibling has business reaching into.
  • Domain entities cross by value. When orders imports Product from @openlinker/core/products, it binds to the published shape — products can evolve internals freely, and any breaking shape change surfaces at type-check time. Value imports of entities are intentionally allowed (services construct, return, and pattern-match on them); the contract surface is what’s published from the barrel, not the import kind.
  • NestJS module classes cross only at the module-graph layer. orders.module.ts imports CustomersModule to compose providers. A service constructor must never type-hint CustomersModule directly — that’s the wrong layer.

Audited 2026-06-26 from libs/core/src/**:

graph LR
orders --> customers
orders --> identifier-mapping
orders --> integrations
orders --> invoicing
orders --> mappings
orders --> products
orders --> sync
invoicing --> orders
customers --> identifier-mapping
customers --> integrations
customers --> orders
content --> ai
content --> integrations
content --> listings
content --> products
listings --> identifier-mapping
listings --> integrations
listings --> inventory
listings --> mappings
listings --> orders
listings --> products
listings --> sync
inventory --> identifier-mapping
inventory --> integrations
inventory --> listings
inventory --> products
inventory --> sync
products --> identifier-mapping
products --> integrations
products --> listings
sync --> events
sync --> listings
sync --> orders
ai --> integrations
integrations --> identifier-mapping
shipping --> integrations
shipping --> mappings
mailer --> integrations

identifier-mapping, integrations, and events form the most-depended-upon “infrastructure spine” (each used by 5+ siblings). users, webhooks, and mappings have minimal outbound coupling.

The orders ↔ customers, listings ↔ inventory (the latter added for #824), and orders ↔ invoicing (#1120) pairs show up as cycles at the barrel level. They’re safe at runtime because the cross-context surface is interfaces, Symbol tokens, and type imports — there’s no value-level cycle between concrete classes. The NestJS module-graph back-edges (inventory → listings type/token-only; invoicing → orders via the @openlinker/core/orders/types sub-barrel that omits OrdersModule) avoid DI cycles. The same shape would be true of any future cyclic pair: cycle safety is a property of the contract surface, not the file-level dependency graph.

scripts/check-cross-context-imports.mjs runs under pnpm check:invariants (chained into pnpm lint). On any cross-context import that doesn’t match the allow shapes — or that matches a deny shape — it fails the build with a file:line and the rule that fired.

Pre-existing cross-context repository-port couplings are allow-listed in the script’s ALLOW_LIST map by (file, symbol) pair until they’re rewired through service interfaces:

  • Core-to-core (20 entries) — tracked in #718.
  • Plugins + apps (64 entries) — tracked in #722.

The per-symbol gate means new deny-pattern imports added to an already-listed file still fail the build — only the specific repository-port name listed against the path is silenced. When a rewire ships, its allow-list entries drop alongside.

The rule applies to every consumer of @openlinker/core/<ctx> barrels under the walked scopes:

  • libs/core/src/<ctx>/** — core-to-core seam (#713/#721).
  • libs/integrations/<plugin>/** — every plugin’s runtime, fixtures, and __tests__/ (#719).
  • apps/{api,worker}/** — host apps, including src/** and test/integration/** (#719).

libs/plugin-sdk/src/** is currently out of scope (no deny-pattern violations) but follows the same contract; if a violation surfaces a one-line walker addition closes the gap. apps/web/**, libs/shared/**, and libs/test-kit/** don’t import from @openlinker/core/* and stay outside the walker.

Same-context skip applies only when the importer is under libs/core/src/<ctx>/ — plugins and apps have no counterpart context, so every @openlinker/core/<ctx> import from those scopes is by definition cross-context.


openlinker/
├── apps/
│ ├── api/ # Main NestJS API Application
│ │ ├── src/
│ │ │ ├── main.ts
│ │ │ ├── app.module.ts
│ │ │ ├── auth/ # Authentication & Authorization
│ │ │ ├── sync/ # Synchronization orchestration
│ │ │ └── integrations/ # Integration modules
│ │ │ ├── allegro/
│ │ │ └── prestashop/
│ │ └── package.json
│ │
│ └── worker/ # Background Workers (Future)
│ └── src/
├── libs/
│ ├── core/ # Core Bounded Contexts
│ │ ├── src/
│ │ │ ├── products/
│ │ │ ├── inventory/
│ │ │ ├── orders/
│ │ │ ├── listings/
│ │ │ ├── identifier-mapping/
│ │ │ ├── sync/
│ │ │ └── events/
│ │ └── package.json
│ │
│ ├── shared/ # Shared Utilities
│ │ ├── src/
│ │ │ ├── logging/
│ │ │ ├── config/
│ │ │ ├── errors/
│ │ │ └── types/
│ │ └── package.json
│ │
│ └── integrations/ # External Integrations (Optional)
│ ├── ai/
│ ├── allegro/
│ ├── dpd-polska/
│ ├── erli/
│ ├── inpost/
│ ├── prestashop/
│ └── woocommerce/
├── schema.yaml # Unified Data Schema (OpenAPI)
├── pnpm-workspace.yaml
└── package.json

Capability Assignment (Implicit Capabilities)

Section titled “Capability Assignment (Implicit Capabilities)”

OpenLinker uses implicit capabilities: capabilities are declared in code via adapter metadata, not stored in a database. Adapters are resolved per-connection at runtime.

Key Principles:

  • Per-Connection Resolution: Each connection resolves its adapter independently
  • Code-Driven Capabilities: Adapters declare supported capabilities in code (via Adapter Registry)
  • Multiple Connections Per Capability: Multiple connections can support the same capability (e.g., multiple OrderProcessorManager connections)
  • Runtime Validation: Capability support is validated at runtime when requested

Connection Entity:

// Connection represents a configured integration instance
{
id: string; // UUID
platformType: string; // 'prestashop', 'allegro', etc.
name: string; // Human-readable name
status: 'active' | 'disabled' | 'error' | 'needs_reauth';
config: Record<string, any>; // Platform-specific config
credentialsRef: string; // Reference to stored credentials
adapterKey?: string; // Optional explicit adapter key
createdAt: Date;
updatedAt: Date;
}

Adapter Registry (Code-Level):

Each integration module self-registers its adapter metadata via adapterRegistry.register({...}) in onModuleInit (#570/#571), mirroring how AdapterFactoryResolverService.registerFactory works. libs/core no longer carries platform-specific knowledge of which adapters exist — the registry is empty on construct and populated by integration modules at boot. The isDefault: true flag marks the platform-default adapterKey for connections without an explicit adapterKey field.

Plugin contract (#593, @openlinker/plugin-sdk):

The framework-neutral AdapterPlugin contract decouples plugin authoring from NestJS module composition. A plugin descriptor is a plain object with a static manifest, an optional register(host: HostServices) for side registrations (connection tester, retry classifier, scheduler tasks, email normalizer, webhook provisioner), and a createCapabilityAdapter(connection, capability, host) factory. The HostServices bag carries the curated set of services every plugin can rely on: logger, identifierMapping, credentialsResolver, optional cache, plus typed handles to the 8 well-known registries (the 7 originals plus the auth-failure classifier registry, #819 — see ADR-008). Plugin-specific cross-package ports (e.g. CustomerIdentityResolverPort, IMappingConfigService) are passed into the descriptor’s constructor closure — they’re intentionally not in the host bag, to keep the contract surface lean.

In-tree plugins (Allegro, PrestaShop) keep their @Module decorator and their own NestJS providers (TypeORM repositories, provisioners, …); their onModuleInit body builds the descriptor + a HostServices bag from injected fields and routes registration through the descriptor. The createNestAdapterModule(plugin) helper at @openlinker/plugin-sdk is the easy path for plugins that don’t need any plugin-specific Nest providers — it produces a DynamicModule that wires HostServices from DI and delegates registration to the descriptor.

Each app composes the integration modules it ships with via a top-level plugin list, then hands the list to PluginRegistryModule.forRoot({ plugins }) (#572). Apps no longer hard-code per-plugin names in their NestJS module graph — the registry is the single seam an OSS contributor edits to enable a third-party plugin. The adapter-registration mechanic itself is unchanged; only the import seam moved.

// apps/api/src/plugins.ts — single edit point for API plugins.
export const apiPlugins: PluginEntry[] = [
PrestashopIntegrationModule,
AllegroIntegrationModule,
AiIntegrationModule.register(),
];
// apps/api/src/integrations/integrations.module.ts — composes the plugins.
@Module({
imports: [
/* ... core modules ... */,
PluginRegistryModule.forRoot({ plugins: apiPlugins }),
],
exports: [PluginRegistryModule],
})
export class IntegrationsModule {}
// AllegroIntegrationModule.onModuleInit() — descriptor-driven (#593):
const plugin = createAllegroPlugin({ /* plugin-specific deps */ });
const host: HostServices = { /* built from @Inject'd host fields */ };
host.adapterRegistry.register(plugin.manifest);
host.factoryResolver.registerFactory(plugin.manifest.adapterKey, factoryAdapter);
plugin.register?.(host);
// createAllegroPlugin's manifest:
{
adapterKey: 'allegro.publicapi.v1',
platformType: 'allegro',
supportedCapabilities: ['OrderSource', 'OfferManager'],
displayName: 'Allegro Public API v1',
version: '1.0.0',
isDefault: true,
}
// PrestaShop follows the same Shape A pattern via createPrestashopPlugin().

Service Usage (Per-Connection):

@Injectable()
export class ProductSyncService {
constructor(
@Inject(INTEGRATIONS_SERVICE_TOKEN)
private integrationsService: IntegrationsService,
) {}
async syncProduct(connectionId: string, productId: string) {
// Get ProductMaster adapter for specific connection
const productMaster = await this.integrationsService
.getCapabilityAdapter<ProductMasterPort>(connectionId, 'ProductMaster');
// Use abstraction, not concrete implementation
const product = await productMaster.getProduct(productId);
// ... sync logic
}
}
@Injectable()
export class InventorySyncService {
constructor(
@Inject(INTEGRATIONS_SERVICE_TOKEN)
private integrationsService: IntegrationsService,
) {}
async syncInventory(connectionId: string, productId: string) {
// Get InventoryMaster adapter for specific connection
const inventoryMaster = await this.integrationsService
.getCapabilityAdapter<InventoryMasterPort>(connectionId, 'InventoryMaster');
// Use abstraction, not concrete implementation
const inventory = await inventoryMaster.getInventory(productId);
// ... sync logic
}
}
@Injectable()
export class OrderSyncService {
constructor(
@Inject(INTEGRATIONS_SERVICE_TOKEN)
private integrationsService: IntegrationsService,
) {}
async syncOrders() {
// Get ALL OrderProcessorManager adapters (multiple connections)
const orderProcessors = await this.integrationsService
.listCapabilityAdapters<OrderProcessorManagerPort>({
capability: 'OrderProcessorManager',
});
// Process orders from all sources
for (const { connectionId, connection, adapter } of orderProcessors) {
const orders = await adapter.getPendingOrders();
// ... process orders from each connection
}
}
}

Benefits:

  • Multiple Connections: Create multiple connections per platform type
  • Multiple Adapters Per Capability: Support multiple OrderProcessorManager connections (e.g., PrestaShop + Allegro)
  • No Database Config: Capabilities declared in code (type-safe, refactorable)
  • Runtime Validation: Fail fast if capability unsupported
  • Per-Connection Configuration: Each connection has its own config and credentials

See Also: Connections & Adapter Resolution for detailed documentation.


1. Order Synchronization Flow (Marketplace → Shop)

Section titled “1. Order Synchronization Flow (Marketplace → Shop)”
Scheduled Job / Controller
│ @Cron('*/5 * * * *') or HTTP endpoint
│ Initiates order ingestion
OrderIngestionService.ingestOrders()
│ Gets OrderSourcePort adapter for the connection
│ (AllegroOrderSourceAdapter or PrestashopOrderSourceAdapter)
OrderSourcePort.listOrderFeed({ fromCursor, limit })
│ cursor is opaque adapter-defined — Allegro event ID, PrestaShop date_upd
Marketplace / Shop API
│ Returns order-event references (externalOrderId, eventKey, occurredAt)
OrderIngestionService
│ 1. Enqueues one marketplace.order.sync job per feed item
│ 2. Commits nextCursor only after successful enqueue (cursor-safety guard)
OrdersPollHandler → OrderIngestionService.syncOrderFromSource()
│ 1. OrderSourcePort.getOrder({ externalOrderId }) → IncomingOrder
│ 2. Resolves product / variant / customer identifiers via IdentifierMappingService
│ 3. Builds unified Order and dispatches via OrderSyncService
│ 4. Gets OrderProcessorManagerPort adapter for the destination shop
OrderProcessorManagerPort (PrestashopOrderProcessorAdapter)
│ 1. Provisions customer + addresses; creates the cart (carrier +
│ delivery address), writes the per-cart shipping sidecar (#516) and
│ cart-scoped specific_prices (#895).
│ 2. Uses IdentifierMappingService.getExternalIds() to get PrestaShop IDs.
│ 3. Creates the order through PrestaShop's canonical PaymentModule::
│ validateOrder via the OL module's HMAC-authed `importorder` endpoint
│ (ADR-016 / #905) — NOT the raw webservice POST /orders, which bypasses
│ validateOrder and drops the carrier + recomputes shipping (#503/#898).
PrestaShop API (OL module front controller → validateOrder)
│ Returns created order
OrderSyncService
│ Saves OrderMapping
│ Updates sync status
Marketplace API
│ (Webhook)
MarketplaceAdapter
│ 1. Maps to unified Order schema
│ 2. Uses IdentifierMappingService to replace external IDs with internal IDs
│ - Order ID: external → internal
│ - Product IDs: external → internal
Event: 'marketplace.order.received'
│ Payload contains order with internal IDs
OrderSyncListener
│ Gets OrderProcessorManagerPort adapter
OrderSyncService.syncOrderFromEvent()
│ Uses ProductMappingService (for product references)
│ Uses StatusMappingService (for status mapping)
│ Order already has internal IDs from adapter
OrderProcessorManagerPort (PrestashopOrderProcessorAdapter)
│ 1. Uses IdentifierMappingService.getExternalIds() to get PrestaShop IDs
│ - Product IDs: internal → PrestaShop external IDs
│ - Customer ID: internal → PrestaShop external ID
│ 2. Maps unified Order → PrestaShop format
│ 3. createOrder(orderCreate) with PrestaShop external IDs
PrestaShop API

2. Inventory Synchronization Flow (Master → Slaves)

Section titled “2. Inventory Synchronization Flow (Master → Slaves)”
InventoryMasterPort (PrestashopInventoryMasterAdapter)
│ getInventory(productId)
PrestaShop API
│ Returns inventory data
InventorySyncService
│ Finds product mappings
│ Calculates available quantity
For each marketplace:
│ Gets OfferManagerPort adapter for the target connection
OfferManagerPort.updateOfferQuantity(cmd)
Allegro API / Amazon API / etc.
External System Event
Adapter (e.g., AllegroAdapter)
│ Emits domain event
Event Bus (Redis Streams)
Event Handlers
├─> OrderSyncListener
├─> InventorySyncListener
└─> NotificationListener

4. Webhook Ingestion Flow (Inbound → Event Bus → Sync Trigger)

Section titled “4. Webhook Ingestion Flow (Inbound → Event Bus → Sync Trigger)”

See ADR-005 for the decision rationale.

External System (PrestaShop)
│ POST /webhooks/:provider/:connectionId
│ Headers: X-OpenLinker-Timestamp, X-OpenLinker-Signature
WebhookController
│ 1. Validates timestamp (replay window, default 120 s — see below)
│ 2. Validates signature (HMAC SHA256)
│ 3. Postgres dedup gate: INSERT ... ON CONFLICT DO NOTHING on
│ webhook_deliveries (provider, connectionId, eventId) — authoritative
│ 4. Redis dedup (inner gate, fast-path safety net): markProcessing
│ 5. Publishes to event bus
│ 6. UPDATE webhook_deliveries row to status='published', markDone in Redis
│ On publish failure: DELETE the row + clearProcessing in Redis
│ (allows source-side retry to re-enter the gate cleanly)
Redis Streams: events.inbound.webhooks
│ EventEnvelope with InboundWebhookEvent
WebhookToJobHandler (Consumer Group: webhook-handler)
│ 1. Consumes events from stream
│ 2. Maps webhook event to sync job
│ 3. Enqueues job with idempotency key
│ 4. ACKs message after successful enqueue
Redis Streams: jobs.sync
│ SyncJob (e.g., master.product.syncByExternalId)
Future: Worker processes jobs
│ Triggers "pull" sync via adapter APIs
│ (Webhook payload is not source of truth)

Key Design Principles:

  • Fast webhook processing: Validate → enqueue → ACK (target: <100ms).
  • At-least-once delivery: Postgres-authoritative dedup (#711) prevents lost events; Redis inner gate is a fast-path safety net.
  • Idempotent job enqueue: Job-level deduplication prevents duplicate sync jobs.
  • Webhook payload is not source of truth: Triggers “pull” jobs that fetch full data via adapters.
  • Failure-recovery (#711): A row inserted by the Postgres gate is DELETED if downstream publishing fails, so the source’s retry can re-enter the gate. The unique constraint never permanently blocks a legitimate retry.
  • webhook_deliveries = verified deliveries only + separate auth-rejection signal (#1814): an auth-rejected delivery (missing/wrong signing secret → signature verify fails) is thrown before any webhook_deliveries row is written (this doc’s ADR-005 dedup-gate invariant), so that table cannot record failures. WebhookService instead upserts a durable rolling counter to a distinct webhook_auth_rejections table (one row per (provider, connectionId): count + first/last-rejected timestamps + last reason), non-fatally. The webhook-status projection reads it to expose a third operator-facing activation: 'auth-failing' state — a connection whose every delivery is 401-ing is now visually distinct from one that never registered (not-registered). Precedence self-heals: a verified delivery newer than the last rejection wins (verified); a stale rejection (outside a 24h freshness window) reverts to not-registered.
  • Event routing (#900, ADR-015): WebhookToJobHandler is a thin dispatcher — it resolves the connection’s per-plugin WebhookEventTranslator (translate → neutral CanonicalInboundEvent) and delegates to the capability-gated core InboundRoutingPolicy, which maps domain → jobType (no platform string-matching in the interface layer). PrestaShop order events route to marketplace.order.sync (#902/#903).
  • Webhook = trigger, poll = reconciliation backstop (#904): webhooks are the low-latency primary path; PrestaShop also schedules a relaxed prestashop-orders-poll (default every 10 min, marketplace.orders.poll) that heals missed/dropped webhooks by re-reading orders changed since the date_upd watermark. Both paths converge on the idempotent OrderIngestionService.syncOrderFromSource (#906 lock + #909 core update-or-create), so the poll reconciles changed orders (re-pull is authoritative; last write wins) without re-creating webhook-ingested ones.

Security:

  • HMAC SHA256 signature verification using raw body bytes.
  • Replay protection via timestamp validation (#711): default ±120 s window, env-configurable via OL_WEBHOOK_SKEW_WINDOW_MS, clamped to [1 s, 300 s]. Tighter is more secure; too tight breaks legitimate webhooks under NTP drift or load-balancer latency. Operators with stable clock-sync can tighten to 60 s; cloud-hosted deployments with cross-region NTP drift can loosen up to 300 s.
  • Replay protection via durable Postgres dedup (#711): the uq_webhook_deliveries_event_key unique constraint on (provider, connection_id, event_id) rejects same-event replays even if Redis is wiped or restarted between the original delivery and the replay. Failed-validation webhooks (bad signature, stale timestamp) do NOT insert a row — they’re logged-only — so the unique constraint never blocks a legitimate retry of a previously-rejected event.
  • Connection validation (exists, active, provider match).

Location: apps/api/src/webhooks/ (Infrastructure / Inbound Adapters)


  • Framework: NestJS
  • Language: TypeScript (strict mode)
  • Database: PostgreSQL (TypeORM)
  • Caching: Redis
  • Event Bus: Redis Streams (initial), RabbitMQ/Kafka (future)
  • Package Manager: pnpm (monorepo)
  • HTTP Client:
    • Adapter HTTP clients: native fetch() (Node 18+, undici) wrapped in a per-plugin client (e.g. AllegroHttpClient, InpostHttpClient, ErliHttpClient) that hand-rolls retries, rate-limit backoff, and structured logging. This is the current in-tree standard — undici’s default keep-alive pooling removes the original reason to reach for Axios (@nestjs/axios), which is no longer required for a new adapter client.
    • Simple HTTP calls: Native fetch() API (Node.js 18+) - acceptable for one-off calls like OAuth token exchange
  • Scheduling: @nestjs/schedule (Cron jobs)
  • Events: @nestjs/event-emitter (in-memory), Redis Streams (distributed)
  • Authentication: JWT (@nestjs/jwt, @nestjs/passport)
  • Validation: class-validator, class-transformer
  • Logging: framework-neutral LoggerPort (@openlinker/shared/logging) with a console default; host apps install the NestJS-backed adapter at boot via installNestLogger() from @openlinker/shared/logging/nest
  • Linting: ESLint
  • Formatting: Prettier
  • Testing: Jest
  • Type Checking: TypeScript (strict mode)