Skip to content

AI Prompt: Forma3D.Connect — Phase 5b: Domain Boundary Separation

Purpose: This prompt instructs an AI to cleanly separate domain boundaries within the modular monolith
Estimated Effort: 3-4 weeks (~15-20 hours)
Prerequisites: Phase 5 completed (Shipping Integration)
Output: Clean domain boundaries, interfaces between modules, no cross-domain repository access
Status: 🟡 PENDING


🎯 Mission

You are continuing development of Forma3D.Connect, building on the Phase 5 foundation. Your task is to implement Phase 5b: Domain Boundary Separation — refactoring the codebase to establish clean domain boundaries that prepare the monolith for potential future microservices extraction.

Why This Matters:

The current codebase has a solid foundation with event-driven architecture and clear domain separation at the conceptual level. However, the implementation leaks abstractions through:

  • Direct cross-domain repository access
  • Missing interface boundaries between modules
  • Circular dependencies requiring forwardRef()
  • Repositories exported from modules

Phase 5b delivers:

  • Interface-based dependencies between domains
  • Repositories as internal implementation details (not exported)
  • Zero forwardRef() usage
  • Correlation IDs on all events
  • Clean module boundaries ready for future microservices extraction

📋 Context: Current State Assessment

Domain Boundary Scorecard (Current)

Domain Repository Isolation Event Usage Interface Boundary Circular Deps Score
Orders ✅ Self-contained ✅ Emits events ❌ No interface ❌ Consumed by many 2/4
PrintJobs ✅ Self-contained ✅ Emits events ❌ No interface ❌ Consumed by orchestration 2/4
Orchestration ❌ Uses 2 repos ✅ Emits & listens ❌ No interface ⚠️ forwardRef ¼
Fulfillment ❌ Uses orders repo ✅ Listens to events ❌ No interface ✅ No circular 1.5/4
Sendcloud ❌ Uses 2 repos ✅ Emits events ❌ No interface ⚠️ forwardRef ¼
Cancellation ❌ Uses 2 repos ✅ Listens to events ❌ No interface ✅ No circular 1.5/4
Shipments ✅ Self-contained ❌ No events ❌ No interface ✅ No circular 1.5/4
ProductMappings ✅ Self-contained ❌ No events ❌ No interface ✅ No circular 2/4

Overall Score: 12.5/32 (39%)

Target Score: 28+/32 (87%+)

Cross-Domain Repository Access Violations

┌─────────────────────────────────────────────────────────────────────┐
│                      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)             │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

🛠️ Implementation Phases

Phase 0: Add Correlation IDs to Events (2 days)

Priority: Medium | Impact: Medium | Dependencies: None

Prepare for distributed tracing by adding correlation IDs to all events.

1. Create Correlation ID Middleware

Create apps/api/src/common/middleware/correlation.middleware.ts:

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';

export const CORRELATION_ID_HEADER = 'x-correlation-id';

@Injectable()
export class CorrelationMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction): void {
    const correlationId = req.headers[CORRELATION_ID_HEADER] as string || randomUUID();

    // Attach to request for downstream use
    req['correlationId'] = correlationId;

    // Add to response headers
    res.setHeader(CORRELATION_ID_HEADER, correlationId);

    next();
  }
}

2. Create Correlation Context Service

Create apps/api/src/common/correlation/correlation.service.ts:

import { Injectable, Scope } from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';

interface CorrelationContext {
  correlationId: string;
}

@Injectable({ scope: Scope.DEFAULT })
export class CorrelationService {
  private readonly storage = new AsyncLocalStorage<CorrelationContext>();

  runWithContext<T>(correlationId: string, fn: () => T): T {
    return this.storage.run({ correlationId }, fn);
  }

  getCorrelationId(): string | undefined {
    return this.storage.getStore()?.correlationId;
  }
}

3. Update Base Event Interface

Create libs/domain/src/events/base-event.interface.ts:

export interface BaseEvent {
  /**
   * Unique identifier for tracing related events
   */
  correlationId: string;

  /**
   * Timestamp when the event was created
   */
  timestamp: Date;

  /**
   * Source module that emitted the event
   */
  source: string;
}

export interface OrderEvent extends BaseEvent {
  orderId: string;
}

export interface PrintJobEvent extends BaseEvent {
  printJobId: string;
  orderId: string;
}

export interface ShipmentEvent extends BaseEvent {
  shipmentId?: string;
  orderId: string;
}

4. Update Existing Event Classes

Update all event classes to extend BaseEvent with correlation ID support.

Files to Update: - apps/api/src/orders/events/order.events.ts - apps/api/src/print-jobs/events/print-job.events.ts - apps/api/src/orchestration/events/orchestration.events.ts - apps/api/src/sendcloud/events/shipment.events.ts - apps/api/src/fulfillment/events/fulfillment.events.ts

Example Update:

// BEFORE
export class OrderCreatedEvent {
  constructor(
    public readonly orderId: string,
    public readonly shopifyOrderId: string,
    public readonly lineItemCount: number,
  ) {}
}

// AFTER
import { OrderEvent } from '@forma3d/domain';

export class OrderCreatedEvent implements OrderEvent {
  constructor(
    public readonly correlationId: string,
    public readonly timestamp: Date,
    public readonly source: string,
    public readonly orderId: string,
    public readonly shopifyOrderId: string,
    public readonly lineItemCount: number,
  ) {}

  static create(
    correlationId: string,
    orderId: string,
    shopifyOrderId: string,
    lineItemCount: number,
  ): OrderCreatedEvent {
    return new OrderCreatedEvent(
      correlationId,
      new Date(),
      'orders',
      orderId,
      shopifyOrderId,
      lineItemCount,
    );
  }
}

Phase 1: Create Domain Contracts Library (3 days)

Priority: High | Impact: High | Dependencies: None

Create a shared library defining interfaces for cross-domain interactions.

1. Generate Domain Contracts Library

pnpm nx g @nx/js:lib domain-contracts --directory=libs/domain-contracts --buildable

2. Create Interface Definitions

Create libs/domain-contracts/src/index.ts:

// Orders Domain
export * from './lib/orders.interface';

// PrintJobs Domain
export * from './lib/print-jobs.interface';

// Shipments Domain
export * from './lib/shipments.interface';

// Fulfillment Domain
export * from './lib/fulfillment.interface';

// Shared Types
export * from './lib/types';

Create libs/domain-contracts/src/lib/types.ts:

import { OrderStatus, PrintJobStatus, ShipmentStatus } from '@prisma/client';

/**
 * Lightweight Order DTO for cross-domain communication
 */
export interface OrderDto {
  id: string;
  shopifyOrderId: string;
  shopifyOrderNumber: string;
  status: OrderStatus;
  customerName: string;
  customerEmail: string | null;
  shippingAddress: unknown;
  createdAt: Date;
  updatedAt: Date;
}

/**
 * Lightweight LineItem DTO for cross-domain communication
 */
export interface LineItemDto {
  id: string;
  orderId: string;
  sku: string;
  productName: string;
  quantity: number;
  status: string;
}

/**
 * Lightweight PrintJob DTO for cross-domain communication
 */
export interface PrintJobDto {
  id: string;
  lineItemId: string;
  orderId: string;
  status: PrintJobStatus;
  externalPrintJobId: string | null;
  createdAt: Date;
  updatedAt: Date;
}

/**
 * Lightweight Shipment DTO for cross-domain communication
 */
export interface ShipmentDto {
  id: string;
  orderId: string;
  status: ShipmentStatus;
  trackingNumber: string | null;
  trackingUrl: string | null;
  labelUrl: string | null;
  carrierName: string | null;
  createdAt: Date;
}

Create libs/domain-contracts/src/lib/orders.interface.ts:

import { OrderDto, LineItemDto } from './types';
import { OrderStatus } from '@prisma/client';

/**
 * Interface for Orders domain service
 * Used by other domains to interact with order data
 */
export interface IOrdersService {
  /**
   * Find an order by its internal ID
   */
  findById(id: string): Promise<OrderDto | null>;

  /**
   * Find an order by Shopify order ID
   */
  findByShopifyOrderId(shopifyOrderId: string): Promise<OrderDto | null>;

  /**
   * Update order status
   */
  updateStatus(id: string, status: OrderStatus): Promise<OrderDto>;

  /**
   * Get orders ready for fulfillment
   */
  getOrdersReadyForFulfillment(): Promise<OrderDto[]>;

  /**
   * Get line items for an order
   */
  getLineItems(orderId: string): Promise<LineItemDto[]>;

  /**
   * Update order tracking information
   */
  updateTracking(
    id: string,
    trackingNumber: string,
    trackingUrl: string | null,
  ): Promise<OrderDto>;
}

/**
 * Injection token for IOrdersService
 */
export const ORDERS_SERVICE = Symbol('IOrdersService');

Create libs/domain-contracts/src/lib/print-jobs.interface.ts:

import { PrintJobDto } from './types';
import { PrintJobStatus } from '@prisma/client';

/**
 * Interface for PrintJobs domain service
 * Used by other domains to interact with print job data
 */
export interface IPrintJobsService {
  /**
   * Find print jobs by order ID
   */
  findByOrderId(orderId: string): Promise<PrintJobDto[]>;

  /**
   * Find print job by line item ID
   */
  findByLineItemId(lineItemId: string): Promise<PrintJobDto[]>;

  /**
   * Check if all print jobs for an order are complete
   */
  areAllJobsComplete(orderId: string): Promise<boolean>;

  /**
   * Cancel all print jobs for an order
   */
  cancelJobsForOrder(orderId: string): Promise<void>;

  /**
   * Get pending print jobs count
   */
  getPendingJobsCount(): Promise<number>;
}

/**
 * Injection token for IPrintJobsService
 */
export const PRINT_JOBS_SERVICE = Symbol('IPrintJobsService');

Create libs/domain-contracts/src/lib/shipments.interface.ts:

import { ShipmentDto } from './types';
import { ShipmentStatus } from '@prisma/client';

/**
 * Interface for Shipments domain service
 * Used by other domains to interact with shipment data
 */
export interface IShipmentsService {
  /**
   * Find shipment by order ID
   */
  findByOrderId(orderId: string): Promise<ShipmentDto | null>;

  /**
   * Update shipment status
   */
  updateStatus(id: string, status: ShipmentStatus): Promise<ShipmentDto>;

  /**
   * Check if order has a shipment
   */
  hasShipment(orderId: string): Promise<boolean>;
}

/**
 * Injection token for IShipmentsService
 */
export const SHIPMENTS_SERVICE = Symbol('IShipmentsService');

Create libs/domain-contracts/src/lib/fulfillment.interface.ts:

/**
 * Interface for Fulfillment domain service
 */
export interface IFulfillmentService {
  /**
   * Create fulfillment for an order
   */
  createFulfillment(orderId: string): Promise<void>;

  /**
   * Check if order is fulfilled
   */
  isFulfilled(orderId: string): Promise<boolean>;
}

/**
 * Injection token for IFulfillmentService
 */
export const FULFILLMENT_SERVICE = Symbol('IFulfillmentService');

Phase 2: Stop Exporting Repositories (1 week)

Priority: High | Impact: Critical | Dependencies: Phase 1

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

1. Update Module Exports

Files to Update:

Module File Current Exports New Exports
apps/api/src/orders/orders.module.ts OrdersService, OrdersRepository OrdersService
apps/api/src/print-jobs/print-jobs.module.ts PrintJobsService, PrintJobsRepository PrintJobsService
apps/api/src/shipments/shipments.module.ts ShipmentsService, ShipmentsRepository ShipmentsService

Example Change:

// BEFORE: apps/api/src/orders/orders.module.ts
@Module({
  imports: [DatabaseModule, EventLogModule],
  controllers: [OrdersController],
  providers: [OrdersService, OrdersRepository],
  exports: [OrdersService, OrdersRepository],  // ❌ Repository exported
})
export class OrdersModule {}

// AFTER
@Module({
  imports: [DatabaseModule, EventLogModule],
  controllers: [OrdersController],
  providers: [
    OrdersService,
    OrdersRepository,
    {
      provide: ORDERS_SERVICE,
      useExisting: OrdersService,
    },
  ],
  exports: [
    OrdersService,
    ORDERS_SERVICE,  // ✅ Export interface token
  ],
})
export class OrdersModule {}

2. Implement Interfaces in Services

Update each service to implement its interface:

Update apps/api/src/orders/orders.service.ts:

import { IOrdersService, OrderDto, LineItemDto, ORDERS_SERVICE } from '@forma3d/domain-contracts';

@Injectable()
export class OrdersService implements IOrdersService {
  // ... existing implementation

  // Add interface methods with proper return types
  async findById(id: string): Promise<OrderDto | null> {
    const order = await this.ordersRepository.findById(id);
    return order ? this.toOrderDto(order) : null;
  }

  private toOrderDto(order: Order): OrderDto {
    return {
      id: order.id,
      shopifyOrderId: order.shopifyOrderId,
      shopifyOrderNumber: order.shopifyOrderNumber,
      status: order.status,
      customerName: order.customerName,
      customerEmail: order.customerEmail,
      shippingAddress: order.shippingAddress,
      createdAt: order.createdAt,
      updatedAt: order.updatedAt,
    };
  }

  async getLineItems(orderId: string): Promise<LineItemDto[]> {
    const lineItems = await this.ordersRepository.findLineItemsByOrderId(orderId);
    return lineItems.map(this.toLineItemDto);
  }

  private toLineItemDto(lineItem: LineItem): LineItemDto {
    return {
      id: lineItem.id,
      orderId: lineItem.orderId,
      sku: lineItem.sku,
      productName: lineItem.productName,
      quantity: lineItem.quantity,
      status: lineItem.status,
    };
  }
}

Repeat for: - apps/api/src/print-jobs/print-jobs.service.ts → implements IPrintJobsService - apps/api/src/shipments/shipments.service.ts → implements IShipmentsService


Phase 3: Refactor Services to Use Interfaces (3 days)

Priority: Critical | Impact: Critical | Dependencies: Phase 2

Replace all direct repository access with interface-based service calls.

1. Refactor OrchestrationService

Current violations:

import { PrintJobsRepository } from '../print-jobs/print-jobs.repository';
import { OrdersRepository } from '../orders/orders.repository';

Refactored:

// apps/api/src/orchestration/orchestration.service.ts
import { Injectable, Inject, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
  IOrdersService,
  IPrintJobsService,
  ORDERS_SERVICE,
  PRINT_JOBS_SERVICE,
} from '@forma3d/domain-contracts';

@Injectable()
export class OrchestrationService {
  private readonly logger = new Logger(OrchestrationService.name);

  constructor(
    @Inject(ORDERS_SERVICE)
    private readonly ordersService: IOrdersService,
    @Inject(PRINT_JOBS_SERVICE)
    private readonly printJobsService: IPrintJobsService,
    private readonly eventEmitter: EventEmitter2,
  ) {}

  async checkOrderReadiness(orderId: string): Promise<boolean> {
    // Use interface methods instead of direct repository access
    const allComplete = await this.printJobsService.areAllJobsComplete(orderId);

    if (allComplete) {
      const order = await this.ordersService.findById(orderId);
      if (order) {
        this.eventEmitter.emit(
          ORCHESTRATION_EVENTS.ORDER_READY_FOR_FULFILLMENT,
          OrderReadyForFulfillmentEvent.create(
            this.correlationService.getCorrelationId() || randomUUID(),
            order,
          ),
        );
      }
    }

    return allComplete;
  }
}

2. Refactor FulfillmentService

Current violations:

import { OrdersRepository } from '../orders/orders.repository';

Refactored:

// apps/api/src/fulfillment/fulfillment.service.ts
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
  IOrdersService,
  IShipmentsService,
  ORDERS_SERVICE,
  SHIPMENTS_SERVICE,
} from '@forma3d/domain-contracts';

@Injectable()
export class FulfillmentService implements IFulfillmentService {
  constructor(
    @Inject(ORDERS_SERVICE)
    private readonly ordersService: IOrdersService,
    @Inject(SHIPMENTS_SERVICE)
    private readonly shipmentsService: IShipmentsService,
    private readonly shopifyService: ShopifyService,
    private readonly eventLogService: EventLogService,
  ) {}

  async createFulfillment(orderId: string): Promise<void> {
    const order = await this.ordersService.findById(orderId);
    if (!order) {
      throw new NotFoundException(`Order not found: ${orderId}`);
    }

    const shipment = await this.shipmentsService.findByOrderId(orderId);

    // ... rest of fulfillment logic
  }
}

3. Refactor SendcloudService

Current violations:

import { ShipmentsRepository } from '../shipments/shipments.repository';
import { OrdersRepository } from '../orders/orders.repository';

Refactored:

// apps/api/src/sendcloud/sendcloud.service.ts
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
  IOrdersService,
  ORDERS_SERVICE,
} from '@forma3d/domain-contracts';
import { ShipmentsRepository } from '../shipments/shipments.repository';

@Injectable()
export class SendcloudService {
  constructor(
    @Inject(ORDERS_SERVICE)
    private readonly ordersService: IOrdersService,
    // NOTE: ShipmentsRepository is OK here because Sendcloud is in the shipments domain
    private readonly shipmentsRepository: ShipmentsRepository,
    private readonly sendcloudClient: SendcloudApiClient,
  ) {}

  async createShipment(orderId: string, shippingMethodId?: number): Promise<Shipment> {
    const order = await this.ordersService.findById(orderId);
    if (!order) {
      throw new NotFoundException(`Order not found: ${orderId}`);
    }

    // ... rest of shipment creation logic
  }
}

4. Refactor CancellationService

Current violations:

import { OrdersRepository } from '../orders/orders.repository';
import { PrintJobsRepository } from '../print-jobs/print-jobs.repository';

Refactored:

// apps/api/src/cancellation/cancellation.service.ts
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
  IOrdersService,
  IPrintJobsService,
  ORDERS_SERVICE,
  PRINT_JOBS_SERVICE,
} from '@forma3d/domain-contracts';

@Injectable()
export class CancellationService {
  constructor(
    @Inject(ORDERS_SERVICE)
    private readonly ordersService: IOrdersService,
    @Inject(PRINT_JOBS_SERVICE)
    private readonly printJobsService: IPrintJobsService,
    private readonly simplyPrintService: SimplyPrintService,
    private readonly eventLogService: EventLogService,
  ) {}

  async cancelOrder(orderId: string): Promise<void> {
    const order = await this.ordersService.findById(orderId);
    if (!order) {
      throw new NotFoundException(`Order not found: ${orderId}`);
    }

    // Cancel print jobs via interface
    await this.printJobsService.cancelJobsForOrder(orderId);

    // Update order status via interface
    await this.ordersService.updateStatus(orderId, OrderStatus.CANCELLED);

    // ... rest of cancellation logic
  }
}

Phase 4: Remove Circular Dependencies (2 days)

Priority: Medium | Impact: Medium | Dependencies: Phase 3

Eliminate all forwardRef() usage by restructuring module dependencies.

1. Identify Current forwardRef Usage

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

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

2. Restructure Module Imports

Updated OrchestrationModule:

// apps/api/src/orchestration/orchestration.module.ts
import { Module } from '@nestjs/common';
import { OrdersModule } from '../orders/orders.module';
import { PrintJobsModule } from '../print-jobs/print-jobs.module';
import { OrchestrationService } from './orchestration.service';

@Module({
  imports: [
    OrdersModule,      // ✅ No forwardRef - uses interface
    PrintJobsModule,   // ✅ No forwardRef - uses interface
    EventLogModule,
  ],
  providers: [OrchestrationService],
  exports: [OrchestrationService],
})
export class OrchestrationModule {}

Updated SendcloudModule:

// apps/api/src/sendcloud/sendcloud.module.ts
import { Module } from '@nestjs/common';
import { OrdersModule } from '../orders/orders.module';
import { ShipmentsModule } from '../shipments/shipments.module';
import { SendcloudService } from './sendcloud.service';
import { SendcloudApiClient } from './sendcloud-api.client';
import { SendcloudController } from './sendcloud.controller';

@Module({
  imports: [
    OrdersModule,     // ✅ No forwardRef - uses interface
    ShipmentsModule,
    EventLogModule,
    RetryQueueModule,
  ],
  controllers: [SendcloudController],
  providers: [SendcloudService, SendcloudApiClient],
  exports: [SendcloudService, SendcloudApiClient],
})
export class SendcloudModule {}

3. Verify Zero forwardRef Usage

Create a verification script or manually search:

# Should return zero results
rg "forwardRef" apps/api/src/

Phase 5: Update Module Architecture (2 days)

Priority: Medium | Impact: Medium | Dependencies: Phase 4

Organize modules into clear architectural layers.

1. Module Layer Structure

┌─────────────────────────────────────────────────────────────────┐
│                        API Layer                                 │
│   Controllers, API Gateway                                       │
├─────────────────────────────────────────────────────────────────┤
│                    Coordination Layer                            │
│   OrchestrationModule, RetryQueueModule                          │
├─────────────────────────────────────────────────────────────────┤
│                    Integration Layer                             │
│   FulfillmentModule, SendcloudModule, SimplyPrintModule          │
├─────────────────────────────────────────────────────────────────┤
│                      Core Domain Layer                           │
│   OrdersModule, PrintJobsModule, ShipmentsModule                 │
├─────────────────────────────────────────────────────────────────┤
│                      Cross-Cutting                               │
│   EventLogModule, NotificationsModule, EventBus                  │
├─────────────────────────────────────────────────────────────────┤
│                      Infrastructure                              │
│   DatabaseModule, ConfigModule                                   │
└─────────────────────────────────────────────────────────────────┘

2. Dependency Rules

Layer Can Import From
API All layers below
Coordination Integration, Core Domain, Cross-Cutting
Integration Core Domain (via interfaces), Cross-Cutting
Core Domain Cross-Cutting, Infrastructure
Cross-Cutting Infrastructure only
Infrastructure Nothing (foundation layer)

3. Update AppModule Registration Order

// apps/api/src/app/app.module.ts
@Module({
  imports: [
    // Infrastructure
    ConfigModule.forRoot({ isGlobal: true }),
    DatabaseModule,

    // Cross-Cutting
    EventEmitterModule.forRoot(),
    EventLogModule,
    NotificationsModule,

    // Core Domain
    OrdersModule,
    PrintJobsModule,
    ShipmentsModule,
    ProductMappingsModule,

    // Integration
    ShopifyModule,
    SimplyPrintModule,
    SendcloudModule,
    FulfillmentModule,

    // Coordination
    OrchestrationModule,
    RetryQueueModule,
    CancellationModule,

    // API
    GatewayModule,
    HealthModule,
    TestSeedingModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

Phase 6: Event Outbox Pattern (Optional - 1 week)

Priority: Low | Impact: High (for future) | Dependencies: None

For guaranteed event delivery in a future microservices architecture.

Note: This phase is optional for the current modular monolith but recommended if you plan to migrate to microservices.

1. Create Outbox Table

Add to prisma/schema.prisma:

model EventOutbox {
  id            String   @id @default(uuid())
  eventType     String
  aggregateId   String   // e.g., orderId
  aggregateType String   // e.g., "Order"
  payload       Json
  correlationId String
  status        OutboxStatus @default(PENDING)
  createdAt     DateTime @default(now())
  processedAt   DateTime?
  retryCount    Int      @default(0)
  lastError     String?

  @@index([status, createdAt])
  @@index([aggregateId])
}

enum OutboxStatus {
  PENDING
  PROCESSED
  FAILED
}

2. Create Outbox Service

// apps/api/src/common/outbox/outbox.service.ts
@Injectable()
export class OutboxService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly eventEmitter: EventEmitter2,
  ) {}

  /**
   * Store event in outbox within transaction
   */
  async storeEvent<T>(
    tx: Prisma.TransactionClient,
    eventType: string,
    aggregateId: string,
    aggregateType: string,
    payload: T,
    correlationId: string,
  ): Promise<void> {
    await tx.eventOutbox.create({
      data: {
        eventType,
        aggregateId,
        aggregateType,
        payload: payload as Prisma.JsonObject,
        correlationId,
      },
    });
  }

  /**
   * Process pending events (call from scheduled job)
   */
  @Cron('*/5 * * * * *') // Every 5 seconds
  async processOutbox(): Promise<void> {
    const events = await this.prisma.eventOutbox.findMany({
      where: { status: 'PENDING' },
      orderBy: { createdAt: 'asc' },
      take: 100,
    });

    for (const event of events) {
      try {
        this.eventEmitter.emit(event.eventType, event.payload);

        await this.prisma.eventOutbox.update({
          where: { id: event.id },
          data: { status: 'PROCESSED', processedAt: new Date() },
        });
      } catch (error) {
        await this.prisma.eventOutbox.update({
          where: { id: event.id },
          data: {
            retryCount: { increment: 1 },
            lastError: error.message,
            status: event.retryCount >= 3 ? 'FAILED' : 'PENDING',
          },
        });
      }
    }
  }
}

3. Update Services to Use Outbox

// Example: OrdersService with outbox
async createOrder(data: CreateOrderDto): Promise<Order> {
  return this.prisma.$transaction(async (tx) => {
    // 1. Create order
    const order = await tx.order.create({ data: /* ... */ });

    // 2. Store event in outbox (same transaction)
    await this.outboxService.storeEvent(
      tx,
      ORDER_EVENTS.CREATED,
      order.id,
      'Order',
      OrderCreatedEvent.create(
        this.correlationService.getCorrelationId() || randomUUID(),
        order.id,
        order.shopifyOrderId,
        data.lineItems.length,
      ),
      this.correlationService.getCorrelationId() || randomUUID(),
    );

    return order;
  });
}

📁 Files to Create/Modify

New Files

libs/
  domain-contracts/
    src/
      index.ts
      lib/
        orders.interface.ts
        print-jobs.interface.ts
        shipments.interface.ts
        fulfillment.interface.ts
        types.ts
    project.json
    tsconfig.json
    jest.config.ts

apps/api/src/
  common/
    middleware/
      correlation.middleware.ts
    correlation/
      correlation.service.ts
      correlation.module.ts
    outbox/                        # Optional
      outbox.service.ts
      outbox.module.ts

libs/domain/src/
  events/
    base-event.interface.ts

Modified Files

apps/api/src/
  app/
    app.module.ts                  # Register correlation middleware

  orders/
    orders.module.ts               # Remove repository export, add interface provider
    orders.service.ts              # Implement IOrdersService
    events/order.events.ts         # Add correlation ID

  print-jobs/
    print-jobs.module.ts           # Remove repository export, add interface provider
    print-jobs.service.ts          # Implement IPrintJobsService
    events/print-job.events.ts     # Add correlation ID

  shipments/
    shipments.module.ts            # Remove repository export, add interface provider
    shipments.service.ts           # Implement IShipmentsService

  orchestration/
    orchestration.module.ts        # Remove forwardRef
    orchestration.service.ts       # Use interfaces instead of repositories
    events/orchestration.events.ts # Add correlation ID

  fulfillment/
    fulfillment.module.ts          # Update imports
    fulfillment.service.ts         # Use interfaces instead of repositories

  sendcloud/
    sendcloud.module.ts            # Remove forwardRef
    sendcloud.service.ts           # Use IOrdersService interface
    events/shipment.events.ts      # Add correlation ID

  cancellation/
    cancellation.module.ts         # Update imports
    cancellation.service.ts        # Use interfaces instead of repositories

prisma/
  schema.prisma                    # Add EventOutbox model (optional)

🧪 Testing Requirements

Unit Test Updates

All existing unit tests must be updated to use interface mocks instead of repository mocks:

// BEFORE: Mocking repository
const mockOrdersRepository = {
  findById: jest.fn(),
  update: jest.fn(),
};

// AFTER: Mocking interface
const mockOrdersService: jest.Mocked<IOrdersService> = {
  findById: jest.fn(),
  findByShopifyOrderId: jest.fn(),
  updateStatus: jest.fn(),
  getOrdersReadyForFulfillment: jest.fn(),
  getLineItems: jest.fn(),
  updateTracking: jest.fn(),
};

// In test setup
const module = await Test.createTestingModule({
  providers: [
    OrchestrationService,
    { provide: ORDERS_SERVICE, useValue: mockOrdersService },
    { provide: PRINT_JOBS_SERVICE, useValue: mockPrintJobsService },
  ],
}).compile();

New Test Scenarios

Category Scenario Priority
Domain Contracts Interface DTOs correctly map entities High
Correlation Correlation ID flows through event chain High
Module Isolation No cross-domain repository access Critical
Event Flow Events contain required correlation metadata High
Outbox (if implemented) Events stored and processed correctly Medium

Integration Test Verification

After refactoring, verify the full event flow still works:

Order Created → Print Jobs Created → Print Complete → Shipment Created → Fulfillment

✅ Validation Checklist

Phase 0: Correlation IDs

  • CorrelationMiddleware created and registered
  • CorrelationService with AsyncLocalStorage working
  • BaseEvent interface created with correlationId
  • All event classes updated to include correlationId
  • Correlation ID flows through HTTP request to events

Phase 1: Domain Contracts

  • libs/domain-contracts library created
  • IOrdersService interface defined
  • IPrintJobsService interface defined
  • IShipmentsService interface defined
  • DTO types defined for cross-domain communication
  • Library builds successfully

Phase 2: Repository Isolation

  • OrdersRepository no longer exported from OrdersModule
  • PrintJobsRepository no longer exported from PrintJobsModule
  • ShipmentsRepository no longer exported from ShipmentsModule
  • Interface tokens registered as providers
  • Services implement their interfaces

Phase 3: Interface-Based Dependencies

  • OrchestrationService uses IOrdersService and IPrintJobsService
  • FulfillmentService uses IOrdersService and IShipmentsService
  • SendcloudService uses IOrdersService
  • CancellationService uses IOrdersService and IPrintJobsService
  • No direct repository imports from other domains

Phase 4: Circular Dependencies

  • Zero forwardRef() usage in codebase
  • All modules compile without circular dependency errors
  • Module import order is correct

Phase 5: Architecture Verification

  • Modules organized by architectural layer
  • Dependency rules enforced
  • AppModule imports in correct order
  • All tests passing

Phase 6: Event Outbox (Optional)

  • EventOutbox table created
  • OutboxService processes pending events
  • Services use outbox for critical events
  • Tests verify outbox processing

Final Verification

# No forwardRef usage
rg "forwardRef" apps/api/src/ # Should return 0 results

# No cross-domain repository imports
rg "from '\.\./orders/orders\.repository'" apps/api/src/ --glob '!orders/**'
rg "from '\.\./print-jobs/print-jobs\.repository'" apps/api/src/ --glob '!print-jobs/**'
rg "from '\.\./shipments/shipments\.repository'" apps/api/src/ --glob '!shipments/**'

# Build succeeds
pnpm nx build api
pnpm nx build domain-contracts

# Tests pass
pnpm nx test api
pnpm nx test domain-contracts

🚫 Constraints and Rules

MUST DO

  • Use @Inject() with Symbol tokens for interface dependencies
  • Create DTOs for all cross-domain data transfer
  • Add correlation IDs to all events
  • Update all unit tests to use interface mocks
  • Maintain backward compatibility (no breaking API changes)
  • Keep all existing functionality working

MUST NOT

  • Export repositories from modules
  • Use forwardRef() for circular dependencies
  • Import repositories directly from other domains
  • Skip unit test updates
  • Remove or break existing event subscriptions
  • Change public API contracts

📊 Success Metrics

Domain Boundary Scorecard Target

Domain Repository Isolation Event Usage Interface Boundary Circular Deps Target Score
Orders ✅ Self-contained ✅ Emits events ✅ Has interface ✅ No circular 4/4
PrintJobs ✅ Self-contained ✅ Emits events ✅ Has interface ✅ No circular 4/4
Orchestration ✅ Uses interfaces ✅ Emits & listens ✅ Has interface ✅ No circular 4/4
Fulfillment ✅ Uses interfaces ✅ Listens to events ✅ Has interface ✅ No circular 4/4
Sendcloud ✅ Uses interfaces ✅ Emits events ✅ N/A ✅ No circular 3/3
Cancellation ✅ Uses interfaces ✅ Listens to events ✅ N/A ✅ No circular 3/3
Shipments ✅ Self-contained ✅ Has events ✅ Has interface ✅ No circular 4/4
ProductMappings ✅ Self-contained ✅ Has events ✅ N/A ✅ No circular 3/3

Target Overall Score: 29/31 (93%+)


📝 Documentation Updates

Required Updates

Document Updates Required
README.md Add domain architecture section
docs/03-architecture/adr/ADR.md Add ADR-031: Domain Boundary Separation
docs/04-development/implementation-plan.md Mark Phase 5b as complete
docs/03-architecture/events/microservices-brainstorm.md Update scorecard with new results

ADR Template for Phase 5b

## ADR-031: Domain Boundary Separation

| Attribute   | Value                                           |
| ----------- | ----------------------------------------------- |
| **ID**      | ADR-031                                         |
| **Status**  | Accepted                                        |
| **Date**    | 2026-XX-XX                                      |
| **Context** | Prepare modular monolith for potential microservices extraction |

### Decision

Introduce interface-based dependencies between domain modules:

1. Create `libs/domain-contracts` with interfaces for all domain services
2. Stop exporting repositories from modules
3. Use Symbol tokens for dependency injection
4. Add correlation IDs to all events

### Rationale

- Clean boundaries make future microservices extraction straightforward
- Interface-based dependencies improve testability
- Correlation IDs enable distributed tracing when needed
- Repository encapsulation prevents tight coupling

### Consequences

- ✅ Clear module boundaries
- ✅ Better testability with interface mocks
- ✅ Ready for microservices extraction
- ✅ Correlation IDs enable tracing
- ⚠️ Additional abstraction layer
- ⚠️ Need to maintain interface/implementation sync

🎬 Execution Order

  1. Phase 0: Add correlation IDs to events (2 days)
  2. Phase 1: Create domain-contracts library (3 days)
  3. Phase 2: Stop exporting repositories (1 week)
  4. Phase 3: Refactor services to use interfaces (3 days)
  5. Phase 4: Remove circular dependencies (2 days)
  6. Phase 5: Update module architecture (2 days)
  7. Phase 6: Event outbox pattern (optional, 1 week)

Per-Phase Validation

After each phase: - Run all unit tests - Run all integration tests - Verify application starts correctly - Verify full event flow works


🔮 Future Benefits

Once Phase 5b is complete, the codebase will be prepared for:

  1. Microservices Extraction: Any domain can be extracted to a separate service by:
  2. Replacing interface implementation with HTTP/gRPC client
  3. No changes needed in consuming modules

  4. NATS Integration: If message broker is needed:

  5. Events already have correlation IDs
  6. Outbox pattern ensures reliable delivery
  7. Interface contracts define the API

  8. Multi-Tenancy: If SaaS model is adopted:

  9. Tenant context can be added to correlation
  10. Interfaces can be extended for tenant-aware queries

  11. Testing Improvements:

  12. Interfaces make mocking trivial
  13. Integration tests can use in-memory implementations

END OF PROMPT


This prompt focuses on internal architecture improvements without changing external behavior. All existing tests should continue to pass after refactoring. The goal is to improve maintainability and prepare for potential future microservices extraction.