Skip to content

Microservices Architecture Brainstorm

Status: Draft / Brainstorm (Domain Boundaries Implemented)
Created: January 16, 2026
Updated: January 17, 2026
Author: Architecture Team

This document explores the possibility of migrating from the current monolithic architecture to a microservices architecture using NATS as the message broker, with each domain owning its own database.


Table of Contents

  1. Current State Analysis
  2. Proposed Microservices Architecture
  3. Migration Path
  4. Comparison: Monolith vs Microservices
  5. Risk Analysis
  6. Recommendation
  7. What If: Multi-Tenancy & SaaS Model
  8. Domain Boundary Evaluation

Current State Analysis

Architecture Overview

The current system is a modular monolith built with NestJS, deployed as a single application with the following characteristics:

uml diagram

Current Domains

Domain Responsibility Database Tables
Orders Order lifecycle, status management Order, LineItem
PrintJobs Print job tracking, status sync PrintJob
Orchestration Workflow coordination (stateless, uses Order/PrintJob)
Fulfillment Shopify fulfillment creation (updates Order)
Shipments Sendcloud integration, labels Shipment
ProductMappings SKU → STL file mappings ProductMapping
Notifications Email alerts (stateless)
EventLog Audit trail EventLog
RetryQueue Failed operation retries RetryJob

Current Event Flow

Events flow synchronously within the same process via EventEmitter2:

OrdersService → emit('order.created') → OrchestrationService.handleOrderCreated()
                                      → EventsGateway.broadcastToClients()

Characteristics:

  • ✅ Zero latency between services
  • ✅ Shared transaction context possible
  • ✅ Simple debugging (single process)
  • ❌ No isolation between domains
  • ❌ Single point of failure
  • ❌ Scaling requires scaling entire application

Proposed Microservices Architecture

Service Decomposition

Based on domain boundaries and the Bounded Context pattern, we propose the following microservices:

uml diagram

Proposed Services

Service Owns Events Database External Dependencies
Orders Service order.* Orders, LineItems -
Print Service printjob.* PrintJobs SimplyPrint API
Orchestration Service orchestration.* (stateless) -
Fulfillment Service fulfillment.* Fulfillments Shopify API
Shipping Service shipment.* Shipments Sendcloud API
Notification Service - (stateless) SMTP
Dashboard BFF - (stateless) Aggregates from other services

NATS Integration

NATS would serve as the central nervous system:

// Publishing an event (Orders Service)
await this.nats.publish('order.created', {
  orderId: order.id,
  shopifyOrderId: order.shopifyOrderId,
  lineItemCount: order.lineItems.length,
});

// Subscribing to events (Orchestration Service)
@NatsSubscribe('order.created')
async handleOrderCreated(data: OrderCreatedEvent) {
  await this.processNewOrder(data);
}

NATS Features to Leverage:

  • JetStream: Durable message storage for replay and exactly-once delivery
  • Request/Reply: Synchronous queries between services
  • Subject Hierarchy: order.>, printjob.status.>, etc.
  • Consumer Groups: Horizontal scaling of service instances

Data Ownership Strategy

Each service owns its data exclusively:

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  Orders Service │     │  Print Service  │     │ Shipping Service│
├─────────────────┤     ├─────────────────┤     ├─────────────────┤
│ • Order         │     │ • PrintJob      │     │ • Shipment      │
│ • LineItem      │     │ • PrinterStatus │     │ • ShippingMethod│
│ • orderStatus   │     │ • printJobStatus│     │ • Label         │
└────────┬────────┘     └────────┬────────┘     └────────┬────────┘
         │                       │                       │
         └───────────────────────┴───────────────────────┘
                                 │
                    ┌────────────┴────────────┐
                    │   NATS Event Stream     │
                    │  (eventual consistency) │
                    └─────────────────────────┘

Cross-Service Data Access Patterns:

  1. Event Carrying State Transfer: Events include enough data for consumers
  2. Request/Reply: For synchronous lookups (e.g., "get order details")
  3. Local Materialized Views: Services maintain read-only copies of data they need frequently

Migration Path

Phase 0: Preparation (Current State Enhancement) ✅ COMPLETE

Goal: Make the monolith "microservices-ready" without breaking anything.

Duration: 2-4 weeks
Risk: Low
Status: ✅ COMPLETE (January 17, 2026)

Steps:

  1. ✅ Already done: Event-driven architecture with clear domain events
  2. ✅ Interface boundaries between modules (no direct repository access across domains) — libs/domain-contracts
  3. ✅ Correlation IDs on all events for distributed tracing — CorrelationService with AsyncLocalStorage
  4. ⏳ Implement the Outbox Pattern for reliable event publishing (optional, deferred)
  5. ✅ Health checks and readiness probes (already implemented in Phase 1c)

Phase 1: Extract Message Broker (Strangler Fig Pattern)

Goal: Run NATS alongside EventEmitter2, gradually shifting events.

Duration: 3-4 weeks
Risk: Medium

uml diagram

Steps:

  1. Deploy NATS cluster (JetStream enabled)
  2. Create EventBridgeService that publishes to both EventEmitter2 and NATS
  3. Update all event emitters to use the bridge
  4. Verify events flow correctly in both systems
  5. Add dead-letter handling for NATS

Phase 2: Extract First Service (Orders)

Goal: Run Orders as a separate service while monolith handles the rest.

Duration: 4-6 weeks
Risk: High (first extraction is hardest)

Steps:

  1. Create new orders-service NestJS application
  2. Copy Orders module code to new service
  3. Set up dedicated PostgreSQL database for Orders
  4. Migrate data (one-time sync or CDC with Debezium)
  5. Update monolith to consume order data via NATS request/reply
  6. Route Shopify order webhooks to new service
  7. Deprecate Orders module in monolith

Phase 3: Extract Print Service

Goal: Isolate SimplyPrint integration.

Duration: 3-4 weeks
Risk: Medium

Steps:

  1. Extract PrintJobs module + SimplyPrint integration
  2. Create dedicated database
  3. Subscribe to order.created from Orders Service
  4. Publish printjob.* events to NATS
  5. Update Orchestration to consume via NATS

Phase 4: Extract Shipping & Fulfillment

Goal: Complete the core service decomposition.

Duration: 4-6 weeks
Risk: Medium

Steps:

  1. Extract Shipping Service (Sendcloud integration)
  2. Extract Fulfillment Service (Shopify fulfillment)
  3. Handle the coordination: Shipping → Fulfillment dependency
  4. Implement Saga pattern for the fulfillment workflow

Phase 5: Finalize & Optimize

Goal: Production-ready microservices.

Duration: 2-4 weeks
Risk: Low

Steps:

  1. Implement API Gateway (routing, auth, rate limiting)
  2. Set up distributed tracing (Jaeger/Zipkin)
  3. Configure service mesh (optional: Linkerd/Istio)
  4. Implement circuit breakers
  5. Load testing and performance optimization
  6. Documentation and runbooks

Comparison: Monolith vs Microservices

For Forma3D Connect Specifically

Criterion Monolith Microservices Winner for Our Case
Team Size 1-3 developers 5+ developers 🏆 Monolith (small team)
Deployment Complexity Simple (1 container) Complex (10+ containers) 🏆 Monolith
Operational Overhead Low High (monitoring, logging, tracing) 🏆 Monolith
Development Speed Fast (shared codebase) Slower (cross-service changes) 🏆 Monolith
Scaling Flexibility Limited (scale everything) Fine-grained (scale what's needed) 🏆 Microservices
Fault Isolation Poor (one bug crashes all) Good (isolated failures) 🏆 Microservices
Technology Flexibility Same stack everywhere Polyglot possible Tie (not needed)
Data Consistency Easy (ACID transactions) Hard (eventual consistency) 🏆 Monolith
Testing Simple (integration tests) Complex (contract testing) 🏆 Monolith
Debugging Easy (single process) Hard (distributed tracing) 🏆 Monolith
Independent Deployability No Yes 🏆 Microservices
External Integration Isolation No Yes (SimplyPrint/Sendcloud isolated) 🏆 Microservices

Monolith Advantages (For Our Use Case)

  1. Simplicity: Single deployment, single database, single log stream
  2. Transactional Integrity: Create order + line items + print jobs in one transaction
  3. Refactoring Freedom: Rename, move, restructure without cross-service coordination
  4. Lower Infrastructure Cost: One PostgreSQL, one container, one monitoring stack
  5. Faster Development: No inter-service contracts, no API versioning overhead
  6. Debugging: Full stack trace in one place

Microservices Advantages (For Our Use Case)

  1. Fault Isolation: SimplyPrint API down? Only Print Service affected
  2. Independent Scaling: Print job processing needs 5x resources? Scale only that service
  3. External API Isolation: Sendcloud rate limit? Only Shipping Service queues up
  4. Team Independence: Different teams can own different services (future growth)
  5. Technology Evolution: Replace Print Service implementation without touching Orders
  6. Resilience: NATS JetStream provides message durability and replay

Hidden Costs of Microservices

Cost Category Monolith Microservices
Infrastructure 1 PostgreSQL, 1 container 5+ databases, 10+ containers, NATS cluster
Monitoring Simple (stdout + Sentry) Distributed tracing, per-service dashboards
DevOps Time Minimal Significant (deployment, networking, secrets)
Cognitive Load Understand one codebase Understand 5+ codebases + interactions
Incident Response "The app is down" "Which service? What's the event flow?"
Data Migration None Complex (cross-service data sync)

Risk Analysis

Migration Risks

Risk Probability Impact Mitigation
Data inconsistency during migration High Critical Dual-write with verification
Service communication failures Medium High Circuit breakers, retries, DLQ
Increased latency High Medium Request/reply caching, async where possible
Developer productivity drop High Medium Extensive documentation, templates
Operational complexity overwhelming Medium High Invest in DevOps tooling first
Cost overrun Medium Medium Phase gates with go/no-go decisions

"Point of No Return" Considerations

  • Phase 1 (NATS alongside): Fully reversible
  • Phase 2 (First service extracted): Reversible but expensive
  • Phase 3+: Committed to microservices

Recommendation

Short-Term (0-12 months): Stay with Modular Monolith

Reasoning:

  1. Team Size: With a small team (1-3 developers), the operational overhead of microservices would consume significant development capacity
  2. Current Pain Points: The monolith is not experiencing scaling issues, deployment bottlenecks, or fault isolation problems
  3. Feature Velocity: The team can ship features faster with the current architecture
  4. ROI: The investment in microservices infrastructure (NATS cluster, per-service databases, distributed tracing) does not have a clear payback period

What to Do Instead:

  1. Strengthen Module Boundaries: Enforce interface-based communication between modules
  2. Improve Event Infrastructure: Add correlation IDs, structured logging, event versioning
  3. Monitor for Pain Points: Track metrics that would indicate need for microservices:
  4. Deployment frequency constraints
  5. Scaling bottlenecks (specific modules under load)
  6. Mean time to recovery after failures
  7. Document Domain Boundaries: Keep the event catalog updated as the "API contract" for future decomposition

Medium-Term (12-24 months): Evaluate Based on Growth

Trigger conditions for reconsidering microservices:

Trigger Threshold
Team size grows to 5+ developers
Deployment conflicts per month > 10
Single service needs 3x more resources than others Yes
External API integration issues affecting unrelated domains Recurring
Feature development blocked by monolith coupling > 20% of sprints

Long-Term (24+ months): Gradual Extraction if Needed

If the triggers above are met, follow the phased migration path starting with the most problematic domain (likely Print Service due to external dependency on SimplyPrint).


Summary

Aspect Recommendation
Architecture Stay with modular monolith
Message Broker Not needed now; EventEmitter2 is sufficient
Database Keep single PostgreSQL database
Event System Continue investing in event catalog and correlation
Future-Proofing Maintain clear module boundaries for potential extraction

Key Insight

"Start with a monolith, and only extract microservices when you feel the pain."
— Martin Fowler, "MonolithFirst"

The current Forma3D Connect architecture is already event-driven and has clear domain boundaries. This positions it well for future decomposition if and when that becomes necessary. Premature optimization toward microservices would add complexity without proportional benefits at the current scale.


Appendix: NATS Quick Reference

If we do proceed with NATS in the future, here's a quick reference:

// NestJS NATS integration
import { ClientProxy, ClientProxyFactory, Transport } from '@nestjs/microservices';

const client = ClientProxyFactory.create({
  transport: Transport.NATS,
  options: {
    servers: ['nats://localhost:4222'],
  },
});

// Publish event (fire-and-forget)
client.emit('order.created', orderData);

// Request/Reply (synchronous)
const order = await client.send('order.get', { orderId: '123' }).toPromise();

// Subscribe (in service)
@MessagePattern('order.get')
getOrder(@Payload() data: { orderId: string }) {
  return this.ordersRepository.findById(data.orderId);
}

@EventPattern('printjob.completed')
handlePrintJobCompleted(@Payload() data: PrintJobCompletedEvent) {
  // Handle event
}

NATS JetStream for Durability

// Durable consumer with exactly-once delivery
const js = await nats.jetstream();
const consumer = await js.consumers.get('ORDERS', 'orchestration-consumer');
const messages = await consumer.consume();

for await (const msg of messages) {
  await this.processMessage(msg.data);
  msg.ack();
}

What If: Multi-Tenancy & SaaS Model

Scenario: Forma3D Connect becomes a commercial SaaS product, allowing other 3D print farms to subscribe and use the integration services with their own Shopify stores, SimplyPrint accounts, and Sendcloud credentials.

This section explores how multi-tenancy changes the architectural calculus.


Multi-Tenancy Requirements

Requirement Description
Data Isolation Each tenant's orders, print jobs, and shipments must be completely isolated
Configuration Isolation Each tenant has their own Shopify store, SimplyPrint account, Sendcloud credentials
Billing & Licensing Usage tracking, subscription tiers, feature gating
Tenant Onboarding Self-service signup with credential configuration
Scalability Support for 10s to 1000s of tenants with varying workloads
Compliance Data residency requirements (GDPR, tenant data export/deletion)
Customization Tenant-specific workflows, notification templates, branding

Multi-Tenancy Patterns

There are three primary patterns for multi-tenant architecture:

uml diagram

Pattern Cost Isolation Complexity Best For
Shared Everything $ Low Low SMB, low-security
Shared App, Isolated DB $$ Medium Medium Most SaaS
Fully Isolated $$$ High High Enterprise, regulated

How Multi-Tenancy Changes the Architecture

New Components Required

uml diagram

Key Architectural Changes

Aspect Single-Tenant (Current) Multi-Tenant SaaS
Authentication Single API key JWT with tenant claims, tenant admin roles
Database One database Schema-per-tenant or tenant_id column
External Credentials Environment variables Encrypted vault per tenant
Rate Limiting Global Per-tenant quotas
Event Subjects order.created tenant.{tenantId}.order.created
Webhook Routing Single endpoint Tenant-aware routing
Billing None Usage metering, subscription tiers
Onboarding Manual setup Self-service wizard

Revised Comparison: Monolith vs Microservices for Multi-Tenant

Criterion Monolith Microservices Winner for Multi-Tenant
Tenant Isolation Harder (shared memory) Easier (process isolation) 🏆 Microservices
Per-Tenant Scaling Impossible Native support 🏆 Microservices
Noisy Neighbor Prevention Very difficult Natural boundaries 🏆 Microservices
Credential Isolation Risk of leakage Service-scoped secrets 🏆 Microservices
Feature Flags per Tenant Complex Natural service versioning 🏆 Microservices
Compliance (data residency) Hard Deploy service in region 🏆 Microservices
Initial Development Speed Fast Slower 🏆 Monolith
Operational Complexity Lower Higher 🏆 Monolith
Infrastructure Cost Lower Higher 🏆 Monolith

Score: Microservices 6, Monolith 3 (reversed from single-tenant!)


Multi-Tenant Monolith: Is It Viable?

Yes, but with significant constraints:

// Every database query needs tenant context
@Injectable()
export class OrdersRepository {
  async findById(tenantId: string, orderId: string): Promise<Order> {
    return this.prisma.order.findFirst({
      where: {
        id: orderId,
        tenantId: tenantId, // CRITICAL: Never forget this!
      },
    });
  }
}

// Every external API call needs tenant credentials
@Injectable()
export class ShopifyService {
  async createFulfillment(tenantId: string, orderId: string) {
    const credentials = await this.credentialVault.get(tenantId, 'shopify');
    const client = new ShopifyClient(credentials);
    // ...
  }
}

Multi-Tenant Monolith Risks:

Risk Severity Mitigation Complexity
Cross-tenant data leakage (missing WHERE clause) Critical High (requires rigorous review)
Credential leakage between tenants Critical High (encryption, vault)
One tenant's load affecting others High Medium (rate limiting)
One tenant's bug crashing all tenants High Cannot fully mitigate
Compliance audit complexity Medium High (proving isolation)

Given the multi-tenancy requirements, the recommendation shifts:

Phase 1: Multi-Tenant Monolith (MVP)

For initial market validation (0-50 tenants):

uml diagram

Key additions to current architecture:

  1. Tenant table with credentials vault
  2. tenantId column on all existing tables
  3. TenantContextMiddleware that sets tenant for each request
  4. Prisma middleware to automatically filter by tenant
  5. NATS subjects prefixed with tenant ID
// Prisma middleware for automatic tenant filtering
prisma.$use(async (params, next) => {
  const tenantId = getTenantContext();

  if (params.action === 'findMany' || params.action === 'findFirst') {
    params.args.where = { ...params.args.where, tenantId };
  }

  if (params.action === 'create') {
    params.args.data = { ...params.args.data, tenantId };
  }

  return next(params);
});

Phase 2: Hybrid Architecture (Growth)

For scaling (50-500 tenants):

Extract the most isolation-critical services while keeping others in the monolith:

Service Extraction Priority Reason
Tenant & Billing High Security boundary, different scaling
Print Service High SimplyPrint credentials are sensitive
Shipping Service Medium Sendcloud credentials, different load pattern
Orders, Fulfillment Low Can remain in core monolith

uml diagram

Phase 3: Full Microservices (Scale)

For enterprise scale (500+ tenants):

Complete the migration as described in the earlier migration path, with these multi-tenant enhancements:

  1. Per-tenant message queues in NATS for complete isolation
  2. Tenant-aware API Gateway with quota enforcement
  3. Separate credential vaults per service
  4. Regional deployments for data residency compliance

Multi-Tenant Cost Projection

Tenants Architecture Monthly Infrastructure Cost (est.)
1-10 Multi-tenant Monolith $100-200
10-50 Multi-tenant Monolith $200-500
50-200 Hybrid (3-4 services) $500-1,500
200-500 Hybrid (5-6 services) $1,500-4,000
500+ Full Microservices $4,000-10,000+

Costs include: Compute, databases, NATS cluster, monitoring, secrets management


Updated Recommendation Summary

Scenario Recommended Architecture
Single-tenant (current) Modular Monolith (no change)
Multi-tenant MVP (1-50 tenants) Multi-tenant Monolith with tenant context
Multi-tenant Growth (50-500 tenants) Hybrid: Extract Print, Shipping, Tenant services
Multi-tenant Scale (500+ tenants) Full Microservices with NATS

Key Insight for Multi-Tenancy

"Multi-tenancy is the forcing function that often justifies microservices. The isolation, security, and per-tenant scaling requirements naturally align with service boundaries."

If Forma3D Connect pivots to a SaaS model:

  1. Start with multi-tenant monolith for market validation
  2. Plan for microservices from day one (maintain clean boundaries)
  3. Extract services progressively as tenant count and revenue justify the investment
  4. NATS becomes essential for tenant-scoped event routing and scaling

The current event-driven, modular architecture positions the codebase well for this evolution. The investment in clear domain boundaries pays off significantly when multi-tenancy enters the picture.


Multi-Tenant Security Checklist

Before going multi-tenant, implement these security controls:

Control Priority Implementation
Tenant context injection Critical Middleware on every request
Automatic query filtering Critical Prisma middleware
Credential encryption at rest Critical Vault (HashiCorp, AWS Secrets Manager)
Tenant isolation testing Critical Automated tests attempting cross-tenant access
Rate limiting per tenant High API Gateway with tenant quotas
Audit logging with tenant ID High All actions logged with tenant context
Tenant data export Medium GDPR compliance endpoint
Tenant data deletion Medium Cascade delete with verification
Tenant-scoped API keys Medium Separate keys per tenant
Cross-tenant penetration testing High Before production launch

Domain Boundary Evaluation

Status: Current State Analysis
Purpose: Evaluate how well the current codebase maintains domain boundaries and identify improvements for microservices-readiness.

As stated in the recommendation above, maintaining clear domain boundaries within the monolith is critical for a future microservices transition. This section evaluates the current boundaries and proposes improvements.


Evaluation Criteria

Criterion Description Ideal State
Repository Isolation Each domain accesses only its own repository No cross-domain repository access
Interface-Based Dependencies Domains depend on interfaces, not implementations All cross-domain calls through interfaces
Event-Driven Communication Domains communicate via events, not direct calls Events for state changes, no direct service calls
Data Ownership Each domain owns its data exclusively No shared tables across domains
Module Encapsulation Modules expose minimal public API Only services/DTOs exported, not repositories
Circular Dependencies No circular imports between domains Zero forwardRef() usage

Current State Assessment

1. Repository Access Across Domains

Status: ✅ RESOLVED (January 17, 2026)

Previously, several services directly accessed repositories from other domains. This has been resolved by introducing interface-based dependencies.

┌─────────────────────────────────────────────────────────────────────┐
│                      Cross-Domain Repository Access                 │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  OrchestrationService                                               │
│    ├── → OrdersRepository      (from orders domain)                 │
│    └── → PrintJobsRepository   (from print-jobs domain)             │
│                                                                     │
│  FulfillmentService                                                 │
│    └── → OrdersRepository      (from orders domain)                 │
│                                                                     │
│  SendcloudService                                                   │
│    ├── → OrdersRepository      (from orders domain)                 │
│    └── → ShipmentsRepository   (from shipments domain)              │
│                                                                     │
│  CancellationService                                                │
│    ├── → OrdersRepository      (from orders domain)                 │
│    └── → PrintJobsRepository   (from print-jobs domain)             │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Evidence from codebase:

// apps/api/src/orchestration/orchestration.service.ts
import { PrintJobsRepository } from '../print-jobs/print-jobs.repository';
import { OrdersRepository } from '../orders/orders.repository';

// apps/api/src/fulfillment/fulfillment.service.ts
import { OrdersRepository } from '../orders/orders.repository';

// apps/api/src/sendcloud/sendcloud.service.ts
import { ShipmentsRepository } from '../shipments/shipments.repository';
import { OrdersRepository } from '../orders/orders.repository';

// apps/api/src/cancellation/cancellation.service.ts
import { OrdersRepository } from '../orders/orders.repository';
import { PrintJobsRepository } from '../print-jobs/print-jobs.repository';

Impact: If we extract any service to a microservice, we must also extract all the repositories it depends on, or create API calls to fetch the data. This creates tight coupling.


2. Module Export Patterns

Status: ✅ RESOLVED (January 17, 2026)

Modules no longer export repositories directly. Only interface tokens are exported for cross-domain access:

// apps/api/src/orders/orders.module.ts
@Module({
  providers: [OrdersService, OrdersRepository],
  exports: [OrdersService, OrdersRepository],  // ❌ Repository exported
})

// apps/api/src/print-jobs/print-jobs.module.ts
@Module({
  providers: [PrintJobsService, PrintJobsRepository],
  exports: [PrintJobsService, PrintJobsRepository],  // ❌ Repository exported
})

Ideal Pattern:

@Module({
  providers: [OrdersService, OrdersRepository],
  exports: [OrdersService],  // ✅ Only service exported
})

3. Circular Dependencies

Status: ⚠️ PARTIAL (January 17, 2026)

Most forwardRef() usages have been eliminated through interface-based dependencies. Some remain for the RetryQueue pattern:

// apps/api/src/orchestration/orchestration.module.ts
imports: [forwardRef(() => PrintJobsModule), forwardRef(() => OrdersModule)];

// apps/api/src/sendcloud/sendcloud.module.ts
imports: [forwardRef(() => OrdersModule), forwardRef(() => RetryQueueModule)];

Circular Dependency Graph:

uml diagram


4. Event-Driven Communication

Status: GOOD

The codebase correctly uses events for cross-domain state changes:

// Orders → Orchestration (via event)
this.eventEmitter.emit(ORDER_EVENTS.CREATED, { orderId, ... });

// PrintJobs → Orchestration (via event)
this.eventEmitter.emit(PRINT_JOB_EVENTS.COMPLETED, { printJob, orderId });

// Orchestration → Fulfillment (via event)
this.eventEmitter.emit(ORCHESTRATION_EVENTS.ORDER_READY_FOR_FULFILLMENT, { orderId });

// Orchestration → Sendcloud (via event)
@OnEvent(ORCHESTRATION_EVENTS.ORDER_READY_FOR_FULFILLMENT)
async handleOrderReadyForFulfillment(event) { ... }

Positive patterns:

  • Events carry sufficient data for consumers
  • Events trigger downstream workflows
  • Event constants are centralized in domain folders

5. Data Ownership

Status: GOOD (with caveats)

Each domain has clear table ownership:

Domain Owned Tables Foreign Keys To
Orders Order, LineItem -
PrintJobs PrintJob LineItem (orders)
Shipments Shipment Order (orders)
ProductMappings ProductMapping, AssemblyPart -
EventLog EventLog Order (optional)
RetryQueue RetryQueue -

Concern: The PrintJobLineItem and ShipmentOrder foreign keys create data coupling. In a microservices world, these would need to be replaced with:

  • Denormalized copies of needed data (e.g., orderId, customerName)
  • API calls for data retrieval
  • Event-carried state transfer

6. Interface Boundaries

Status: ✅ RESOLVED (January 17, 2026)

Formal interfaces now exist between domains in libs/domain-contracts. Services depend on interfaces via injection tokens:

// Direct dependency on concrete class
constructor(
  private readonly ordersRepository: OrdersRepository,  // ❌ No interface
  private readonly printJobsService: PrintJobsService,  // ❌ No interface
) {}

Ideal Pattern:

// Interface in orders domain
export interface IOrdersService {
  findById(id: string): Promise<Order>;
  updateStatus(id: string, status: OrderStatus): Promise<Order>;
}

// Consumer depends on interface
constructor(
  @Inject('IOrdersService')
  private readonly ordersService: IOrdersService,  // ✅ Interface
) {}

Domain Boundary Scorecard

Domain Repository Isolation Event Usage Interface Boundary Circular Deps Score
Orders ✅ Self-contained ✅ Emits events ✅ IOrdersService ✅ Via interface 4/4
PrintJobs ✅ Self-contained ✅ Emits events ✅ IPrintJobsService ✅ Via interface 4/4
Orchestration ✅ Uses interfaces ✅ Emits & listens ✅ Uses interfaces ✅ No circular 4/4
Fulfillment ✅ Uses interfaces ✅ Listens to events ✅ IFulfillmentSvc ✅ No circular 4/4
Sendcloud ✅ Uses interfaces ✅ Emits events ✅ Uses interfaces ⚠️ forwardRef(RQ) 3.5/4
Cancellation ✅ Uses interfaces ✅ Listens to events ✅ Uses interfaces ✅ No circular 4/4
Shipments ✅ Self-contained ❌ No events ❌ No interface ✅ No circular 2/4
ProductMappings ✅ Self-contained ❌ No events ❌ No interface ✅ No circular 2/4

Overall Score: 27.5/32 (86%) ✅ (Updated January 17, 2026)


Improvement Recommendations

Priority 1: Stop Exporting Repositories

Effort: Low | Impact: High

Repositories should be internal implementation details. Only services should be exported.

// BEFORE: apps/api/src/orders/orders.module.ts
exports: [OrdersService, OrdersRepository],

// AFTER
exports: [OrdersService],  // Repository is internal

Action Items:

  1. Remove OrdersRepository from OrdersModule.exports
  2. Remove PrintJobsRepository from PrintJobsModule.exports
  3. Remove ShipmentsRepository from ShipmentsModule.exports
  4. Update all consumers to use services instead of repositories

Priority 2: Create Domain Interfaces

Effort: Medium | Impact: High

Define interfaces for cross-domain interactions:

// libs/domain-contracts/src/orders.interface.ts
export interface IOrdersService {
  findById(id: string): Promise<OrderDto | null>;
  findByShopifyOrderId(shopifyOrderId: string): Promise<OrderDto | null>;
  updateStatus(id: string, status: OrderStatus): Promise<OrderDto>;
  getOrdersForFulfillment(): Promise<OrderDto[]>;
}

// libs/domain-contracts/src/print-jobs.interface.ts
export interface IPrintJobsService {
  createPrintJobsForLineItem(lineItem: LineItemDto): Promise<PrintJobDto[]>;
  findByOrderId(orderId: string): Promise<PrintJobDto[]>;
  cancelJobsForOrder(orderId: string): Promise<void>;
}

New folder structure:

libs/
  domain-contracts/
    src/
      index.ts
      orders.interface.ts
      print-jobs.interface.ts
      fulfillment.interface.ts
      shipments.interface.ts

Priority 3: Replace Repository Access with Service Calls

Effort: High | Impact: Critical

Refactor services to use domain services instead of repositories:

// BEFORE: apps/api/src/orchestration/orchestration.service.ts
constructor(
  private readonly printJobsRepository: PrintJobsRepository,
  private readonly ordersRepository: OrdersRepository,
) {}

async handleOrderCreated(event: OrderCreatedEvent) {
  const order = await this.ordersRepository.findById(event.orderId);
  // ...
}

// AFTER
constructor(
  @Inject('IPrintJobsService')
  private readonly printJobsService: IPrintJobsService,
  @Inject('IOrdersService')
  private readonly ordersService: IOrdersService,
) {}

async handleOrderCreated(event: OrderCreatedEvent) {
  const order = await this.ordersService.findById(event.orderId);
  // ...
}

Priority 4: Break Circular Dependencies

Effort: Medium | Impact: Medium

Restructure to eliminate forwardRef():

uml diagram

Key changes:

  1. Remove direct module imports between coordination layer and core domain
  2. Use event subscriptions instead of direct service calls where possible
  3. For synchronous needs, use interfaces injected via tokens

Priority 5: Add Correlation IDs to Events

Effort: Low | Impact: Medium

Prepare for distributed tracing:

// BEFORE
interface OrderCreatedEvent {
  orderId: string;
  shopifyOrderId: string;
  lineItemCount: number;
}

// AFTER
interface OrderCreatedEvent {
  correlationId: string; // Add tracing ID
  orderId: string;
  shopifyOrderId: string;
  lineItemCount: number;
  timestamp: Date; // Add timestamp
}

// Generate in middleware
const correlationId = req.headers['x-correlation-id'] || uuid();

Priority 6: Consider Event Outbox Pattern

Effort: High | Impact: High (for future)

For reliable event publishing in a microservices future:

// Instead of direct emit
this.eventEmitter.emit(ORDER_EVENTS.CREATED, event);

// Use outbox pattern
await this.prisma.$transaction([
  // 1. Persist the business change
  prisma.order.create({ data: orderData }),
  // 2. Persist the event in outbox table
  prisma.outbox.create({
    data: {
      eventType: ORDER_EVENTS.CREATED,
      payload: JSON.stringify(event),
      status: 'PENDING',
    },
  }),
]);

// Separate process publishes events from outbox

This ensures events are never lost even if the app crashes after the database write.


Migration-Ready Architecture

After implementing the improvements, the architecture would look like:

uml diagram


Implementation Roadmap

Phase Tasks Effort Dependencies Status
Phase 0 Add correlation IDs to all events 2 days None ✅ Complete
Phase 1 Create domain-contracts lib with interfaces 3 days None ✅ Complete
Phase 2 Stop exporting repositories 1 week Phase 1 ✅ Complete
Phase 3 Refactor OrchestrationService to use interfaces 3 days Phase 2 ✅ Complete
Phase 4 Refactor FulfillmentService to use interfaces 2 days Phase 2 ✅ Complete
Phase 5 Refactor SendcloudService to use interfaces 2 days Phase 2 ✅ Complete
Phase 6 Refactor CancellationService to use interfaces 2 days Phase 2 ✅ Complete
Phase 7 Remove all forwardRef() usages 2 days Phases 3-6 ⚠️ Partial (RetryQueue remains)
Phase 8 Add event outbox pattern (optional) 1 week None ⏳ Pending

Completed: January 17, 2026 (Phases 0-6 complete, Phase 7 partial)


Summary

Aspect Previous State Current State (Jan 17, 2026)
Repository Access Cross-domain access common ✅ Only within owning module
Module Exports Repositories exported ✅ Only interface tokens
Interfaces None ✅ All cross-domain via interfaces
Circular Dependencies forwardRef() used ⚠️ Mostly resolved (RetryQueue remains)
Event Correlation No correlation IDs ✅ All events have correlation ID
Event Reliability Direct emit ⏳ Outbox pattern (optional, pending)

Key Insight (Updated January 17, 2026):

The codebase now has clean domain boundaries with interface-based dependencies, correlation ID propagation, and encapsulated repositories. This positions it well for future microservices extraction with minimal refactoring. The investment in boundary improvements has been completed, significantly improving code maintainability and reducing the effort required for potential future service decomposition.

References: - ADR-032: Domain Boundary Separation with Interface Contracts - Phase 5b in Implementation Plan