AI Prompt: Forma3D.Connect — Phase 5i: Domain Contract Cleanup¶
Purpose: This prompt instructs an AI to clean up domain contract usage and remove Prisma leakage
Estimated Effort: 2-3 days (~12-18 hours)
Prerequisites: Phase 5h completed (Controller Tests)
Output: Clean domain boundaries, no Prisma types in domain layer, proper service contracts
Status: 🟡 PENDING
🎯 Mission¶
You are continuing development of Forma3D.Connect, building on the Phase 5h foundation. Your task is to implement Phase 5i: Domain Contract Cleanup — specifically addressing TD-007 (Incomplete Domain Contract Usage) from the technical debt register.
Why This Matters:
The orchestration service mixes domain contracts with direct Prisma types, creating a leaky abstraction:
- Tight Prisma Coupling: Domain layer knows about database types
- Type Safety Loss:
as unknown ascasts bypass TypeScript - Refactoring Difficulty: Database changes ripple through domain
- Testing Complexity: Mocks require Prisma type knowledge
Phase 5i delivers:
- Clean domain contract interfaces
- Prisma types isolated to repository layer
- Type-safe service method signatures
- Proper boundary conversions
📋 Context: Technical Debt Item¶
TD-007: Incomplete Domain Contract Usage¶
| Attribute | Value |
|---|---|
| Type | Architecture Debt |
| Priority | High |
| Location | apps/api/src/orchestration/orchestration.service.ts:97-103 |
| Interest Rate | Medium |
| Principal (Effort) | 2-3 days |
Current State¶
// Current problematic code
const jobs = await this.printJobsService.createPrintJobsForLineItem({
id: lineItem.id,
// ...
status: lineItem.status as import('@prisma/client').LineItemStatus,
// Type casting required - leaky abstraction
unitPrice: 0 as unknown as import('@prisma/client/runtime/library').Decimal,
// Awkward type coercion
});
🛠️ Implementation Phases¶
Phase 1: Define Domain Enums (2 hours)¶
Priority: Critical | Impact: High | Dependencies: None
1. Create Domain Enums¶
Create libs/domain/src/enums/index.ts:
/**
* Order status in the domain layer.
* Mirrors Prisma OrderStatus but without database dependency.
*/
export enum OrderStatus {
PENDING = 'PENDING',
PROCESSING = 'PROCESSING',
PRINTING = 'PRINTING',
PRINTED = 'PRINTED',
SHIPPING = 'SHIPPING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
CANCELLED = 'CANCELLED',
}
/**
* Line item status in the domain layer.
*/
export enum LineItemStatus {
PENDING = 'PENDING',
PROCESSING = 'PROCESSING',
PRINTING = 'PRINTING',
PRINTED = 'PRINTED',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
CANCELLED = 'CANCELLED',
}
/**
* Print job status in the domain layer.
*/
export enum PrintJobStatus {
PENDING = 'PENDING',
QUEUED = 'QUEUED',
PRINTING = 'PRINTING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
CANCELLED = 'CANCELLED',
}
/**
* Shipment status in the domain layer.
*/
export enum ShipmentStatus {
PENDING = 'PENDING',
LABEL_CREATED = 'LABEL_CREATED',
SHIPPED = 'SHIPPED',
IN_TRANSIT = 'IN_TRANSIT',
DELIVERED = 'DELIVERED',
FAILED = 'FAILED',
RETURNED = 'RETURNED',
}
/**
* Event severity levels.
*/
export enum EventSeverity {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARNING = 'WARNING',
ERROR = 'ERROR',
}
Update libs/domain/src/index.ts:
export * from './enums';
export * from './schemas';
Phase 2: Create Domain Entity Interfaces (3 hours)¶
Priority: High | Impact: High | Dependencies: Phase 1
1. Create Domain Entities¶
Create libs/domain/src/entities/index.ts:
import { OrderStatus, LineItemStatus, PrintJobStatus, ShipmentStatus } from '../enums';
import { ShippingAddress, PrintProfile, EventMetadata } from '../schemas';
/**
* Domain entity for Order.
* Independent of Prisma types.
*/
export interface Order {
id: string;
shopifyOrderId: string;
shopifyOrderNumber: string;
status: OrderStatus;
customerName: string;
customerEmail: string;
shippingAddress: ShippingAddress | null;
totalPrice: number;
currency: string;
totalParts: number;
completedParts: number;
notes: string | null;
createdAt: Date;
updatedAt: Date;
completedAt: Date | null;
}
/**
* Domain entity for LineItem.
*/
export interface LineItem {
id: string;
orderId: string;
shopifyLineItemId: string;
productMappingId: string | null;
sku: string;
productName: string;
quantity: number;
unitPrice: number;
totalPrice: number;
status: LineItemStatus;
requiresPrinting: boolean;
totalParts: number;
completedParts: number;
createdAt: Date;
updatedAt: Date;
}
/**
* Domain entity for PrintJob.
*/
export interface PrintJob {
id: string;
lineItemId: string;
assemblyPartId: string | null;
simplyPrintJobId: string | null;
status: PrintJobStatus;
copyNumber: number;
printerId: string | null;
printerName: string | null;
fileId: string | null;
fileName: string | null;
queuedAt: Date | null;
startedAt: Date | null;
completedAt: Date | null;
estimatedDuration: number | null;
actualDuration: number | null;
progress: number;
errorMessage: string | null;
retryCount: number;
maxRetries: number;
createdAt: Date;
updatedAt: Date;
}
/**
* Domain entity for ProductMapping.
*/
export interface ProductMapping {
id: string;
shopifyProductId: string;
shopifyVariantId: string | null;
sku: string;
productName: string;
description: string | null;
isAssembly: boolean;
isActive: boolean;
modelFileId: string | null;
modelFileName: string | null;
defaultPrintProfile: PrintProfile | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Domain entity for AssemblyPart.
*/
export interface AssemblyPart {
id: string;
productMappingId: string;
name: string;
modelFileId: string;
modelFileName: string;
quantity: number;
printProfile: PrintProfile | null;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
}
/**
* Domain entity for Shipment.
*/
export interface Shipment {
id: string;
orderId: string;
sendcloudParcelId: string | null;
status: ShipmentStatus;
carrier: string | null;
trackingNumber: string | null;
trackingUrl: string | null;
labelUrl: string | null;
weight: number | null;
shippedAt: Date | null;
deliveredAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Domain entity for EventLog.
*/
export interface EventLog {
id: string;
orderId: string | null;
printJobId: string | null;
eventType: string;
severity: string;
message: string;
metadata: EventMetadata | null;
createdAt: Date;
}
Update libs/domain/src/index.ts:
export * from './enums';
export * from './entities';
export * from './schemas';
Phase 3: Create Type Converters (2 hours)¶
Priority: High | Impact: High | Dependencies: Phase 2
1. Create Prisma-to-Domain Converters¶
Create apps/api/src/database/converters/index.ts:
import { Prisma } from '@prisma/client';
import {
Order as DomainOrder,
LineItem as DomainLineItem,
PrintJob as DomainPrintJob,
ProductMapping as DomainProductMapping,
AssemblyPart as DomainAssemblyPart,
Shipment as DomainShipment,
OrderStatus,
LineItemStatus,
PrintJobStatus,
ShipmentStatus,
} from '@forma3d/domain';
import {
Order as PrismaOrder,
LineItem as PrismaLineItem,
PrintJob as PrismaPrintJob,
ProductMapping as PrismaProductMapping,
AssemblyPart as PrismaAssemblyPart,
Shipment as PrismaShipment,
} from '@prisma/client';
import { transformShippingAddress, transformPrintProfile } from '../json-transformers';
/**
* Convert Prisma Decimal to number.
*/
function decimalToNumber(decimal: Prisma.Decimal | null): number {
if (decimal === null) return 0;
return decimal.toNumber();
}
/**
* Convert Prisma Order to Domain Order.
*/
export function toDomainOrder(prismaOrder: PrismaOrder): DomainOrder {
return {
id: prismaOrder.id,
shopifyOrderId: prismaOrder.shopifyOrderId,
shopifyOrderNumber: prismaOrder.shopifyOrderNumber,
status: prismaOrder.status as OrderStatus,
customerName: prismaOrder.customerName,
customerEmail: prismaOrder.customerEmail,
shippingAddress: transformShippingAddress(prismaOrder.shippingAddress),
totalPrice: decimalToNumber(prismaOrder.totalPrice),
currency: prismaOrder.currency,
totalParts: prismaOrder.totalParts,
completedParts: prismaOrder.completedParts,
notes: prismaOrder.notes,
createdAt: prismaOrder.createdAt,
updatedAt: prismaOrder.updatedAt,
completedAt: prismaOrder.completedAt,
};
}
/**
* Convert Prisma LineItem to Domain LineItem.
*/
export function toDomainLineItem(prismaLineItem: PrismaLineItem): DomainLineItem {
return {
id: prismaLineItem.id,
orderId: prismaLineItem.orderId,
shopifyLineItemId: prismaLineItem.shopifyLineItemId,
productMappingId: prismaLineItem.productMappingId,
sku: prismaLineItem.sku,
productName: prismaLineItem.productName,
quantity: prismaLineItem.quantity,
unitPrice: decimalToNumber(prismaLineItem.unitPrice),
totalPrice: decimalToNumber(prismaLineItem.totalPrice),
status: prismaLineItem.status as LineItemStatus,
requiresPrinting: prismaLineItem.requiresPrinting,
totalParts: prismaLineItem.totalParts,
completedParts: prismaLineItem.completedParts,
createdAt: prismaLineItem.createdAt,
updatedAt: prismaLineItem.updatedAt,
};
}
/**
* Convert Prisma PrintJob to Domain PrintJob.
*/
export function toDomainPrintJob(prismaPrintJob: PrismaPrintJob): DomainPrintJob {
return {
id: prismaPrintJob.id,
lineItemId: prismaPrintJob.lineItemId,
assemblyPartId: prismaPrintJob.assemblyPartId,
simplyPrintJobId: prismaPrintJob.simplyPrintJobId,
status: prismaPrintJob.status as PrintJobStatus,
copyNumber: prismaPrintJob.copyNumber,
printerId: prismaPrintJob.printerId,
printerName: prismaPrintJob.printerName,
fileId: prismaPrintJob.fileId,
fileName: prismaPrintJob.fileName,
queuedAt: prismaPrintJob.queuedAt,
startedAt: prismaPrintJob.startedAt,
completedAt: prismaPrintJob.completedAt,
estimatedDuration: prismaPrintJob.estimatedDuration,
actualDuration: prismaPrintJob.actualDuration,
progress: prismaPrintJob.progress,
errorMessage: prismaPrintJob.errorMessage,
retryCount: prismaPrintJob.retryCount,
maxRetries: prismaPrintJob.maxRetries,
createdAt: prismaPrintJob.createdAt,
updatedAt: prismaPrintJob.updatedAt,
};
}
/**
* Convert Prisma ProductMapping to Domain ProductMapping.
*/
export function toDomainProductMapping(
prismaMapping: PrismaProductMapping,
): DomainProductMapping {
return {
id: prismaMapping.id,
shopifyProductId: prismaMapping.shopifyProductId,
shopifyVariantId: prismaMapping.shopifyVariantId,
sku: prismaMapping.sku,
productName: prismaMapping.productName,
description: prismaMapping.description,
isAssembly: prismaMapping.isAssembly,
isActive: prismaMapping.isActive,
modelFileId: prismaMapping.modelFileId,
modelFileName: prismaMapping.modelFileName,
defaultPrintProfile: transformPrintProfile(prismaMapping.defaultPrintProfile),
createdAt: prismaMapping.createdAt,
updatedAt: prismaMapping.updatedAt,
};
}
/**
* Convert Prisma AssemblyPart to Domain AssemblyPart.
*/
export function toDomainAssemblyPart(
prismaPart: PrismaAssemblyPart,
): DomainAssemblyPart {
return {
id: prismaPart.id,
productMappingId: prismaPart.productMappingId,
name: prismaPart.name,
modelFileId: prismaPart.modelFileId,
modelFileName: prismaPart.modelFileName,
quantity: prismaPart.quantity,
printProfile: transformPrintProfile(prismaPart.printProfile),
sortOrder: prismaPart.sortOrder,
createdAt: prismaPart.createdAt,
updatedAt: prismaPart.updatedAt,
};
}
/**
* Convert Prisma Shipment to Domain Shipment.
*/
export function toDomainShipment(prismaShipment: PrismaShipment): DomainShipment {
return {
id: prismaShipment.id,
orderId: prismaShipment.orderId,
sendcloudParcelId: prismaShipment.sendcloudParcelId,
status: prismaShipment.status as ShipmentStatus,
carrier: prismaShipment.carrier,
trackingNumber: prismaShipment.trackingNumber,
trackingUrl: prismaShipment.trackingUrl,
labelUrl: prismaShipment.labelUrl,
weight: prismaShipment.weight ? decimalToNumber(prismaShipment.weight) : null,
shippedAt: prismaShipment.shippedAt,
deliveredAt: prismaShipment.deliveredAt,
createdAt: prismaShipment.createdAt,
updatedAt: prismaShipment.updatedAt,
};
}
Phase 4: Update Service Contracts (4 hours)¶
Priority: High | Impact: High | Dependencies: Phase 3
1. Create Service Input/Output Types¶
Create libs/domain-contracts/src/services/print-jobs.service.contract.ts:
import { LineItem, PrintJob, AssemblyPart, PrintProfile } from '@forma3d/domain';
/**
* Input for creating print jobs from a line item.
*/
export interface CreatePrintJobsInput {
lineItem: LineItem;
assemblyParts: AssemblyPart[];
defaultPrintProfile: PrintProfile | null;
}
/**
* Output from creating print jobs.
*/
export interface CreatePrintJobsOutput {
jobs: PrintJob[];
totalJobs: number;
}
/**
* Contract for PrintJobsService.
*/
export interface IPrintJobsService {
createPrintJobsForLineItem(input: CreatePrintJobsInput): Promise<CreatePrintJobsOutput>;
findByLineItemId(lineItemId: string): Promise<PrintJob[]>;
updateStatus(jobId: string, status: string): Promise<PrintJob>;
retry(jobId: string): Promise<PrintJob>;
cancel(jobId: string, reason?: string): Promise<PrintJob>;
}
2. Update Orchestration Service¶
Update apps/api/src/orchestration/orchestration.service.ts:
import {
toDomainLineItem,
toDomainAssemblyPart,
} from '../database/converters';
import { CreatePrintJobsInput } from '@forma3d/domain-contracts';
// Replace the problematic code:
// Before (with Prisma type leakage):
// const jobs = await this.printJobsService.createPrintJobsForLineItem({
// ...lineItem,
// status: lineItem.status as import('@prisma/client').LineItemStatus,
// unitPrice: 0 as unknown as import('@prisma/client/runtime/library').Decimal,
// });
// After (with domain types):
const domainLineItem = toDomainLineItem(lineItem);
const domainAssemblyParts = assemblyParts.map(toDomainAssemblyPart);
const input: CreatePrintJobsInput = {
lineItem: domainLineItem,
assemblyParts: domainAssemblyParts,
defaultPrintProfile: productMapping.defaultPrintProfile,
};
const { jobs } = await this.printJobsService.createPrintJobsForLineItem(input);
Phase 5: Update Repositories (4 hours)¶
Priority: High | Impact: High | Dependencies: Phase 3
1. Update Repository Return Types¶
Repositories should return domain entities, not Prisma types:
// apps/api/src/orders/orders.repository.ts
import { Order as DomainOrder } from '@forma3d/domain';
import { toDomainOrder } from '../database/converters';
@Injectable()
export class OrdersRepository {
constructor(private readonly prisma: PrismaService) {}
async findOne(id: string): Promise<DomainOrder | null> {
const order = await this.prisma.order.findUnique({
where: { id },
});
return order ? toDomainOrder(order) : null;
}
async findAll(params: OrderQueryParams): Promise<{
orders: DomainOrder[];
total: number;
}> {
const [orders, total] = await Promise.all([
this.prisma.order.findMany({
where: this.buildWhereClause(params),
skip: (params.page - 1) * params.pageSize,
take: params.pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.order.count({
where: this.buildWhereClause(params),
}),
]);
return {
orders: orders.map(toDomainOrder),
total,
};
}
}
Phase 6: Documentation Updates (30 minutes)¶
Priority: Medium | Impact: Medium | Dependencies: Phase 5
1. Update Technical Debt Register¶
Update docs/04-development/techdebt/technical-debt-register.md:
### ~~TD-007: Incomplete Domain Contract Usage~~ ✅ RESOLVED
**Type:** Architecture Debt
**Status:** ✅ **Resolved in Phase 5i**
**Resolution Date:** 2026-XX-XX
#### Resolution
Implemented clean domain boundaries with proper type conversions:
- **Domain Enums**: OrderStatus, LineItemStatus, PrintJobStatus, ShipmentStatus
- **Domain Entities**: Order, LineItem, PrintJob, ProductMapping, Shipment
- **Type Converters**: Prisma-to-Domain conversion functions
- **Service Contracts**: Typed input/output interfaces
**Key Changes:**
- Removed all `as unknown as` type casts from service layer
- Prisma types isolated to repository layer
- Services work exclusively with domain types
- Clean boundary at repository layer
**Files Created:**
- `libs/domain/src/enums/index.ts`
- `libs/domain/src/entities/index.ts`
- `apps/api/src/database/converters/index.ts`
- `libs/domain-contracts/src/services/*.contract.ts`
📁 Files to Create/Modify¶
New Files¶
libs/domain/src/enums/index.ts
libs/domain/src/entities/index.ts
apps/api/src/database/converters/index.ts
libs/domain-contracts/src/services/print-jobs.service.contract.ts
libs/domain-contracts/src/services/orders.service.contract.ts
libs/domain-contracts/src/services/index.ts
Modified Files¶
libs/domain/src/index.ts
libs/domain-contracts/src/index.ts
libs/domain-contracts/src/lib/types.ts # Remove Prisma imports
apps/api/src/orchestration/orchestration.service.ts
apps/api/src/orders/orders.repository.ts
apps/api/src/print-jobs/print-jobs.repository.ts
apps/api/src/print-jobs/print-jobs.service.ts
docs/04-development/techdebt/technical-debt-register.md
✅ Validation Checklist¶
Phase 1-2: Domain Types¶
- Domain enums created (OrderStatus, LineItemStatus, etc.)
- Domain entities created (Order, LineItem, PrintJob, etc.)
- Types exported from libs/domain
Phase 3: Converters¶
- Prisma-to-Domain converters created
- Decimal conversion handled
- JSON field transformation integrated
Phase 4-5: Service Updates¶
- Service contracts defined
- Orchestration service uses domain types
- No Prisma imports in domain layer
- No
as unknown ascasts
Final Verification¶
# Verify no Prisma imports in domain
grep -r "@prisma/client" libs/domain/ # Should return nothing
# All tests pass
pnpm nx test api
# Build passes
pnpm nx build api
pnpm nx build domain
pnpm nx build domain-contracts
🚫 Constraints and Rules¶
MUST DO¶
- Create domain enums mirroring Prisma enums
- Create domain entities with plain TypeScript types
- Convert at repository boundaries
- Use domain types in services
MUST NOT¶
- Import
@prisma/clientin libs/domain - Use
as unknown ascasts - Leak Prisma types through service interfaces
- Skip conversion for related entities
END OF PROMPT
This prompt resolves TD-007 from the technical debt register by creating clean domain boundaries and removing Prisma type leakage.