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¶
-
CorrelationMiddlewarecreated and registered -
CorrelationServicewith AsyncLocalStorage working -
BaseEventinterface created with correlationId - All event classes updated to include correlationId
- Correlation ID flows through HTTP request to events
Phase 1: Domain Contracts¶
-
libs/domain-contractslibrary created -
IOrdersServiceinterface defined -
IPrintJobsServiceinterface defined -
IShipmentsServiceinterface defined - DTO types defined for cross-domain communication
- Library builds successfully
Phase 2: Repository Isolation¶
-
OrdersRepositoryno longer exported fromOrdersModule -
PrintJobsRepositoryno longer exported fromPrintJobsModule -
ShipmentsRepositoryno longer exported fromShipmentsModule - Interface tokens registered as providers
- Services implement their interfaces
Phase 3: Interface-Based Dependencies¶
-
OrchestrationServiceusesIOrdersServiceandIPrintJobsService -
FulfillmentServiceusesIOrdersServiceandIShipmentsService -
SendcloudServiceusesIOrdersService -
CancellationServiceusesIOrdersServiceandIPrintJobsService - 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)¶
-
EventOutboxtable created -
OutboxServiceprocesses 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¶
- Phase 0: Add correlation IDs to events (2 days)
- Phase 1: Create domain-contracts library (3 days)
- Phase 2: Stop exporting repositories (1 week)
- Phase 3: Refactor services to use interfaces (3 days)
- Phase 4: Remove circular dependencies (2 days)
- Phase 5: Update module architecture (2 days)
- 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:
- Microservices Extraction: Any domain can be extracted to a separate service by:
- Replacing interface implementation with HTTP/gRPC client
-
No changes needed in consuming modules
-
NATS Integration: If message broker is needed:
- Events already have correlation IDs
- Outbox pattern ensures reliable delivery
-
Interface contracts define the API
-
Multi-Tenancy: If SaaS model is adopted:
- Tenant context can be added to correlation
-
Interfaces can be extended for tenant-aware queries
-
Testing Improvements:
- Interfaces make mocking trivial
- 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.