Skip to content

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:

  1. Tight Prisma Coupling: Domain layer knows about database types
  2. Type Safety Loss: as unknown as casts bypass TypeScript
  3. Refactoring Difficulty: Database changes ripple through domain
  4. 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 as casts

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/client in libs/domain
  • Use as unknown as casts
  • 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.