Architecture Overview
Table of Contents
Section titled “Table of Contents”- High-Level Architecture
- Core Bounded Contexts
- Capability Abstractions (Business Roles)
- Hexagonal Architecture Structure
- Cross-context dependencies in core
- Module Organization
- Data Flow
- Technology Stack
High-Level Architecture
Section titled “High-Level Architecture”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
Architecture Diagram
Section titled “Architecture Diagram”┌─────────────────────────────────────────────────────────────────┐│ 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.
Core Bounded Contexts
Section titled “Core Bounded Contexts”The system is organized into the following core bounded contexts:
1. Identity
Section titled “1. Identity”- Responsibility: User authentication, authorization
- Key Entities: User, Role, Permission
- Location:
apps/api/src/auth/orlibs/core/src/auth/
2. Products
Section titled “2. Products”- Responsibility: Product catalog management, product mapping between platforms
- Key Entities: Product, ProductMapping, ProductVariant
- Location:
libs/core/src/products/ - Capability: Uses
ProductMasterPortabstraction - Barcode Storage: EAN/GTIN are stored on
ProductVariant(notProduct), as variants are the canonical offer-link targets. - Simple Products: Products without combinations produce a deterministic synthetic variant to ensure a stable mapping target.
3. Inventory
Section titled “3. Inventory”- Responsibility: Inventory synchronization, stock level management
- Key Entities: Inventory, InventoryAdjustment, InventoryMapping
- Location:
libs/core/src/inventory/ - Capability: Uses
InventoryMasterPortabstraction - Variant-keyed master inventory (#822, ADR-010):
MasterInventorySyncServicekeys eachinventory_itemsrow 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-suppliedInventory.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)— oneInventoryper variant — and writes one variant-keyed row each. The PrestaShop adapter enumeratesstock_availablesfor the product and emits one entry per combination (resolving each combination id to itsProductVariant), 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 onConnection.config.stockSafetyBuffer(JSONB — no schema/migration change) and defaults to0, which preserves the pre-#1844 pass-through behaviour (master is authoritative, including 0). Two pure, marketplace-neutral helpers own the policy (readStockSafetyBuffer/applyStockSafetyBufferin@openlinker/core/identifier-mapping, mirroring theparseTriggerModelconfig-coercion precedent). It is applied at both stock write sites and applied once per flow: the publish/create builders (OfferBuilderService,ProductPublishBuilderService) stamp the bufferedcommand.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.
4. Orders
Section titled “4. Orders”- 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).
5. Customers
Section titled “5. Customers”- 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
6. Listings (Offers)
Section titled “6. Listings (Offers)”- 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
OfferManagerPortabstraction 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.syncjob (pre-sync pipeline). - Allegro offer sync uses
GET /sale/offer-eventswith persisted cursor keyallegro.offers.lastEventId. - Offer status sync (#816): the
marketplace.offer.statusSyncjob refreshes the live marketplace publication status (active | activating | inactivating | inactive | ended) of mapped offers into theoffer_status_snapshotstable — the steady-state counterpart toOfferCreationRecord’s one-shot creation lifecycle. It reuses theOfferStatusReadercapability, 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.
OfferStatusReadServiceexposes it per product (GET /listings/products/:productId/offer-status, rendered on the products drawer), and a manualPOST .../offers/:externalOfferId/refresh-statusforce-reads one offer. Because the creation poller (#447) terminalises a record todraft/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, delayedmarketplace.offer.refreshSnapshotjob (~2/8/20 min) that re-reads live status viaOfferStatusSyncService.refreshOneand 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 callscreateOffer, so a fresh run creates a duplicate offer; the shop path (ProductPublishExecutionService) resolves theShopProductmapping 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 newShopProductMappingRepositoryPort(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, neverplatformType. 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[]vsbody.productSet[].product.parameters[]on Allegro — the latter carries Brand, Model, Manufacturer-code, etc., and mirrors the shape Allegro returns fromGET /sale/product-offers/{offerId}). The neutralCategoryParameter.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/listingsexposes 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@Injectableservice classes) lives on the@openlinker/core/listings/servicessubpath — 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-entitiessub-barrels (#594) — seedocs/engineering-standards.md § Import Aliasesfor 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 bybulk_batch_advancements, #737), andBulkOfferCreationRetryService(#742). All four delegate to the same single-offer primitives — there’s no parallel “bulk” pipeline.BulkOfferCreationRetryServicereopens a terminal-state batch: per failed record it deletes thebulk_batch_advancementsrow, decrementsfailedCountlock-stepped to the record reset, then transitionscompleted | partially-failed | failed → runningonce after the loop. The per-record V2 payload is rebuilt from the persistedrequestsnapshot + the parent batch’ssharedConfig.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.submittreats 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 (sototalCountmatches the real fan-out). Each variant-offer sources itsstock.availablefrom 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-resolvedproductCardId). 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 variantattributesas 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.buildCreateOfferCommandstamps a platform-neutralCreateOfferCommand.variantGroup(OfferVariantGroup= an opaquegroupIdshared by every sibling — today the parent OL product id — plus this variant’s distinguishingattributes, flattened fromProductVariant.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;groupIdis 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 atbulk-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 theirofferCreationWizardplugin-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 theTaxonomyBorrowercapability (getBorrowedTaxonomy(): TaxonomyOwner, e.g.'allegro');OfferBuilderServicethreads that value + the mastersourceConnectionIdintoCategoryResolutionServiceandAttributeProjectionService, which fall back from a destination-keyed lookup to the owner’s provenance-matching rows (destination_taxonomy_provenance). Capability-driven, neverplatformType; the downstreamsource:"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 themappingscontext (AttributeMappingRuleentity +attribute_mapping_rulestable; kind-specific data in aconfigjsonb, scope + target in real columns) and are read throughIMappingConfigService.getAttributeMappingRules. They are scoped (all optional, AND-combined) by source connection, destination category, manufacturer (equality), and product-name phrase (substring), and ordered bypriority(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-valuereads product-derivedAttributeProjectionInput.metadata, assembled by thebuildProjectionMetadatahelper at the offer-builder and product-publish-builder call sites (manufacturer derived from a well-known product feature). Operator-authored viaconnections/:connectionId/attribute-rules(GET/PUT/DELETE) and the connection Mappings page “Attribute Rules” tab (capability-gated toOfferManager/ProductPublisherdestinations). - 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/priceare already per-item (#1414);contentwas batch-shared and category placement + parameters were server-derived by the #1042 builder. #1831 adds optional per-itemcontent/destinationCategoryIds/parameterstoBulkShopPublishSubmitItemInput(+ the request DTO) that override the batch-shared / server-derived defaults, threaded end-to-end (submit →ProductPublishEnqueueServiceV1/V2 payload →shop.product.publishhandler →ProductPublishExecutionService→ProductPublishBuilderService). Precedence in the builder: a defined per-itemdestinationCategoryIds(even empty ⇒ publish uncategorised) skipsCategoryProvisionerprovisioning; a defined per-itemparameters(even empty) skips attribute projection + the required-parameter gate; per-itemcontent(chosen at submit viaitem.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).ShopCategoryBrowseris a sub-capability ofShopProductManagerPort(browseCategories(parentId?)→ neutralShopCategory[], co-locatedisShopCategoryBrowserguard) — the shop-side sibling of the marketplaceCategoryBrowser(onOfferManagerPort). Unlike a marketplace’s closed leaf-gated taxonomy, a shop lets a product be placed in any node, soShopCategorycarries noleafflag. It is advertised-without-dispatch (declared in the WooCommerce manifestsupportedCapabilitiesfor host/FE discovery, resolved by narrowing the dispatchedProductPublisheradapter with the guard — nevergetCapabilityAdapter('ShopCategoryBrowser'); not inCoreCapabilityValues). The WooCommerce adapter implements it overGET /products/categories(parent-scoped, paged at the WC maxper_page=100);ShopCategoryBrowseService+GET /listings/connections/:id/shop-publish/categoriesexpose it, and a self-contained FEShopCategoryPickerModal+useShopCategoriesQueryback 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 atomicity —
BulkShopPublishSubmitServicemirrorsBulkListingSubmitService: on a mid-fan-out enqueue failure it reconcilestotalCountdown to what actually reached the stream, deletes orphaned pre-createdListingCreationRecords, and level-triggers the terminal-status derivation (succeeded + failed === totalCount) so a strandedrunningbatch can never occur (previously it flipped the whole batchfailedwhile already-enqueued children kept running). (2) Status reconcile — the steady-stateshop.product.statusSyncjob drains one page of a connection’s published/draft records per tick viaShopStatusSyncService, reads live shop-side status through the newShopProductStatusReadersub-capability ofShopProductManagerPort, and upserts ashop_product_status_snapshotsrow (neutralShopPublicationStatus = published | draft | unpublished | removed) — the shop-side counterpart tooffer_status_snapshots(#816). WooCommerce maps its nativepublish/draft/pending/private/trash+ a 404 onto the neutral union; scheduled per WC connection (ProductPublisher-gated, hourly). (3) Retry —BulkShopPublishRetryServicere-runs only the failed children, rebuilding eachshop.product.publishV2 payload from a new per-itemListingCreationRecord.requestsnapshot (persisted at enqueue) under a wave-distinct idempotency key (mirrorsBulkListingRetryService#742). (4) Duplicate handling —ProductPublishExecutionServicenow swallows a first-publishDuplicateIdentifierMappingErroronly after verifying the winningShopProductmapping claims the same internal variant; a divergent mapping raisesShopProductMappingConflictExceptioninstead of silently mis-linking. - Shop global-attribute read + publish linkage (#1835, ADR-024): shops previously emitted every neutral publish
parameteras a per-product free-text custom attribute ({name, options}), droppingvaluesIds— which cannot power storefront filtering.ShopAttributeReaderis a sub-capability ofShopProductManagerPort(listAttributes()+listAttributeTerms(attributeId)→ neutralShopAttribute[]/ShopAttributeTerm[], co-locatedisShopAttributeReaderguard) 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 dispatchedProductPublisheradapter — not inCoreCapabilityValues). The WooCommerce adapter readsGET /products/attributes+GET /products/attributes/{id}/terms, and on publish links a real global attribute when a neutralOfferParametercarriesvaluesIds(term ids) plus a numericid(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 FEShopAttributePicker(+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 + oneproducts/{parentId}/variationsentry per sibling — a single-variant / simple product keeps the pre-#1836type:'simple'path unchanged.ProductPublishBuilderServicepopulates the shop-publish sibling of the marketplaceOfferVariantGroup(#1065) —PublishProductCommand.variantGroup: PublishProductVariantGroup(groupId= the OL product id, this variant’s own distinguishing attribute values, plusgroupAttributeValues— the UNION of every sibling’s values per attribute name, since the parent must declare its full variation-axis option set up front) — whenevergetVariantsByProductId(...).length > 1, mirroring the #1065 populate rule.ProductPublishExecutionServiceadditionally resolves the parent’sShopProductmapping (keyed ongroupId, distinct from the variant’s own mapping — no schema change, since product and variant internal ids are distinctly prefixed and coexist in the sameShopProductentityType) and stamps it ontovariantGroup.externalParentProductId;PublishProductResult.externalParentProductIdreports 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 globalpa_*, sinceProductVariant.attributesare freeform) and agroupId-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 singularoption, not the parent’s pluraloptions). - 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 aPublishProductCommand-shaped input (stock,weight,commerce.dimensions) — that reportsRequiredToSellIssue[](OUT_OF_STOCK/MISSING_WEIGHT/MISSING_DIMENSIONS, each carrying aseverity).blockgates the publish exactly like the builder’s existing price/unresolved-parameter gates;warnis a soft, operator-overridable signal. Every rule shipped today iswarn— the severity field exists so a future hard-required rule (marketplace or shop) doesn’t need a shape change.ProductPublishBuilderService.buildPublishProductCommandruns the check on every shop publish and logswarn-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 onCreateOfferCommand): sameRequiredToSellIssueoutput shape, a different pure input projection — not built yet, no marketplace requirement is scoped.
7. Sync Manager
Section titled “7. Sync Manager”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. EachSyncJobHandler.execute()returns aSyncJobHandlerResultwhoseoutcomethe runner persists viamarkSucceeded(id, outcome)— atomic with the status flip.OfferCreationExecutionServiceis the first orchestrator to derivebusiness_failurefrom a terminal-rejection branch; other handlers return'ok'mechanically until they grow their own domain-failure semantics.
8. Event Bus / Messaging
Section titled “8. Event Bus / Messaging”- Responsibility: Event-driven communication between modules
- Technology: Redis Streams (initial), RabbitMQ/Kafka (future)
- Location:
libs/core/src/events/
9. Identifier Mapping Service
Section titled “9. Identifier Mapping Service”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
10. Plugin Manager / Integrations
Section titled “10. Plugin Manager / Integrations”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):
WebhookProvisioningPortdefinesinstall(connectionId, actorUserId?)returning a neutralWebhookProvisioningResult(webhooksConfigured,testPingTriggered, optionalwarning). Each integration package self-registers its adapter againstWebhookProvisioningRegistryServiceinonModuleInit(today onlyPrestashopWebhookProvisioningAdapteratprestashop.webservice.v1).ConnectionService.installWebhooksis the single dispatch layer: it resolves the connection’s adapter viaIntegrationsService.resolveAdapterMetadata, looks the provisioner up byadapterKey, and returns 400 if no provisioner is registered.ConnectionController.installWebhooksis 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):
ConnectionConfigShapeValidatorPortandConnectionCredentialsShapeValidatorPortdefinevalidate(payload): Promise<void>, throwingInvalidConnectionConfigException/InvalidCredentialsShapeExceptionfromlibs/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 inplugin.register(host)— today Allegro registers a config-shape validator atallegro.publicapi.v1(credentials shape is enforced deeper, atAllegroAdapterFactory.resolveCredentials), and PrestaShop registers both.ConnectionService.create/update/updateCredentialsare the single dispatch layer: they callIntegrationsService.resolveAdapterMetadata, look the validator up byadapterKey, and short-circuit when no validator is registered (closing the previous platform-switch inapps/api/src/integrations/application/services/util/). The API maps the domain exceptions toBadRequestException({ message, errors })at the boundary; plugins never depend on@nestjs/common. The structuralValidationErrorLiketype inlibs/corekeepsclass-validatorout of the core runtime — the function consumes the shape, and plugins that opt into class-validator-based DTOs depend on it from their ownpackage.json. - Static manifest export (#575): every adapter package exports its
AdapterMetadataas a top-levelconstfrom the package barrel —allegroAdapterManifestfrom@openlinker/integrations-allegro,prestashopAdapterManifestfrom@openlinker/integrations-prestashop,erliAdapterManifestfrom@openlinker/integrations-erli(#980 — shipssupportedCapabilities: []; #984/#993 addOfferManager/OrderSourcetogether 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 readadapterKey/supportedCapabilities/versionwithout 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
dispatchCapabilitytable entry. FinerOfferManagersub-capabilities — Allegro’sCategoryBrowser/EanCategoryMatcher(#1367), Allegro’sOfferCreator/OfferEventReaderand Erli’sOfferCreator(#1498) — are declared insupportedCapabilitiespurely so host-side discovery (the connection response) can tell them apart, and are resolved only by narrowing the dispatchedOfferManageradapter with itsis*guard (isCategoryBrowser,isOfferCreator, …), never viagetCapabilityAdapter(connectionId, 'OfferCreator')directly. CallinggetCapabilityAdapter/listCapabilityAdapterswith an advertised-without-dispatch name still passes the manifest gate but then fails insidedispatchCapability(a genericError, notAdapterNotFoundException— 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.
- Advertised-without-dispatch sub-capabilities: a manifest capability is not always backed by a
- Type-safe capability dispatch (#573):
@openlinker/plugin-sdkshipsdispatchCapability<T>(capability, table, pluginName). PlugincreateCapabilityAdapterimplementations declare a typed dispatch table{ OfferManager: () => offerManager, OrderSource: () => orderSource }and the helper returns the matching adapter cast toT. The cast lives in one place rather than once percaseper plugin. True compile-time enforcement ofcapability ↔ Tis blocked by the open string-set design (#576) — plugins can register new capability names at runtime, so a closedRecord<Capability, AdapterPort>keyed by literal capability names can’t exist statically. The helper is the right size for the problem: it deletes the per-caseas unknown as Tboilerplate 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).
11. Logging & Monitoring
Section titled “11. Logging & Monitoring”- Responsibility: Structured logging, metrics, tracing
- Contract:
LoggerPort(log/debug/warn/error) shipped from@openlinker/shared/logging. The consumer-facingLoggerfactory (new Logger(ClassName.name)) delegates to a process-wide active backend. - Backends:
ConsoleLoggerAdapteris the zero-dependency default that ships in the same package, so plugins compiled against@openlinker/sharedget working logs without any host wiring. The NestJS-backedNestLoggerAdapterlives on the host-only subpath@openlinker/shared/logging/nest; hosts callinstallNestLogger()at the top ofbootstrap()to swap it in (#589). Plugins never transitively pull@nestjs/commonthrough the logger. - Tracing: OpenTelemetry (future).
- Location:
libs/shared/src/logging/(port + default + factory);libs/shared/src/logging/nest/(Nest-backed adapter, host-only).
12. Content
Section titled “12. Content”- 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
ContentPublisherPortfor outbound publishing. Master path resolvesProductMasterPortvia the integrations registry and callsupdateProductwith a keyed patch. Channel path resolves anOfferManagerPortfor the target connection, requires theOfferFieldUpdatersub-capability, walks the product’s variants →OfferMappingRepositoryPort.findMany→ distinct external offer IDs, and issues oneupdateOfferFieldsper distinct offer (Allegro TEXT-section payload; idempotency keycontent:{productId}:{connectionId}:{publishTimestamp}). - AI suggestion:
ContentSuggestionServicecomposesIIntegrationsService(fetch product + variants) +IPromptTemplateService(render theoffer.description.suggesttemplate for the current channel) +AiCompletionPort(generate). Bound in the API layer (apps/api/src/content/content.module.ts) becauseAI_COMPLETION_PORT_TOKENis only provided whereAiIntegrationModule.register()is registered. - Storage:
product_content_fieldtable with two partial unique indexes (master vs channel) to honour Postgres’ NULL-distinct uniqueness for the nullableconnection_idcolumn. - Conflict model: optimistic — inbound reconcile sets
has_conflict=truewhen an external version diverges while a draft is pending; re-saving the draft is treated as implicit acknowledgement and clears the flag.
13. AI
Section titled “13. AI”- 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). Thechannelaxis is open-world (#580):PromptTemplateChannel = stringcarries any platform connection’splatformType, 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 theplatformTypeprecedent inCreateConnectionDto); the controller maps the'master'query sentinel tonulland forwards every other non-empty string verbatim. FE picker and list rendering are registry-driven viausePlugins(). - 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 (renderTemplatepure 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 oneVercelAiCompletionAdapterinstance per supported provider (anthropic via@ai-sdk/anthropic, openai via@ai-sdk/openai) plusFakeAiCompletionAdapterfor tests / offline dev.AI_COMPLETION_PORT_TOKENresolves toMultiProviderAiCompletionAdapter, 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 toprovider === 'anthropic'so the OpenAI request never carries a strayproviderOptions.anthropicblock. - Selection (#451 / #452): the active provider is a runtime setting persisted on the singleton
ai_provider_active_settingtable. Resolution: DB row →OL_AI_PROVIDERenv (first-boot fallback) →'anthropic'default. Admins switch the active provider viaPUT /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 atref = ai-provider:{provider}in the encryptedintegration_credentialstable;CredentialsAiProviderAdapterretains a 60 s per-provider key cache. - Admin surface:
PromptTemplatesControlleratapps/api/src/ai/http/prompt-templates.controller.ts;AiProviderSettingsControlleratapps/api/src/ai/http/ai-provider-settings.controller.tsexposesGET /ai-provider-settings,PUT /keys/:provider,DELETE /keys/:provider,PUT /active(all@Roles('admin')). FE admin UI at/ai/prompt-templatesand/ai/provider-settings. - Storage:
prompt_templatestable with four partial unique indexes honouringNULL-distinct semantics on the nullablechannelcolumn (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 inapps/worker/src/plugins.tsso the bulk-flowmarketplace.offer.createhandler can callContentSuggestionService.suggestDescription()per-job for AI-generated offer descriptions.AI_COMPLETION_PORT_TOKENresolves throughPluginRegistryModule.forRoot({ plugins: workerPlugins })in the worker’sAppModule. The suggestion binding lives atapps/worker/src/content/worker-content.module.ts(mirrorsapps/api/src/content/content.module.ts) — it cannot live inlibs/core/src/content/content.module.tsbecause 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 — theShopProductPublishHandler(worker) callsContentSuggestionService.suggestDescription({ channel: 'woocommerce', ... })to fillcontent.descriptionwhen the operator setsgenerateDescriptionon the single/bulk shop-publish submit (threaded viaEnqueueProductPublishInput→ShopProductPublishPayload). Same placement + precedence as the offer flow: an explicit operatorcontent.descriptionoverride always wins (never overwritten), and any AI failure (variant lookup, missing template, LLM error) logs a warning and falls through to the master description. Theoffer.description.suggestprompt template is seeded per channel; #1840 adds thewoocommerceseed alongside the existingallegro/prestashoprows. 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).
14. Invoicing
Section titled “14. Invoicing”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 inlibs/core; all national specifics are confined to the provider adapter package. - Key Entities:
InvoiceRecord(neutral projection of an issued document), value typesDocumentType,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 indomain/ports/capabilities/, each with a co-locatedis*guard:RegulatoryStatusReader— read the clearance status of a previously-submitted document.RegulatoryTransmitter(extendsRegulatoryStatusReader) — 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 neutralRegulatoryDocumentblob.BankAccountsReader(#1303 follow-up) — list the seller’s payable bank accounts known to the provider (neutralInvoicingBankAccount), backing the live picker for Transfer invoices.BankAccountDefaultSetter(extendsBankAccountsReader, #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 inrejected. Kept flat (does notextends RegulatoryStatusReader) — it backs a NATIVELY-transmitting provider (e.g. inFakt) that needs an explicit resubmit kick from OL, distinct fromRegulatoryTransmitter(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.originalDocumentis 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):InvoiceRecordpersists a neutralissuedLineSnapshot({ buyer, currency, lines }) whenever a document is issued (from the issue command) or corrected (the correction’s own post-correction lines) — captured in the coreInvoiceService, so no adapter change. The HTTP controller assemblesoriginalDocumentfrom that snapshot on the document being corrected, so theoriginalLineNumber-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 itsbuyerTaxId: null+ line-fidelity caveats).
- KSeF (Polish national e-invoicing),
@openlinker/integrations-ksef, adapterKeyksef.publicapi.v2, registry capabilityInvoicing.KsefInvoicingAdapterimplementsInvoicingPort,RegulatoryTransmitter, andCorrectionIssuer(#1288): FA(3)VAT+KORdocuments are issued and cleared through the async submit→poll→UPO model (issueInvoice), andissueCorrectionis 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, adapterKeyinfakt.accounting.v1, registry capabilityInvoicing(#1281). Infakt submits to KSeF on the seller’s behalf rather than OL building FA(3) XML itself, soInfaktInvoicingAdapterimplementsRegulatoryStatusReader(poll the KSeF-relay clearance status Infakt reports) rather thanRegulatoryTransmitter. It also implementsCorrectionIssuer,RegulatoryDocumentReader,RegulatoryResubmitter(re-trigger transmission of an already-issued document — the operator “resend” action for arejectedclearance),BankAccountsReader/BankAccountDefaultSetter, plus the accounting-specificPaymentStatusReader/PaymentMarkerandInvoiceEmailSendersub-capabilities. - Subiekt nexo (via Sfera bridge),
@openlinker/integrations-subiekt— the firstInvoicingPortadapter (#753), implementingRegulatoryStatusReader,CorrectionIssuer,BankAccountsReader, andBankAccountDefaultSetter.
Capability Abstractions (Business Roles)
Section titled “Capability Abstractions (Business Roles)”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
InventoryMasterPort
Section titled “InventoryMasterPort”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
ProductMasterPort
Section titled “ProductMasterPort”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 // ... }}OrderProcessorManagerPort
Section titled “OrderProcessorManagerPort”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.
| Capability | Method(s) | Notes |
|---|---|---|
OrderFulfillmentUpdater | updateFulfillment({ externalOrderId, status, trackingNumber? }) | Push a post-create status + tracking update to the destination order (#837). PrestaShop maps the neutral OrderStatus to its native state. |
OrderStatusWriteback | write(event: OrderLifecycleEvent) | Relay-based, source-bound order-status round-trip — push a status change back to the originating marketplace (#1157, ADR-027). |
FulfillmentStatusReader | getFulfillmentStatus({ externalOrderId }) | Read a destination order’s current fulfillment status. |
DestinationOptionsReader | listCarriers(), listOrderStatuses(), listPaymentMethods() | Discover the destination’s option vocabulary for mapping. |
SourceOptionsReader | listOrderStatuses(), listDeliveryMethods(), listPaymentMethods() | Discover a source’s option vocabulary for mapping. |
Current Implementations: PrestashopOrderProcessorAdapter, WooCommerceOrderProcessorAdapter
Future Implementations:
OpenLinkerOrderProcessorAdapter(OpenLinker’s own order system)ShopifyOrderProcessorAdapter
OrderSourcePort
Section titled “OrderSourcePort”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)
OfferManagerPort
Section titled “OfferManagerPort”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.
| Capability | Method |
|---|---|
OfferLister | listOffers(input) |
OfferEventReader | listOfferEvents(input) |
OfferQuantityBatchUpdater | updateOfferQuantitiesBatch(cmd) |
OfferFieldUpdater | updateOfferFields(cmd) |
OfferStockRestorer | restoreStockOnCancellation(targets) |
CategoryBrowser | fetchCategories(parentId?) |
CategoryBarcodeMatcher | matchCategoryByBarcode(barcode) |
OfferCreator | createOffer(cmd) |
OfferStatusReader | getOfferStatus(externalOfferId) |
OfferSmartClassificationReader | getOfferSmartClassification(externalOfferId) |
SellerPoliciesReader | fetchSellerPolicies() |
CatalogProductReader | findProductsByBarcode(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: updateOfferQuantity → PUT /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.
Future Capability Ports
Section titled “Future Capability Ports”- PricingAuthorityPort: Manages pricing rules and catalog pricing. A capability-port-shaped
PricingAuthorityPortis 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 thestockSafetyBufferconfig-coercion precedent (#1844) — that resolve a destination price from the master catalog price via a per-connectionConnection.config.pricingRule({ type: 'passthrough' | 'markup' | 'margin', percent?, rounding?: 'none' | 'nearestWhole' | 'endingIn99' }, JSONB — no schema/migration change).markupis cost-plus (price = base * (1 + percent/100));marginsolves for the price whose margin over the base equalspercent(price = base / (1 - percent/100), guarded forpercent >= 100). BothOfferBuilderServiceandProductPublishBuilderServicecall it as the sole source ofcommand.pricewhenever no explicit per-iteminput.priceis supplied (an explicit price always wins and is never re-priced) — replacing the prior rawproduct.pricepassthrough. A connection with no configured rule is untouched (byte-identical to the pre-#1843 passthrough). No FE control exists yet for editingconfig.pricingRule(same posture asstockSafetyBuffertoday) — 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).
Identifier Mapping Service
Section titled “Identifier Mapping Service”Overview
Section titled “Overview”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.
Key Responsibilities
Section titled “Key Responsibilities”- Generate Internal Identifiers: Creates new unique internal IDs for entities when they are first encountered from external platforms
- Map External to Internal: Provides mapping from external platform IDs to internal OpenLinker IDs
- Context-Aware Mapping: Handles mapping based on entity type (Product, Order, Offer, etc.), platform, and context
- Maintain Mapping Registry: Stores and retrieves mappings between external and internal identifiers
Connection Entity
Section titled “Connection Entity”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
Section titled “Interface”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 Identifier Format
Section titled “Internal Identifier Format”Internal identifiers are generated from a single unified seed across all entity types:
- Format:
ol_{prefix}_{uuid}whereprefixdefaults toentityType.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
TEXTtype in PostgreSQL (not UUID) - Prefix overrides: A small
ENTITY_TYPE_ID_PREFIXmap inidentifier-mapping.types.tsoverrides the default for entity types where the documented prefix diverges from the lowercased class name. Today:ProductVariant → variant(so IDs areol_variant_*, notol_productvariant_*). - Canonical Entities: Product, ProductVariant, InventoryItem use internal IDs as primary keys
Usage by Adapters
Section titled “Usage by Adapters”Adapters are responsible for:
- Fetching data from external platforms
- Transforming data to OpenLinker unified schema
- 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); }}Storage
Section titled “Storage”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
platformTypefor faster lookups - Data integrity:
platformTypeis immutable on Connection, safe to denormalize
Benefits
Section titled “Benefits”- Unified Identity: All entities have consistent internal identifiers regardless of source
- Platform Agnostic: Core domain logic works with internal IDs only
- Traceability: Can always find external IDs from internal IDs and vice versa
- Adapter Responsibility: Adapters handle ID translation, keeping core domain clean
- Single Source of Truth: One service manages all identifier mappings
Customer Identity Resolution
Section titled “Customer Identity Resolution”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.
Identity Resolution Modes
Section titled “Identity Resolution Modes”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)
Customer Provisioning Model (Model A)
Section titled “Customer Provisioning Model (Model A)”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:
- PrestaShop adapter provisions a guest customer (
is_guest=1) - Customer is created with valid password (5-72 chars, PrestaShop hashes internally)
- Customer ID is stored in identifier mappings for future reuse
Customer Projection Model (Model C)
Section titled “Customer Projection Model (Model C)”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:
emailHashis always persisted regardless of PII setting
Email Normalization
Section titled “Email Normalization”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.pl→8awgqyk6a5@allegromail.pl - Why: Transaction ID changes per order, but fixed part is stable per buyer
Address Reuse
Section titled “Address Reuse”Addresses are reused across orders when identical (determined by hash):
- Hash Components:
address1,address2,city,postcode,countryIso2 - Reuse Priority:
- Primary: Query
destination_address_mappingstable (fast, deterministic) - Fallback: Query PrestaShop addresses and match by hash (recovery scenario)
- Primary: Query
- Address Alias: Deterministic alias format:
OL-{type}-{hash-prefix}(e.g.,OL-shipping-a1b2c3)
Collision Handling
Section titled “Collision Handling”When emailHash matches multiple customers (collision):
- Policy: Create new internal customer (no merge)
- Logging: Warning logged with emailHash and match count
- Result:
collisionDetected=truein resolution result - Rationale: Prevents incorrect customer merging (shared emails in families/businesses)
Hexagonal Architecture Structure
Section titled “Hexagonal Architecture Structure”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.tsLayer Dependencies
Section titled “Layer Dependencies”interfaces → application → domain ↓ ↓infrastructure → domainRules:
- 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
Repository Ports Pattern
Section titled “Repository Ports Pattern”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:
-
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} -
Implement port in infrastructure layer:
infrastructure/persistence/repositories/product.repository.ts @Injectable()export class ProductRepository implements ProductRepositoryPort {// Implementation using TypeORM} -
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) {}} -
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 directlyimport { ProductRepository } from '../infrastructure/persistence/repositories/product.repository';
// Service works with ORM entitiesconst 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 checkingexport interface ProductRepositoryPort { insertMapping(...): Promise<...>; isUniqueViolationError(error: unknown): boolean; // ❌ Infrastructure-specific}
// Service depends on infrastructure error typescatch (error) { if (error instanceof QueryFailedError) { // ❌ Infrastructure awareness // ... }}Cross-context dependencies in core
Section titled “Cross-context dependencies in core”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.
The rule
Section titled “The rule”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:
| Allowed | Pattern | Example |
|---|---|---|
| Service interfaces | I*Service | IIntegrationsService, IIdentifierMappingService |
| DI tokens (Symbol) | *_TOKEN | INTEGRATIONS_SERVICE_TOKEN, EVENT_PUBLISHER_TOKEN |
| Capability ports | *Port (single Port suffix) | OfferManagerPort, OrderSourcePort, EventPublisherPort |
| Capability type-guards | is* | isOfferCreator, isCategoryBarcodeMatcher |
| Domain entities, value objects, type aliases | published in the barrel | Connection, Product, Order, MarketplaceCursor |
| Domain exceptions | *Exception, *Error | ConnectionNotFoundException, DuplicateIdentifierMappingError |
Other as const value constants | UPPER_SNAKE_CASE | CORE_ENTITY_TYPE, OFFER_CREATION_STATUS |
| NestJS module classes | *Module — for imports: [...] only, never injected into services | CustomersModule in orders.module.ts |
The cross-context contract is explicitly forbidden for these symbol shapes:
| Forbidden | Pattern | Why |
|---|---|---|
| Repository ports | *RepositoryPort | Intra-context contract — exposes persistence concerns the source context controls. Cross-context callers go through I*Service. |
| ORM entities | *OrmEntity | TypeORM-decorated infrastructure detail (and ESLint-guarded under @openlinker/core/<ctx>/orm-entities separately). |
| Adapter classes | *Adapter | Concrete infrastructure; sibling contexts see behaviour through I*Service or capability ports, never the adapter directly. |
| Application DTOs | *Dto | Owned by the source context’s interface layer. |
| Default imports | import X from '@openlinker/core/<ctx>' | Barrels have no default export. |
| Namespace imports | import * 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.
Why each rule exists
Section titled “Why each rule exists”- Service interfaces are the seam. A context’s
I*Serviceshape is the only thing sibling contexts can rely on staying stable. Whenproductsreorganises its repository layout, the consumers inorders/listings/inventorydon’t break — they kept askingIProductsServicefor what they needed, andproductsis 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
ordersimportsProductfrom@openlinker/core/products, it binds to the published shape —productscan 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.tsimportsCustomersModuleto compose providers. A service constructor must never type-hintCustomersModuledirectly — that’s the wrong layer.
Current dependency map
Section titled “Current dependency map”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 --> integrationsidentifier-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.
Enforcement
Section titled “Enforcement”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:
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, includingsrc/**andtest/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.
Module Organization
Section titled “Module Organization”Monorepo Structure
Section titled “Monorepo Structure”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.jsonCapability 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
OrderProcessorManagerconnections) - ✅ 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
OrderProcessorManagerconnections (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.
Data Flow
Section titled “Data Flow”1. Order Synchronization Flow (Marketplace → Shop)
Section titled “1. Order Synchronization Flow (Marketplace → Shop)”Polling Flow
Section titled “Polling Flow”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 statusReal-Time Flow
Section titled “Real-Time Flow”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 API2. 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.3. Event-Driven Flow
Section titled “3. Event-Driven Flow”External System Event │ ▼Adapter (e.g., AllegroAdapter) │ │ Emits domain event ▼Event Bus (Redis Streams) │ ▼Event Handlers │ ├─> OrderSyncListener ├─> InventorySyncListener └─> NotificationListener4. 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 anywebhook_deliveriesrow is written (this doc’s ADR-005 dedup-gate invariant), so that table cannot record failures.WebhookServiceinstead upserts a durable rolling counter to a distinctwebhook_auth_rejectionstable (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-facingactivation: '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 tonot-registered.- Event routing (#900, ADR-015):
WebhookToJobHandleris a thin dispatcher — it resolves the connection’s per-pluginWebhookEventTranslator(translate → neutralCanonicalInboundEvent) and delegates to the capability-gated coreInboundRoutingPolicy, which mapsdomain → jobType(no platform string-matching in the interface layer). PrestaShop order events route tomarketplace.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 thedate_updwatermark. Both paths converge on the idempotentOrderIngestionService.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_keyunique 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)
Technology Stack
Section titled “Technology Stack”Core Technologies
Section titled “Core Technologies”- Framework: NestJS
- Language: TypeScript (strict mode)
- Database: PostgreSQL (TypeORM)
- Caching: Redis
- Event Bus: Redis Streams (initial), RabbitMQ/Kafka (future)
- Package Manager: pnpm (monorepo)
Key Libraries
Section titled “Key Libraries”- 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
- Adapter HTTP clients: native
- 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 viainstallNestLogger()from@openlinker/shared/logging/nest
Development Tools
Section titled “Development Tools”- Linting: ESLint
- Formatting: Prettier
- Testing: Jest
- Type Checking: TypeScript (strict mode)
Related Documentation
Section titled “Related Documentation”- Engineering Standards - Coding standards and conventions
- AI Coding Assistant Guide - Behaviour, reasoning expectations and guardrails for AI coding assistants
- ADR-025: Erli marketplace adapter - reconciliation-first posture, static API-key auth, Allegro-ID taxonomy reuse