Skip to content

Architectural & Design Patterns Catalog

This document catalogs all architectural and design patterns employed in the Forma 3D Connect codebase. It explains the rationale behind each pattern choice and demonstrates how they work together to create a maintainable, scalable, and robust system.


Table of Contents

  1. System Overview
  2. Architectural Patterns
  3. Layered Architecture
  4. Microservices Architecture
  5. Event-Driven Architecture
  6. CQRS-Lite
  7. Structural Design Patterns
  8. Repository Pattern
  9. Service Layer Pattern
  10. Gateway Pattern
  11. Anti-Corruption Layer
  12. Behavioral Design Patterns
  13. Observer Pattern (Event Emitter)
  14. State Machine Pattern
  15. Strategy Pattern
  16. Command Pattern
  17. Integration Patterns
  18. Webhook Handler Pattern
  19. Retry with Exponential Backoff
  20. Circuit Breaker (Implicit)
  21. Idempotency Pattern
  22. Resilience Patterns
  23. Dead Letter Queue (Retry Queue)
  24. Saga Pattern (Orchestration)
  25. Correlation ID Pattern
  26. Frontend Patterns
  27. Container/Presentational Components
  28. Custom Hooks Pattern
  29. Context Provider Pattern
  30. Optimistic Updates
  31. Cross-Cutting Patterns
  32. Dependency Injection
  33. Interface Segregation
  34. DTO Pattern
  35. How Patterns Work Together
  36. Pattern Decision Matrix

System Overview

Forma 3D Connect is a print-on-demand order orchestration platform that bridges Shopify e-commerce, 3D printing (SimplyPrint), and shipping (SendCloud). The system is deployed as domain-aligned microservices communicating via BullMQ event queues (Redis) and internal HTTP APIs. The architecture must handle:

  • Asynchronous workflows: Orders flow through multiple stages over hours/days
  • External system integration: Multiple external APIs with different reliability characteristics
  • Real-time updates: Operators need live dashboards
  • Fault tolerance: Network failures and API outages are expected
  • Multi-tenancy: Each tenant has isolated credentials and data context

uml diagram


Architectural Patterns

Layered Architecture

Pattern: Organize code into horizontal layers with strict dependency rules.

Implementation: The codebase uses a 4-layer architecture:

uml diagram

Rationale:

  • Testability: Each layer can be tested in isolation with mocked dependencies
  • Maintainability: Changes to external APIs don't ripple to business logic
  • Clear boundaries: Import rules prevent architectural erosion

Code Example (from apps/order-service/src/orders/orders.service.ts):

@Injectable()
export class OrdersService implements IOrdersService {
  constructor(
    private readonly ordersRepository: OrdersRepository,  // Infrastructure
    private readonly eventEmitter: EventEmitter2,         // Cross-cutting
    private readonly eventLogService: EventLogService,    // Application
  ) {}

  // Service coordinates between layers
  async createFromShopify(input: CreateOrderInput): Promise<Order> {
    const order = await this.ordersRepository.create(input);  // Infrastructure
    this.eventEmitter.emit(ORDER_EVENTS.CREATED, ...);        // Application
    return order;
  }
}

Microservices Architecture

Pattern: Decompose the system into domain-aligned services that communicate via event queues and internal HTTP APIs.

Implementation: Each domain runs as an independent NestJS application, routed through an API Gateway:

uml diagram

Gateway Routing (from apps/gateway/src/routing/route-config.ts):

export const ROUTE_TARGETS: RouteTarget[] = [
  // Webhooks — bypass session auth, routed to respective services
  { pathPrefix: '/api/v1/webhooks/shopify', serviceUrlEnvKey: 'ORDER_SERVICE_URL', isPublic: true },
  { pathPrefix: '/webhooks/simplyprint', serviceUrlEnvKey: 'PRINT_SERVICE_URL', isPublic: true },
  { pathPrefix: '/webhooks/sendcloud', serviceUrlEnvKey: 'SHIPPING_SERVICE_URL', isPublic: true },

  // Domain routes
  { pathPrefix: '/api/v1/orders', serviceUrlEnvKey: 'ORDER_SERVICE_URL', isPublic: false },
  { pathPrefix: '/api/v1/print-jobs', serviceUrlEnvKey: 'PRINT_SERVICE_URL', isPublic: false },
  { pathPrefix: '/api/v1/shipments', serviceUrlEnvKey: 'SHIPPING_SERVICE_URL', isPublic: false },
  { pathPrefix: '/api/v1/gridflock', serviceUrlEnvKey: 'GRIDFLOCK_SERVICE_URL', isPublic: false },
  // ... additional routes
];

Service Module Boundaries (from apps/order-service/src/app/app.module.ts):

@Module({
  imports: [
    // Infrastructure & Cross-Cutting
    EventEmitterModule.forRoot(),
    ScheduleModule.forRoot(),
    DatabaseModule,
    CorrelationModule,

    // BullMQ Event Bus (cross-service communication)
    EventBusModule.forRootAsync({
      useFactory: (configService: ConfigService) => ({
        redisUrl: configService.get<string>('REDIS_URL') || 'redis://localhost:6379',
      }),
      inject: [ConfigService],
    }),

    // Service clients (for calling other microservices)
    InternalAuthModule,
    ServiceClientModule,

    // Auth & Tenancy (user context from gateway headers)
    SharedAuthModule.forRoot(),
    TenancyModule,

    // Core Domain
    OrdersModule,
    PrintJobsModule,
    OrchestrationModule,
    FulfillmentModule,
    CancellationModule,
    InventoryModule,

    // BullMQ Event Subscribers
    EventsModule,
  ],
})
export class AppModule {}

Rationale:

  • Fault isolation: SimplyPrint API down? Only Print Service is affected
  • Independent scaling: Print job processing can scale separately from order intake
  • Domain ownership: Each service owns its domain logic, events, and external integrations
  • Bounded contexts: Services communicate via well-defined event contracts and shared interfaces

Event-Driven Architecture

Pattern: Services communicate through events rather than direct calls, using a dual-layer event system.

Implementation: Two event layers work together:

  1. BullMQ (cross-service): Redis-backed queues for reliable inter-service communication with retry, persistence, and exactly-once delivery
  2. EventEmitter2 (intra-service): In-process events for local domain coordination within a single service

Bridge services (EventPublisherService / EventSubscriberService) connect the two layers:

uml diagram

Cross-Service Event Types (from libs/service-common/src/lib/events/event-types.ts):

export const SERVICE_EVENTS = {
  // Order Service publishes
  ORDER_CREATED: 'order.created',
  ORDER_READY_FOR_FULFILLMENT: 'order.ready-for-fulfillment',
  ORDER_CANCELLED: 'order.cancelled',

  // Print Service publishes
  PRINT_JOB_COMPLETED: 'print-job.completed',
  PRINT_JOB_FAILED: 'print-job.failed',
  PRINT_JOB_STATUS_CHANGED: 'print-job.status-changed',
  PRINT_JOB_CANCELLED: 'print-job.cancelled',

  // Shipping Service publishes
  SHIPMENT_CREATED: 'shipment.created',
  SHIPMENT_STATUS_CHANGED: 'shipment.status-changed',

  // GridFlock Service publishes
  GRIDFLOCK_MAPPING_READY: 'gridflock.mapping-ready',
  GRIDFLOCK_PIPELINE_FAILED: 'gridflock.pipeline-failed',
} as const;

export interface ServiceEvent {
  eventId: string;        // UUID v4 — enables idempotency checks
  eventType: string;      // Queue name (e.g. 'order.created')
  source: string;         // Source service (e.g. 'order-service')
  tenantId: string;       // Multi-tenancy context
  timestamp: string;      // ISO 8601
  correlationId?: string; // Distributed tracing
}

Event Bridge — Publisher (from apps/order-service/src/events/event-publisher.service.ts):

@Injectable()
export class EventPublisherService {
  constructor(@Inject(EVENT_BUS) private readonly eventBus: IEventBus) {}

  @OnEvent(ORDER_EVENTS.CREATED)
  async onOrderCreated(payload: OrderCreatedPayload): Promise<void> {
    const event: OrderCreatedEvent = {
      eventId: randomUUID(),
      eventType: SERVICE_EVENTS.ORDER_CREATED,
      source: 'order-service',
      tenantId: payload.tenantId,
      orderId: payload.orderId,
      lineItems: payload.lineItems || [],
      timestamp: new Date().toISOString(),
      correlationId: payload.correlationId,
    };
    await this.eventBus.publish(event);
  }
}

Rationale:

  • Loose coupling: Services don't need to know about all consumers
  • Reliability: BullMQ provides retry (3 attempts, exponential backoff), persistence, and dead-letter handling
  • Exactly-once processing: BullMQ workers ensure no duplicate event processing across replicas
  • Auditability: Events carry eventId, correlationId, and tenantId for full traceability
  • Real-time updates: WebSocket gateway subscribes to same local events as domain handlers

CQRS-Lite

Pattern: Separate read and write paths without full CQRS complexity.

Implementation: - Writes go through services with full validation and events - Reads can bypass services for simple queries

uml diagram

Rationale:

  • Performance: Read operations skip unnecessary business logic
  • Simplicity: Not a full CQRS with separate read models, but gains most benefits
  • Evolution path: Can add read replicas or materialized views later

Structural Design Patterns

Repository Pattern

Pattern: Abstract data access behind a domain-specific interface.

Implementation (from apps/order-service/src/orders/orders.repository.ts):

@Injectable()
export class OrdersRepository {
  constructor(private readonly prisma: PrismaService) {}

  async create(input: CreateOrderInput): Promise<OrderWithLineItems> {
    return this.prisma.order.create({
      data: { ... },
      include: { lineItems: true },
    });
  }

  async findById(id: string): Promise<OrderWithLineItems | null> {
    return this.prisma.order.findUnique({
      where: { id },
      include: { lineItems: true },
    });
  }

  async updateStatus(id: string, status: OrderStatus): Promise<Order> {
    return this.prisma.order.update({
      where: { id },
      data: { status },
    });
  }
}

uml diagram

Rationale:

  • Testability: Services can be tested with mock repositories
  • Abstraction: Business logic doesn't know about Prisma
  • Query encapsulation: Complex queries live in one place

Service Layer Pattern

Pattern: Encapsulate business logic in services that coordinate between components.

Implementation: Services are the "brain" of each module:

uml diagram

Key Responsibilities:

@Injectable()
export class OrdersService {
  // 1. Business rule enforcement
  private validateStatusTransition(from: OrderStatus, to: OrderStatus): void {
    const validTransitions: Record<OrderStatus, OrderStatus[]> = {
      [OrderStatus.PENDING]: [OrderStatus.PROCESSING, OrderStatus.CANCELLED],
      // ... 
    };
    if (!validTransitions[from].includes(to)) {
      throw new ConflictException(`Invalid transition from ${from} to ${to}`);
    }
  }

  // 2. Coordination of operations
  async updateStatus(id: string, newStatus: OrderStatus): Promise<Order> {
    const order = await this.findById(id);
    this.validateStatusTransition(order.status, newStatus);
    await this.ordersRepository.updateStatus(id, newStatus);
    await this.eventLogService.log({ ... });
    this.eventEmitter.emit(ORDER_EVENTS.STATUS_CHANGED, ...);
    return this.findById(id);
  }
}

Gateway Pattern

Pattern: Single entry point for external API communication with consistent error handling.

Implementation (from apps/order-service/src/shopify/shopify-api.client.ts):

@Injectable()
export class ShopifyApiClient {
  private async request<T>(
    method: 'GET' | 'POST' | 'PUT' | 'DELETE',
    endpoint: string,
    body?: unknown
  ): Promise<T> {
    // Rate limiting handling
    if (response.status === 429) {
      const retryAfter = response.headers.get('Retry-After');
      await this.delay(parseInt(retryAfter) * 1000);
      return this.request<T>(method, endpoint, body);  // Retry
    }

    // Error normalization
    if (!response.ok) {
      throw new Error(`Shopify API error: ${response.status}`);
    }

    return response.json();
  }

  async createFulfillment(orderId: string, data: FulfillmentInput) {
    return this.request<FulfillmentResponse>(
      'POST',
      `/orders/${orderId}/fulfillments.json`,
      { fulfillment: data }
    );
  }
}

uml diagram


Anti-Corruption Layer

Pattern: Translate between external system models and internal domain models.

Implementation: DTOs and mapping methods isolate external data shapes:

uml diagram

Example (from apps/order-service/src/shopify/shopify.service.ts):

// External Shopify format
interface ShopifyWebhookPayload {
  id: number;                    // External uses number
  order_number: string;          // snake_case
  line_items: Array<{
    sku: string | null;
    product_id: number;
    variant_id: number;
    quantity: number;
  }>;
}

// Internal domain format
interface CreateOrderInput {
  shopifyOrderId: string;        // String for our IDs
  shopifyOrderNumber: string;    // camelCase
  lineItems: CreateLineItemInput[];
}

// Mapping in ShopifyService
async handleOrderCreated(payload: ShopifyWebhookPayload): Promise<void> {
  const input: CreateOrderInput = {
    shopifyOrderId: String(payload.id),
    shopifyOrderNumber: payload.order_number,
    lineItems: payload.line_items.map(li => ({
      shopifyLineItemId: String(li.id),
      shopifyProductId: String(li.product_id),
      shopifyVariantId: String(li.variant_id),
      productSku: li.sku ?? '',
      quantity: li.quantity,
    })),
  };
  await this.ordersService.createFromShopify(input);
}

Behavioral Design Patterns

Observer Pattern (Event Emitter)

Pattern: Objects subscribe to events without knowing the publisher.

Implementation: Two observer layers — local (EventEmitter2) and cross-service (BullMQ):

uml diagram

Gateway as Observer (from apps/order-service/src/gateway/events.gateway.ts):

@WebSocketGateway({ namespace: '/events' })
export class EventsGateway {
  @WebSocketServer()
  server!: Server;

  @OnEvent(ORDER_EVENTS.CREATED)
  handleOrderCreated(event: OrderEventPayload): void {
    this.server.emit('order:created', {
      id: event.orderId,
      orderNumber: event.shopifyOrderNumber,
    });
  }

  @OnEvent(PRINT_JOB_EVENTS.STATUS_CHANGED)
  handlePrintJobStatusChanged(event: PrintJobEventPayload): void {
    this.server.emit('printjob:updated', {
      id: event.printJob.id,
      status: event.newStatus,
    });
  }
}

State Machine Pattern

Pattern: Enforce valid state transitions for entities with complex lifecycles.

Implementation: Order and PrintJob status transitions are validated:

uml diagram

Validation Logic (from apps/order-service/src/orders/orders.service.ts):

private validateStatusTransition(from: OrderStatus, to: OrderStatus): void {
  const validTransitions: Record<OrderStatus, OrderStatus[]> = {
    [OrderStatus.PENDING]: [
      OrderStatus.PROCESSING,
      OrderStatus.CANCELLED,
      OrderStatus.FAILED,
    ],
    [OrderStatus.PROCESSING]: [
      OrderStatus.PARTIALLY_COMPLETED,
      OrderStatus.COMPLETED,
      OrderStatus.FAILED,
      OrderStatus.CANCELLED,
    ],
    [OrderStatus.PARTIALLY_COMPLETED]: [
      OrderStatus.PROCESSING,
      OrderStatus.COMPLETED,
      OrderStatus.FAILED,
      OrderStatus.CANCELLED,
    ],
    [OrderStatus.COMPLETED]: [],  // Terminal state
    [OrderStatus.FAILED]: [
      OrderStatus.PENDING,        // Manual retry
      OrderStatus.PROCESSING,     // Auto-revive via requeued job
    ],
    [OrderStatus.CANCELLED]: [
      OrderStatus.PROCESSING,     // Auto-revive via requeued job
    ],
  };

  if (!validTransitions[from].includes(to)) {
    throw new ConflictException(
      `Invalid status transition from ${from} to ${to}`
    );
  }
}

Rationale:

  • Data integrity: Prevents invalid states in the database
  • Self-documenting: State machine diagram matches code exactly
  • Business rule enforcement: No "jumping" to completed without processing

Strategy Pattern

Pattern: Define a family of algorithms and make them interchangeable.

Implementation: Retry job processor uses strategies for different job types:

uml diagram

Implementation (from apps/order-service/src/retry-queue/retry-queue.processor.ts):

private async processJob(job: RetryQueue): Promise<void> {
  switch (job.jobType) {
    case RetryJobType.FULFILLMENT:
      await this.processFulfillmentRetry(job);
      break;

    case RetryJobType.PRINT_JOB_CREATION:
      await this.processPrintJobRetry(job);
      break;

    case RetryJobType.NOTIFICATION:
      await this.processNotificationRetry(job);
      break;
  }
}

private async processFulfillmentRetry(job: RetryQueue): Promise<void> {
  const payload = job.payload as { orderId: string };
  await this.fulfillmentService.createFulfillment({ orderId: payload.orderId });
}

Command Pattern

Pattern: Encapsulate requests as objects with all information needed for execution.

Implementation: Event objects are commands with factory methods:

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
  ) {}

  // Factory method ensures consistent creation
  static create(
    correlationId: string,
    orderId: string,
    shopifyOrderId: string,
    lineItemCount: number
  ): OrderCreatedEvent {
    return new OrderCreatedEvent(
      correlationId,
      new Date(),
      'orders',
      orderId,
      shopifyOrderId,
      lineItemCount
    );
  }
}

Integration Patterns

Webhook Handler Pattern

Pattern: Secure endpoint that validates and processes external system callbacks.

uml diagram

Implementation (from apps/order-service/src/shopify/shopify-webhook.guard.ts):

@Injectable()
export class ShopifyWebhookGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const hmacHeader = request.headers['x-shopify-hmac-sha256'];
    const rawBody = request.rawBody;

    const computedHmac = crypto
      .createHmac('sha256', this.webhookSecret)
      .update(rawBody)
      .digest('base64');

    return crypto.timingSafeEqual(
      Buffer.from(hmacHeader),
      Buffer.from(computedHmac)
    );
  }
}

Retry with Exponential Backoff

Pattern: Retry failed operations with increasing delays.

uml diagram


Idempotency Pattern

Pattern: Operations can be safely retried without side effects.

Implementation (from apps/order-service/src/orders/orders.service.ts):

async createFromShopify(input: CreateOrderInput): Promise<Order> {
  // Check for existing order (idempotency)
  const existing = await this.ordersRepository.findByShopifyOrderId(
    input.shopifyOrderId
  );

  if (existing) {
    this.logger.debug(
      `Order ${input.shopifyOrderId} already exists, skipping creation`
    );
    return existing;  // Return existing, don't create duplicate
  }

  // Create new order only if not exists
  const order = await this.ordersRepository.create(input);
  // ...
}

uml diagram


Resilience Patterns

Dead Letter Queue (Retry Queue)

Pattern: Store failed operations for later retry with visibility into failures.

uml diagram

Schema:

interface RetryQueue {
  id: string;
  jobType: RetryJobType;
  payload: JsonValue;
  status: RetryStatus;
  attempts: number;
  maxAttempts: number;
  lastError: string | null;
  nextRunAt: Date;
  createdAt: Date;
  updatedAt: Date;
}

Saga Pattern (Orchestration)

Pattern: Coordinate long-running transactions across multiple services.

Implementation: OrchestrationService in the Order Service coordinates the order lifecycle. It reacts to both local events (order creation) and cross-service events (print job completion from Print Service, delivered via BullMQ → EventSubscriber → EventEmitter2):

uml diagram

Key Orchestration Logic (from apps/order-service/src/orchestration/orchestration.service.ts):

@Injectable()
export class OrchestrationService {
  constructor(
    @Inject(ORDERS_SERVICE)
    private readonly ordersService: IOrdersService,
    private readonly printJobsService: PrintJobsService,
    private readonly eventEmitter: EventEmitter2,
    private readonly correlationService: CorrelationService,
  ) {}

  @OnEvent(ORDER_EVENTS.CREATED)
  async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
    await this.processNewOrder(event.orderId);
  }

  @OnEvent(PRINT_JOB_EVENTS.COMPLETED)
  async handlePrintJobCompleted(event: PrintJobCompletedEvent): Promise<void> {
    await this.checkOrderCompletion(event.orderId);
  }

  private async checkOrderCompletion(orderId: string): Promise<void> {
    const printJobs = await this.printJobsService.findByOrderId(orderId);
    const statusCounts = this.countJobStatuses(printJobs);

    if (statusCounts.completed === printJobs.length) {
      await this.markOrderReadyForFulfillment(orderId);
    } else if (statusCounts.failed > 0 && statusCounts.pending === 0) {
      await this.markOrderPartiallyCompleted(orderId, statusCounts);
    }
  }
}

Correlation ID Pattern

Pattern: Track requests across async boundaries with a unique identifier.

uml diagram

Implementation (from apps/order-service/src/common/correlation/correlation.service.ts):

@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);
  }

  getOrCreateCorrelationId(): string {
    return this.getCorrelationId() ?? randomUUID();
  }
}

Frontend Patterns

Container/Presentational Components

Pattern: Separate data fetching from UI rendering.

uml diagram


Custom Hooks Pattern

Pattern: Extract and reuse stateful logic across components.

Implementation (from apps/web/src/hooks/use-orders.ts):

// Read hook - data fetching
export function useOrders(query: OrdersQuery = {}) {
  return useQuery({
    queryKey: ['orders', query],
    queryFn: () => apiClient.orders.list(query),
  });
}

// Single entity hook
export function useOrder(id: string) {
  return useQuery({
    queryKey: ['orders', id],
    queryFn: () => apiClient.orders.get(id),
    enabled: !!id,
  });
}

// Mutation hook with cache invalidation
export function useCancelOrder() {
  const queryClient = useQueryClient();
  const { apiKey } = useAuth();

  return useMutation({
    mutationFn: (orderId: string) => 
      apiClient.orders.cancel(orderId, apiKey),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    },
  });
}

uml diagram


Context Provider Pattern

Pattern: Share state across component tree without prop drilling.

Implementation (from apps/web/src/contexts/socket-context.tsx):

const SocketContext = createContext<SocketContextType>({
  socket: null,
  isConnected: false,
});

export function SocketProvider({ children }: { children: ReactNode }) {
  const [socket, setSocket] = useState<Socket | null>(null);
  const [isConnected, setIsConnected] = useState(false);
  const queryClient = useQueryClient();

  useEffect(() => {
    const socketInstance = io(`${SOCKET_URL}/events`);

    // Auto-invalidate queries on events
    socketInstance.on('order:created', () => {
      toast.success(`New order received!`);
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    });

    socketInstance.on('printjob:updated', () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    });

    return () => socketInstance.disconnect();
  }, [queryClient]);

  return (
    <SocketContext.Provider value={{ socket, isConnected }}>
      {children}
    </SocketContext.Provider>
  );
}

export function useSocket() {
  return useContext(SocketContext);
}

uml diagram


Cross-Cutting Patterns

Dependency Injection

Pattern: Components receive dependencies through constructor rather than creating them.

Implementation: NestJS DI container with custom tokens from @forma3d/domain-contracts:

// Define injection token (libs/domain-contracts)
export const ORDERS_SERVICE = Symbol('IOrdersService');

// Register provider (apps/order-service)
@Module({
  providers: [
    {
      provide: ORDERS_SERVICE,
      useClass: OrdersService,
    },
  ],
  exports: [ORDERS_SERVICE],
})
export class OrdersModule {}

// Inject by token (apps/order-service/src/orchestration)
@Injectable()
export class OrchestrationService {
  constructor(
    @Inject(ORDERS_SERVICE)
    private readonly ordersService: IOrdersService,
  ) {}
}

uml diagram


Interface Segregation

Pattern: Define focused interfaces that clients actually need.

Implementation (from libs/domain-contracts/src/):

// Focused interface for cross-domain communication
export interface IOrdersService {
  findById(id: string): Promise<OrderDto | null>;
  updateStatus(id: string, status: OrderStatus): Promise<OrderDto>;
  getLineItems(orderId: string): Promise<LineItemDto[]>;
  updateTracking(id: string, trackingNumber: string, trackingUrl: string | null): Promise<OrderDto>;
}

// Separate interface for fulfillment concerns
export interface IFulfillmentService {
  createFulfillment(input: FulfillmentInput): Promise<void>;
  isFulfilled(orderId: string): Promise<boolean>;
}

// Separate interface for print job concerns
export interface IPrintJobsService {
  findByOrderId(orderId: string): Promise<PrintJobDto[]>;
  updateStatus(id: string, status: PrintJobStatus): Promise<PrintJobDto>;
}

DTO Pattern

Pattern: Use dedicated objects for data transfer between layers.

uml diagram


How Patterns Work Together

The following diagram shows how patterns interact in a typical order flow across services:

uml diagram


Pattern Decision Matrix

Concern Pattern Why This Pattern
Code organization Layered Architecture Clear boundaries, testable layers
Service boundaries Microservices Fault isolation, independent scaling, domain ownership
Service communication Event-Driven (BullMQ + EventEmitter2) Loose coupling, reliability, extensibility
Data access Repository Testability, query encapsulation
External APIs Gateway + ACL Isolation from external changes
Entity lifecycle State Machine Data integrity, clear transitions
Failed operations Retry Queue + DLQ Resilience, visibility
Long transactions Saga (Orchestration) Coordination, compensation
Request tracing Correlation ID Debugging, observability
Frontend data React Query + Hooks Caching, consistency
Real-time updates WebSocket + Observer Live dashboard, notifications
Duplicate requests Idempotency Safe retries, webhook handling

Appendix: Pattern Interactions Cheat Sheet

External Request → Gateway (proxy) → Service Controller → Service → Repository → Database
                                                               ↓
                                              EventEmitter2 (local) → [Orchestrator, WebSocket, Logger]
                                                               ↓
                                              EventPublisher → BullMQ (Redis) → Other Services
                                                                                     ↓
                                              EventSubscriber → EventEmitter2 → Domain Handlers

Key Invariants:

  1. Controllers never access repositories directly
  2. Services emit local events (EventEmitter2) after successful operations
  3. EventPublisher bridges local events to BullMQ for cross-service delivery
  4. EventSubscriber receives BullMQ events and re-emits as local EventEmitter2 events
  5. Repositories don't emit events (that's the service's job)
  6. External API clients handle their own retry logic
  7. Orchestration service (in Order Service) coordinates multi-step workflows across service boundaries
  8. WebSocket gateway only subscribes, never initiates operations
  9. All cross-service events carry tenantId, eventId, correlationId, and source