Skip to content

ERP-Unlocked Architecture Evolution: Brownbag Presentation

Date: January 2025
Duration: 45 minutes
Audience: Engineering Team


From Trigger.dev to Dagster: Building a Modern Multi-ERP ETL Pipeline

Presented by: Engineering Team


  1. The Problem: Why we needed to change
  2. The Solution: New architecture overview
  3. Key Components: Deep dive into Dagster, dlt, dbt, Typesense
  4. Multi-ERP Support: How we handle different ERP systems
  5. Infrastructure as Code: Terraform + Ansible automation
  6. Migration Path: How we got here
  7. What’s Next: Future enhancements

Trigger.dev + TypeScript + PostgreSQL

Limited Observability

  • Basic Trigger.dev dashboard
  • No asset lineage
  • Difficult to debug failures

Manual Multi-Tenancy

  • Connection IDs passed manually
  • No automatic discovery
  • Difficult to scale

Tight Coupling

  • ERP-specific logic in TypeScript
  • Hard to add new ERP systems
  • No standardized transformation layer

Storage Limitations

  • PostgreSQL for large datasets
  • No time travel or schema evolution
  • Expensive scaling

Dagster + dlt + dbt + Typesense + Iceberg

Full Observability

  • Dagster UI with asset lineage
  • Complete run history
  • Error tracking and debugging

Automatic Multi-Tenancy

  • Sensors discover connections
  • Dynamic partitions per tenant
  • Parallel processing

Extensible Architecture

  • Factory pattern for ERPs
  • Standardized contracts
  • Easy to add new systems

Scalable Storage

  • Iceberg tables on R2
  • Schema evolution
  • Time travel queries

┌─────────────────────────────────────────────────────────┐
│ ERP Systems (Prophet21, NetSuite, SAP) │
└───────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Dagster Orchestration (Discovery, Scheduling) │
└───────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Extract (dlt) → Transform (Python) → Bronze │
│ (Iceberg on R2 - Partitioned by connection_id) │
└───────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Transform (dbt) → Silver → Gold │
│ (DuckDB engine, Parquet on R2) │
└───────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Load (Typesense) → Search Collections │
│ (Sub-millisecond search with typo tolerance) │
└─────────────────────────────────────────────────────────┘

Modern data orchestration platform

  • Asset-Based: Models data as first-class citizens
  • Observability: Built-in UI with lineage and run history
  • Partitions: Native support for multi-tenant workloads
  • Sensors: Event-driven orchestration

Automatic Discovery

@sensor(name="erp_connection_discovery_sensor")
def discover_connections(context):
# Runs every 5 minutes
# Queries erp_connections table
# Creates partitions for new connections

Dynamic Partitions

  • One partition per connection_id
  • Automatic creation/deletion
  • Parallel processing per tenant

Schedules

  • Bronze sync: Every 2 hours (incremental)
  • dbt transform: Hourly
  • Typesense sync: Hourly

Data Load Tool - Python library for building data pipelines

Declarative Sources

@dlt.source(name="prophet21")
def prophet21_source(api_url, username, password):
# Returns resources for products, customers, etc.

Automatic Schema Inference

  • Detects field types
  • Handles schema evolution

Incremental Loading

  • Watermark-based syncs
  • Only fetches changed records

Built-in Retry Logic

  • Exponential backoff
  • Rate limiting
  • Error handling
  • Partitioned by connection_id
  • ACID transactions
  • Schema evolution
  • Time travel queries

Stage 1: Python Transformers

  • Map ERP-specific → Canonical schema
  • Validate against contracts
  • Handle version differences

Stage 2: dbt Models

  • Silver: Deduplication, cleaning
  • Gold: Business logic, search optimization
Prophet21 Product → Canonical Product
─────────────────────────────────────────────────
item_id → erp_product_id
item_desc → name
price1, price2, ... → pricing.price1, pricing.price2
delete_flag = "Y" → delete_flag = true
date_last_modified → updated_at

dbt Gold Layer:

  • OCR variants: “WIDGET-100” → [“WIDGET100”, “WIDGET_100”, …]
  • Format variants: Normalized part numbers
  • Search text: Concatenated searchable fields

Open table format for large analytics datasets

Schema Evolution

  • Add columns without rewriting data
  • Backward compatible changes

Time Travel

  • Query historical snapshots
  • Audit trail built-in

Partition Pruning

  • Efficient queries by connection_id
  • Only scans relevant partitions

ACID Transactions

  • Safe concurrent writes
  • No data corruption

Cloudflare R2 (S3-compatible)

  • Cost-effective object storage
  • Global CDN integration
  • No egress fees

Table Structure:

s3://erp-data-catalog/
├── products/ (Iceberg table)
├── customers/ (Iceberg table)
├── cross_references/ (Iceberg table)
└── shipping_addresses/ (Iceberg table)

Fast, typo-tolerant search engine

Sub-Millisecond Performance

  • < 1ms search latency
  • Handles millions of documents

Typo Tolerance

  • 2-3 character typos
  • OCR error handling

Prefix Matching

  • Fast partial matches
  • Great for part numbers

Faceted Filtering

  • Filter by connection_id
  • Multi-tenant isolation
  • products: Full-text search with variants
  • customers: Fuzzy customer matching
  • cross_references: Part number lookup
  • shipping_addresses: Address search
  • Reads from Gold layer (dbt dim_* tables)
  • Incremental upserts
  • Handles deletes via delete_flag

ERP-Agnostic Architecture

def create_orchestrator(connection_id: str):
connection = fetch_connection(connection_id)
if connection.type == "prophet21":
return Prophet21SyncOrchestrator(...)
elif connection.type == "netsuite":
return NetSuiteSyncOrchestrator(...)
elif connection.type == "sap_b1":
return SAPB1SyncOrchestrator(...)
ERPStatusAPI TypeKey Features
Prophet21✅ ProductionODataVersion detection, input sanitization
Demo✅ TestingMockTest data generation
NetSuite🔜 PlannedRESTOAuth 2.0, SuiteQL
SAP B1🔜 PlannedRESTSession management, nested data

ERP systems have version fragmentation

Example: Prophet21

  • API v1 vs v2
  • OData v3 vs v4
  • Data Services present/absent
  • Field casing: PascalCase vs snake_case

Version Detection & Capability Flags

# Auto-detect on first connection
version_info = P21VersionDetector.detect()
# Returns: {api_version: "v2", odata_version: "v4", use_data_services: true}
# Store in erp_connections.extra_config
connection.extra_config = version_info.to_dict()
# Use in client
client = Prophet21Client(auth, version_info)
# Routes to correct endpoints based on version

Capability Flags

  • Per-ERP default capabilities
  • Per-connection overrides
  • Handles missing features gracefully

Complete automation for infrastructure

Provisioning

  • Hetzner Cloud servers
  • Private networks
  • Firewall rules
  • Load balancers
  • DNS (Cloudflare)

Features

  • 1Password integration for secrets
  • Remote state in R2
  • Multi-environment support

Configuration

  • Server hardening
  • Docker installation
  • Coolify setup
  • Security policies

Features

  • Dynamic inventory (Hetzner API)
  • Idempotent playbooks
  • Role-based organization

Application Deployment

  • API-based setup
  • Environment variables from 1Password
  • GitHub webhook integration
  • Automatic SSL/TLS

ServerTypeRoleCost
erp-webCPX21Webapp (Astro/React)€10/mo
erp-processingCPX31PDF API + Worker€20/mo
erp-dataCPX41Dagster web + daemon€40/mo
erp-failoverCPX41Staging + failover€40/mo
Load BalancerLB11Traffic distribution€5/mo
Total~€115/mo
  • Private network (10.0.0.0/16)
  • Firewall rules for security
  • Load balancer for high availability
  • Coolify: Docker container orchestration
  • GitHub Actions: CI/CD automation
  • 1Password: Secrets management

1. Discovery Sensor (every 5 min)
└─> Finds new connection: "abc-123" (Prophet21)
└─> Creates partition
2. Sync Trigger (every 2 hours)
└─> Triggers bronze_sync for "abc-123"
3. Bronze Sync
└─> ERPSourceFactory → Prophet21SyncOrchestrator
└─> dlt extracts from P21 API
└─> Transformers normalize data
└─> Writes to Iceberg (partitioned by connection_id)
4. dbt Transform (hourly)
└─> Reads from Iceberg
└─> Silver: Deduplication
└─> Gold: OCR variants, search text
└─> Writes to R2 (Parquet)
5. Typesense Sync (hourly)
└─> Reads from Gold
└─> Upserts to Typesense collections
└─> Search available immediately

Time: ~15-20 minutes end-to-end for incremental sync


OperationTimeNotes
Incremental Sync5-10 min10K changed records
Full Sync30-60 min100K records
Parallel ProcessingN/AMultiple connections simultaneously
Partition PruningFastOnly processes changed tenants
MetricValue
Search Latency< 1ms
Typo Tolerance2-3 characters
Prefix MatchingInstant
Faceted Filtering< 1ms
  • Iceberg: Columnar format, efficient compression
  • Partitioning: Only queries relevant tenant data
  • Incremental Updates: Only writes changed records
  • Time Travel: Historical data without duplication

Phase 1: Planning (Q4 2024)

  • Evaluated Dagster vs alternatives
  • Designed multi-ERP architecture
  • Created integration playbooks

Phase 2: Implementation (Q4 2024 - Q1 2025)

  • Built Dagster pipeline
  • Migrated Prophet21 integration
  • Set up dbt transformations
  • Integrated Typesense

Phase 3: Infrastructure (Q1 2025)

  • Terraform for provisioning
  • Ansible for configuration
  • Coolify automation
  • CI/CD integration

Phase 4: Production (Q1 2025)

  • Deployed to production
  • Migrated existing connections
  • Monitored performance
  • Optimized workflows

Better Observability

  • Full asset lineage
  • Complete run history
  • Easy debugging

Automatic Scaling

  • Auto-discovers new tenants
  • Parallel processing
  • No manual configuration

Extensibility

  • Easy to add new ERPs
  • Standardized patterns
  • Reusable components

Data Quality

  • Contract validation
  • Schema enforcement
  • Error tracking

Performance

  • Fast incremental syncs
  • Efficient queries
  • Sub-millisecond search

Additional ERP Systems

  • ✅ Prophet21 (Production)
  • 🔜 NetSuite (In Progress)
  • 🔜 SAP Business One (Planned)
  • 🔜 Microsoft Dynamics (Planned)

Enhanced Observability

  • BetterStack integration
  • Custom metrics dashboards
  • Alerting for sync failures

Performance Optimization

  • Parallel entity syncs
  • Streaming transformations
  • Caching layer

Data Quality

  • Automated quality checks
  • Anomaly detection
  • Data lineage tracking

  • Architecture Docs: docs/architecture/dagster-etl-architecture.md
  • Dagster README: apps/dagster/README.md
  • Prophet21 Playbook: debt/backlog/prophet21-refactor-playbook.md
  • NetSuite Playbook: debt/backlog/netsuite-integration-playbook.md
  • SAP B1 Playbook: debt/backlog/sap-business-one-integration-playbook.md
  • Infrastructure Docs: infrastructure/README.md

Contact:

  • Engineering Team
  • Slack: #engineering
  • GitHub: erp-unlocked

Next Steps:

  • Review documentation
  • Explore Dagster UI
  • Check out integration playbooks

5 Steps to Add NetSuite:

  1. Create DLT Source (sources/netsuite_source.py)

    • Define @dlt.source with resources
    • Handle OAuth 2.0, SuiteQL queries
  2. Create Transformers (transformers/netsuite_products.py)

    • Map NetSuite fields → Canonical schema
    • Register in TransformerRegistry
  3. Create Orchestrator (implementations/netsuite_sync_orchestrator.py)

    • Implement ERPSourceProtocol
    • Wire DLT → Transformers → Iceberg
  4. Update Factory (sources/erp_source_factory.py)

    • Add elif erp_type == "netsuite" case
  5. Test & Deploy

    • Integration tests with sandbox
    • Contract validation
    • Production rollout

See: debt/backlog/netsuite-integration-playbook.md for full details

Prophet21 Version Detection:

class P21VersionDetector:
def detect(self) -> P21VersionInfo:
# Probe OData v4 support
odata_version = self._detect_odata_version()
# Probe Data Services
use_data_services = self._detect_data_services()
return P21VersionInfo(
api_version="v2" if use_data_services else "v1",
odata_version=odata_version,
use_data_services=use_data_services,
detected_at=datetime.now().isoformat()
)

Stored in: erp_connections.extra_config JSONB column

Product Search:

results = typesense.collections['products'].documents.search({
'q': 'widget-100',
'filter_by': 'connection_id:=abc-123 && delete_flag:=false',
'query_by': 'erp_product_id,ocr_variants,format_variants,name',
'num_typos': 2,
'prefix': True
})

Features:

  • Handles typos: “widgt-100” → finds “WIDGET-100”
  • OCR variants: “WIDGET100” → finds “WIDGET-100”
  • Format variants: “widget_100” → finds “WIDGET-100”
  • Sub-millisecond response

Terraform:

Terminal window
cd infrastructure/terraform/environments/production
terraform init
terraform plan
terraform apply

Ansible:

Terminal window
cd infrastructure/ansible
ansible-playbook playbooks/site.yml -i inventories/production/hcloud.yml

Coolify:

Terminal window
cd infrastructure/coolify
./scripts/full-coolify-setup.sh

See: infrastructure/README.md for full workflow


  • Slides 1-5: 10 minutes (Problem & Solution)
  • Slides 6-10: 15 minutes (Components Deep Dive)
  • Slides 11-15: 10 minutes (Multi-ERP & Infrastructure)
  • Slides 16-21: 10 minutes (Performance & Q&A)
  1. Automatic Multi-Tenancy: No manual configuration needed
  2. Extensibility: Easy to add new ERP systems
  3. Observability: Full visibility into data pipeline
  4. Performance: Fast incremental syncs and search
  5. Infrastructure as Code: Complete automation
  1. Dagster UI: Show asset lineage graph
  2. Run History: Show successful sync run
  3. Typesense Search: Demo typo-tolerant search
  4. Infrastructure: Show Terraform plan output

Q: Why Dagster instead of Airflow? A: Better asset-based model, native Python support, modern UI

Q: Why Iceberg instead of Parquet? A: Schema evolution, time travel, ACID transactions

Q: Why Typesense instead of Elasticsearch? A: Simpler setup, better typo tolerance, faster for our use case

Q: How do we handle ERP API changes? A: Version detection, capability flags, fallback chains

Q: What about data quality? A: Contract validation, cursor store tracking, error monitoring