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¶
- System Overview
- Architectural Patterns
- Layered Architecture
- Modular Monolith
- Event-Driven Architecture
- CQRS-Lite
- Structural Design Patterns
- Repository Pattern
- Service Layer Pattern
- Gateway Pattern
- Anti-Corruption Layer
- Behavioral Design Patterns
- Observer Pattern (Event Emitter)
- State Machine Pattern
- Strategy Pattern
- Command Pattern
- Integration Patterns
- Webhook Handler Pattern
- Retry with Exponential Backoff
- Circuit Breaker (Implicit)
- Idempotency Pattern
- Resilience Patterns
- Dead Letter Queue (Retry Queue)
- Saga Pattern (Orchestration)
- Correlation ID Pattern
- Frontend Patterns
- Container/Presentational Components
- Custom Hooks Pattern
- Context Provider Pattern
- Optimistic Updates
- Cross-Cutting Patterns
- Dependency Injection
- Interface Segregation
- DTO Pattern
- How Patterns Work Together
- Pattern Decision Matrix
System Overview¶
Forma 3D Connect is a print-on-demand order orchestration system that bridges Shopify e-commerce, 3D printing (SimplyPrint), and shipping (SendCloud). The architecture must handle:
- Asynchronous workflows: Orders flow through multiple stages over hours/days
- External system integration: Three external APIs with different reliability characteristics
- Real-time updates: Operators need live dashboards
- Fault tolerance: Network failures and API outages are expected
Architectural Patterns¶
Layered Architecture¶
Pattern: Organize code into horizontal layers with strict dependency rules.
Implementation: The codebase uses a 4-layer architecture:
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 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;
}
}
Modular Monolith¶
Pattern: Structure a monolith as loosely-coupled modules that could be extracted to microservices.
Implementation: Each domain is a self-contained NestJS module:
Module Boundaries (from app.module.ts):
@Module({
imports: [
// Infrastructure & Cross-Cutting
EventEmitterModule.forRoot(),
CorrelationModule,
// Core Domain
OrdersModule,
PrintJobsModule,
ProductMappingsModule,
// Integration
ShopifyModule,
SimplyPrintModule,
SendcloudModule,
// Coordination
OrchestrationModule,
FulfillmentModule,
],
})
export class AppModule {}
Rationale: - Future microservices path: Modules can be extracted when scale requires - Team autonomy: Different developers can own different modules - Bounded contexts: Each module encapsulates its domain logic
Event-Driven Architecture¶
Pattern: Components communicate through events rather than direct calls.
Implementation: NestJS EventEmitter2 for in-process events:
Event Definition (from order.events.ts):
export const ORDER_EVENTS = {
CREATED: 'order.created',
STATUS_CHANGED: 'order.status_changed',
CANCELLED: 'order.cancelled',
READY_FOR_FULFILLMENT: 'order.ready-for-fulfillment',
} as const;
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(...): OrderCreatedEvent {
return new OrderCreatedEvent(correlationId, new Date(), 'orders', ...);
}
}
Rationale: - Loose coupling: Services don't need to know about all consumers - Extensibility: New features can subscribe to existing events - Auditability: Events naturally create an audit trail - Real-time updates: WebSocket gateway subscribes to same events as services
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
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 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 },
});
}
}
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:
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 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 }
);
}
}
Anti-Corruption Layer¶
Pattern: Translate between external system models and internal domain models.
Implementation: DTOs and mapping methods isolate external data shapes:
Example (from shopify.service.ts):
// External Shopify format
interface ShopifyWebhookPayload {
id: number; // External uses number
order_number: string; // snake_case
line_items: Array<{
sku: string;
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),
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: Three observer implementations:
Gateway as Observer (from 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:
Validation Logic (from 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.COMPLETED]: [], // Terminal state
[OrderStatus.CANCELLED]: [], // Terminal state
[OrderStatus.FAILED]: [OrderStatus.PENDING], // Can retry
};
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:
Implementation (from 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.
Implementation (from 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.
Idempotency Pattern¶
Pattern: Operations can be safely retried without side effects.
Implementation (from 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);
// ...
}
Resilience Patterns¶
Dead Letter Queue (Retry Queue)¶
Pattern: Store failed operations for later retry with visibility into failures.
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 coordinates the order lifecycle:
Key Orchestration Logic (from orchestration.service.ts):
@OnEvent(ORDER_EVENTS.CREATED)
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
// Update order status
await this.ordersService.updateStatus(event.orderId, OrderStatus.PROCESSING);
// Get line items and create print jobs
const lineItems = await this.ordersService.getLineItems(event.orderId);
for (const lineItem of lineItems) {
await this.printJobsService.createPrintJobsForLineItem(lineItem);
}
}
@OnEvent(PRINT_JOB_EVENTS.COMPLETED)
async handlePrintJobCompleted(event: PrintJobCompletedEvent): Promise<void> {
// Check if order is complete
await this.checkOrderCompletion(event.orderId);
}
private async checkOrderCompletion(orderId: string): Promise<void> {
const printJobs = await this.printJobsInterface.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.
Implementation (from 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.
Custom Hooks Pattern¶
Pattern: Extract and reuse stateful logic across components.
Implementation (from 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'] });
},
});
}
Context Provider Pattern¶
Pattern: Share state across component tree without prop drilling.
Implementation (from 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);
}
Cross-Cutting Patterns¶
Dependency Injection¶
Pattern: Components receive dependencies through constructor rather than creating them.
Implementation: NestJS DI container with custom tokens:
// Define injection token
export const ORDERS_SERVICE = Symbol('IOrdersService');
// Register provider
@Module({
providers: [
{
provide: ORDERS_SERVICE,
useClass: OrdersService,
},
],
exports: [ORDERS_SERVICE],
})
export class OrdersModule {}
// Inject by token
@Injectable()
export class OrchestrationService {
constructor(
@Inject(ORDERS_SERVICE)
private readonly ordersService: IOrdersService,
) {}
}
Interface Segregation¶
Pattern: Define focused interfaces that clients actually need.
Implementation (from domain-contracts):
// 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.
How Patterns Work Together¶
The following diagram shows how patterns interact in a typical order flow:
Pattern Decision Matrix¶
| Concern | Pattern | Why This Pattern |
|---|---|---|
| Code organization | Layered Architecture | Clear boundaries, testable layers |
| Module boundaries | Modular Monolith | Future microservices path, team autonomy |
| Service communication | Event-Driven | Loose coupling, 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 → Guard → Controller → Service → Repository → Database
↓
EventEmitter → [Orchestrator, Gateway, Logger]
↓
RetryQueue (on failure)
Key Invariants:
- Controllers never access repositories directly
- Services emit events after successful operations
- Repositories don't emit events (that's the service's job)
- External API clients handle their own retry logic
- Orchestration service coordinates multi-step workflows
- WebSocket gateway only subscribes, never initiates operations