Microservices Architecture Brainstorm¶
Status: Draft / Brainstorm (Domain Boundaries Implemented)
Created: January 16, 2026
Updated: January 17, 2026
Author: Architecture Team
This document explores the possibility of migrating from the current monolithic architecture to a microservices architecture using NATS as the message broker, with each domain owning its own database.
Table of Contents¶
- Current State Analysis
- Proposed Microservices Architecture
- Migration Path
- Comparison: Monolith vs Microservices
- Risk Analysis
- Recommendation
- What If: Multi-Tenancy & SaaS Model
- Domain Boundary Evaluation
Current State Analysis¶
Architecture Overview¶
The current system is a modular monolith built with NestJS, deployed as a single application with the following characteristics:
Current Domains¶
| Domain | Responsibility | Database Tables |
|---|---|---|
| Orders | Order lifecycle, status management | Order, LineItem |
| PrintJobs | Print job tracking, status sync | PrintJob |
| Orchestration | Workflow coordination | (stateless, uses Order/PrintJob) |
| Fulfillment | Shopify fulfillment creation | (updates Order) |
| Shipments | Sendcloud integration, labels | Shipment |
| ProductMappings | SKU → STL file mappings | ProductMapping |
| Notifications | Email alerts | (stateless) |
| EventLog | Audit trail | EventLog |
| RetryQueue | Failed operation retries | RetryJob |
Current Event Flow¶
Events flow synchronously within the same process via EventEmitter2:
OrdersService → emit('order.created') → OrchestrationService.handleOrderCreated()
→ EventsGateway.broadcastToClients()
Characteristics:
- ✅ Zero latency between services
- ✅ Shared transaction context possible
- ✅ Simple debugging (single process)
- ❌ No isolation between domains
- ❌ Single point of failure
- ❌ Scaling requires scaling entire application
Proposed Microservices Architecture¶
Service Decomposition¶
Based on domain boundaries and the Bounded Context pattern, we propose the following microservices:
Proposed Services¶
| Service | Owns Events | Database | External Dependencies |
|---|---|---|---|
| Orders Service | order.* |
Orders, LineItems | - |
| Print Service | printjob.* |
PrintJobs | SimplyPrint API |
| Orchestration Service | orchestration.* |
(stateless) | - |
| Fulfillment Service | fulfillment.* |
Fulfillments | Shopify API |
| Shipping Service | shipment.* |
Shipments | Sendcloud API |
| Notification Service | - | (stateless) | SMTP |
| Dashboard BFF | - | (stateless) | Aggregates from other services |
NATS Integration¶
NATS would serve as the central nervous system:
// Publishing an event (Orders Service)
await this.nats.publish('order.created', {
orderId: order.id,
shopifyOrderId: order.shopifyOrderId,
lineItemCount: order.lineItems.length,
});
// Subscribing to events (Orchestration Service)
@NatsSubscribe('order.created')
async handleOrderCreated(data: OrderCreatedEvent) {
await this.processNewOrder(data);
}
NATS Features to Leverage:
- JetStream: Durable message storage for replay and exactly-once delivery
- Request/Reply: Synchronous queries between services
- Subject Hierarchy:
order.>,printjob.status.>, etc. - Consumer Groups: Horizontal scaling of service instances
Data Ownership Strategy¶
Each service owns its data exclusively:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Orders Service │ │ Print Service │ │ Shipping Service│
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ • Order │ │ • PrintJob │ │ • Shipment │
│ • LineItem │ │ • PrinterStatus │ │ • ShippingMethod│
│ • orderStatus │ │ • printJobStatus│ │ • Label │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└───────────────────────┴───────────────────────┘
│
┌────────────┴────────────┐
│ NATS Event Stream │
│ (eventual consistency) │
└─────────────────────────┘
Cross-Service Data Access Patterns:
- Event Carrying State Transfer: Events include enough data for consumers
- Request/Reply: For synchronous lookups (e.g., "get order details")
- Local Materialized Views: Services maintain read-only copies of data they need frequently
Migration Path¶
Phase 0: Preparation (Current State Enhancement) ✅ COMPLETE¶
Goal: Make the monolith "microservices-ready" without breaking anything.
Duration: 2-4 weeks
Risk: Low
Status: ✅ COMPLETE (January 17, 2026)
Steps:
- ✅ Already done: Event-driven architecture with clear domain events
- ✅ Interface boundaries between modules (no direct repository access across domains) —
libs/domain-contracts - ✅ Correlation IDs on all events for distributed tracing —
CorrelationServicewithAsyncLocalStorage - ⏳ Implement the Outbox Pattern for reliable event publishing (optional, deferred)
- ✅ Health checks and readiness probes (already implemented in Phase 1c)
Phase 1: Extract Message Broker (Strangler Fig Pattern)¶
Goal: Run NATS alongside EventEmitter2, gradually shifting events.
Duration: 3-4 weeks
Risk: Medium
Steps:
- Deploy NATS cluster (JetStream enabled)
- Create
EventBridgeServicethat publishes to both EventEmitter2 and NATS - Update all event emitters to use the bridge
- Verify events flow correctly in both systems
- Add dead-letter handling for NATS
Phase 2: Extract First Service (Orders)¶
Goal: Run Orders as a separate service while monolith handles the rest.
Duration: 4-6 weeks
Risk: High (first extraction is hardest)
Steps:
- Create new
orders-serviceNestJS application - Copy Orders module code to new service
- Set up dedicated PostgreSQL database for Orders
- Migrate data (one-time sync or CDC with Debezium)
- Update monolith to consume order data via NATS request/reply
- Route Shopify order webhooks to new service
- Deprecate Orders module in monolith
Phase 3: Extract Print Service¶
Goal: Isolate SimplyPrint integration.
Duration: 3-4 weeks
Risk: Medium
Steps:
- Extract PrintJobs module + SimplyPrint integration
- Create dedicated database
- Subscribe to
order.createdfrom Orders Service - Publish
printjob.*events to NATS - Update Orchestration to consume via NATS
Phase 4: Extract Shipping & Fulfillment¶
Goal: Complete the core service decomposition.
Duration: 4-6 weeks
Risk: Medium
Steps:
- Extract Shipping Service (Sendcloud integration)
- Extract Fulfillment Service (Shopify fulfillment)
- Handle the coordination: Shipping → Fulfillment dependency
- Implement Saga pattern for the fulfillment workflow
Phase 5: Finalize & Optimize¶
Goal: Production-ready microservices.
Duration: 2-4 weeks
Risk: Low
Steps:
- Implement API Gateway (routing, auth, rate limiting)
- Set up distributed tracing (Jaeger/Zipkin)
- Configure service mesh (optional: Linkerd/Istio)
- Implement circuit breakers
- Load testing and performance optimization
- Documentation and runbooks
Comparison: Monolith vs Microservices¶
For Forma3D Connect Specifically¶
| Criterion | Monolith | Microservices | Winner for Our Case |
|---|---|---|---|
| Team Size | 1-3 developers | 5+ developers | 🏆 Monolith (small team) |
| Deployment Complexity | Simple (1 container) | Complex (10+ containers) | 🏆 Monolith |
| Operational Overhead | Low | High (monitoring, logging, tracing) | 🏆 Monolith |
| Development Speed | Fast (shared codebase) | Slower (cross-service changes) | 🏆 Monolith |
| Scaling Flexibility | Limited (scale everything) | Fine-grained (scale what's needed) | 🏆 Microservices |
| Fault Isolation | Poor (one bug crashes all) | Good (isolated failures) | 🏆 Microservices |
| Technology Flexibility | Same stack everywhere | Polyglot possible | Tie (not needed) |
| Data Consistency | Easy (ACID transactions) | Hard (eventual consistency) | 🏆 Monolith |
| Testing | Simple (integration tests) | Complex (contract testing) | 🏆 Monolith |
| Debugging | Easy (single process) | Hard (distributed tracing) | 🏆 Monolith |
| Independent Deployability | No | Yes | 🏆 Microservices |
| External Integration Isolation | No | Yes (SimplyPrint/Sendcloud isolated) | 🏆 Microservices |
Monolith Advantages (For Our Use Case)¶
- Simplicity: Single deployment, single database, single log stream
- Transactional Integrity: Create order + line items + print jobs in one transaction
- Refactoring Freedom: Rename, move, restructure without cross-service coordination
- Lower Infrastructure Cost: One PostgreSQL, one container, one monitoring stack
- Faster Development: No inter-service contracts, no API versioning overhead
- Debugging: Full stack trace in one place
Microservices Advantages (For Our Use Case)¶
- Fault Isolation: SimplyPrint API down? Only Print Service affected
- Independent Scaling: Print job processing needs 5x resources? Scale only that service
- External API Isolation: Sendcloud rate limit? Only Shipping Service queues up
- Team Independence: Different teams can own different services (future growth)
- Technology Evolution: Replace Print Service implementation without touching Orders
- Resilience: NATS JetStream provides message durability and replay
Hidden Costs of Microservices¶
| Cost Category | Monolith | Microservices |
|---|---|---|
| Infrastructure | 1 PostgreSQL, 1 container | 5+ databases, 10+ containers, NATS cluster |
| Monitoring | Simple (stdout + Sentry) | Distributed tracing, per-service dashboards |
| DevOps Time | Minimal | Significant (deployment, networking, secrets) |
| Cognitive Load | Understand one codebase | Understand 5+ codebases + interactions |
| Incident Response | "The app is down" | "Which service? What's the event flow?" |
| Data Migration | None | Complex (cross-service data sync) |
Risk Analysis¶
Migration Risks¶
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Data inconsistency during migration | High | Critical | Dual-write with verification |
| Service communication failures | Medium | High | Circuit breakers, retries, DLQ |
| Increased latency | High | Medium | Request/reply caching, async where possible |
| Developer productivity drop | High | Medium | Extensive documentation, templates |
| Operational complexity overwhelming | Medium | High | Invest in DevOps tooling first |
| Cost overrun | Medium | Medium | Phase gates with go/no-go decisions |
"Point of No Return" Considerations¶
- Phase 1 (NATS alongside): Fully reversible
- Phase 2 (First service extracted): Reversible but expensive
- Phase 3+: Committed to microservices
Recommendation¶
Short-Term (0-12 months): Stay with Modular Monolith¶
Reasoning:
- Team Size: With a small team (1-3 developers), the operational overhead of microservices would consume significant development capacity
- Current Pain Points: The monolith is not experiencing scaling issues, deployment bottlenecks, or fault isolation problems
- Feature Velocity: The team can ship features faster with the current architecture
- ROI: The investment in microservices infrastructure (NATS cluster, per-service databases, distributed tracing) does not have a clear payback period
What to Do Instead:
- Strengthen Module Boundaries: Enforce interface-based communication between modules
- Improve Event Infrastructure: Add correlation IDs, structured logging, event versioning
- Monitor for Pain Points: Track metrics that would indicate need for microservices:
- Deployment frequency constraints
- Scaling bottlenecks (specific modules under load)
- Mean time to recovery after failures
- Document Domain Boundaries: Keep the event catalog updated as the "API contract" for future decomposition
Medium-Term (12-24 months): Evaluate Based on Growth¶
Trigger conditions for reconsidering microservices:
| Trigger | Threshold |
|---|---|
| Team size grows to | 5+ developers |
| Deployment conflicts per month | > 10 |
| Single service needs 3x more resources than others | Yes |
| External API integration issues affecting unrelated domains | Recurring |
| Feature development blocked by monolith coupling | > 20% of sprints |
Long-Term (24+ months): Gradual Extraction if Needed¶
If the triggers above are met, follow the phased migration path starting with the most problematic domain (likely Print Service due to external dependency on SimplyPrint).
Summary¶
| Aspect | Recommendation |
|---|---|
| Architecture | Stay with modular monolith |
| Message Broker | Not needed now; EventEmitter2 is sufficient |
| Database | Keep single PostgreSQL database |
| Event System | Continue investing in event catalog and correlation |
| Future-Proofing | Maintain clear module boundaries for potential extraction |
Key Insight¶
"Start with a monolith, and only extract microservices when you feel the pain."
— Martin Fowler, "MonolithFirst"
The current Forma3D Connect architecture is already event-driven and has clear domain boundaries. This positions it well for future decomposition if and when that becomes necessary. Premature optimization toward microservices would add complexity without proportional benefits at the current scale.
Appendix: NATS Quick Reference¶
If we do proceed with NATS in the future, here's a quick reference:
// NestJS NATS integration
import { ClientProxy, ClientProxyFactory, Transport } from '@nestjs/microservices';
const client = ClientProxyFactory.create({
transport: Transport.NATS,
options: {
servers: ['nats://localhost:4222'],
},
});
// Publish event (fire-and-forget)
client.emit('order.created', orderData);
// Request/Reply (synchronous)
const order = await client.send('order.get', { orderId: '123' }).toPromise();
// Subscribe (in service)
@MessagePattern('order.get')
getOrder(@Payload() data: { orderId: string }) {
return this.ordersRepository.findById(data.orderId);
}
@EventPattern('printjob.completed')
handlePrintJobCompleted(@Payload() data: PrintJobCompletedEvent) {
// Handle event
}
NATS JetStream for Durability¶
// Durable consumer with exactly-once delivery
const js = await nats.jetstream();
const consumer = await js.consumers.get('ORDERS', 'orchestration-consumer');
const messages = await consumer.consume();
for await (const msg of messages) {
await this.processMessage(msg.data);
msg.ack();
}
What If: Multi-Tenancy & SaaS Model¶
Scenario: Forma3D Connect becomes a commercial SaaS product, allowing other 3D print farms to subscribe and use the integration services with their own Shopify stores, SimplyPrint accounts, and Sendcloud credentials.
This section explores how multi-tenancy changes the architectural calculus.
Multi-Tenancy Requirements¶
| Requirement | Description |
|---|---|
| Data Isolation | Each tenant's orders, print jobs, and shipments must be completely isolated |
| Configuration Isolation | Each tenant has their own Shopify store, SimplyPrint account, Sendcloud credentials |
| Billing & Licensing | Usage tracking, subscription tiers, feature gating |
| Tenant Onboarding | Self-service signup with credential configuration |
| Scalability | Support for 10s to 1000s of tenants with varying workloads |
| Compliance | Data residency requirements (GDPR, tenant data export/deletion) |
| Customization | Tenant-specific workflows, notification templates, branding |
Multi-Tenancy Patterns¶
There are three primary patterns for multi-tenant architecture:
| Pattern | Cost | Isolation | Complexity | Best For |
|---|---|---|---|---|
| Shared Everything | $ | Low | Low | SMB, low-security |
| Shared App, Isolated DB | $$ | Medium | Medium | Most SaaS |
| Fully Isolated | $$$ | High | High | Enterprise, regulated |
How Multi-Tenancy Changes the Architecture¶
New Components Required¶
Key Architectural Changes¶
| Aspect | Single-Tenant (Current) | Multi-Tenant SaaS |
|---|---|---|
| Authentication | Single API key | JWT with tenant claims, tenant admin roles |
| Database | One database | Schema-per-tenant or tenant_id column |
| External Credentials | Environment variables | Encrypted vault per tenant |
| Rate Limiting | Global | Per-tenant quotas |
| Event Subjects | order.created |
tenant.{tenantId}.order.created |
| Webhook Routing | Single endpoint | Tenant-aware routing |
| Billing | None | Usage metering, subscription tiers |
| Onboarding | Manual setup | Self-service wizard |
Revised Comparison: Monolith vs Microservices for Multi-Tenant¶
| Criterion | Monolith | Microservices | Winner for Multi-Tenant |
|---|---|---|---|
| Tenant Isolation | Harder (shared memory) | Easier (process isolation) | 🏆 Microservices |
| Per-Tenant Scaling | Impossible | Native support | 🏆 Microservices |
| Noisy Neighbor Prevention | Very difficult | Natural boundaries | 🏆 Microservices |
| Credential Isolation | Risk of leakage | Service-scoped secrets | 🏆 Microservices |
| Feature Flags per Tenant | Complex | Natural service versioning | 🏆 Microservices |
| Compliance (data residency) | Hard | Deploy service in region | 🏆 Microservices |
| Initial Development Speed | Fast | Slower | 🏆 Monolith |
| Operational Complexity | Lower | Higher | 🏆 Monolith |
| Infrastructure Cost | Lower | Higher | 🏆 Monolith |
Score: Microservices 6, Monolith 3 (reversed from single-tenant!)
Multi-Tenant Monolith: Is It Viable?¶
Yes, but with significant constraints:
// Every database query needs tenant context
@Injectable()
export class OrdersRepository {
async findById(tenantId: string, orderId: string): Promise<Order> {
return this.prisma.order.findFirst({
where: {
id: orderId,
tenantId: tenantId, // CRITICAL: Never forget this!
},
});
}
}
// Every external API call needs tenant credentials
@Injectable()
export class ShopifyService {
async createFulfillment(tenantId: string, orderId: string) {
const credentials = await this.credentialVault.get(tenantId, 'shopify');
const client = new ShopifyClient(credentials);
// ...
}
}
Multi-Tenant Monolith Risks:
| Risk | Severity | Mitigation Complexity |
|---|---|---|
| Cross-tenant data leakage (missing WHERE clause) | Critical | High (requires rigorous review) |
| Credential leakage between tenants | Critical | High (encryption, vault) |
| One tenant's load affecting others | High | Medium (rate limiting) |
| One tenant's bug crashing all tenants | High | Cannot fully mitigate |
| Compliance audit complexity | Medium | High (proving isolation) |
Recommended Architecture for Multi-Tenant SaaS¶
Given the multi-tenancy requirements, the recommendation shifts:
Phase 1: Multi-Tenant Monolith (MVP)¶
For initial market validation (0-50 tenants):
Key additions to current architecture:
Tenanttable with credentials vaulttenantIdcolumn on all existing tablesTenantContextMiddlewarethat sets tenant for each request- Prisma middleware to automatically filter by tenant
- NATS subjects prefixed with tenant ID
// Prisma middleware for automatic tenant filtering
prisma.$use(async (params, next) => {
const tenantId = getTenantContext();
if (params.action === 'findMany' || params.action === 'findFirst') {
params.args.where = { ...params.args.where, tenantId };
}
if (params.action === 'create') {
params.args.data = { ...params.args.data, tenantId };
}
return next(params);
});
Phase 2: Hybrid Architecture (Growth)¶
For scaling (50-500 tenants):
Extract the most isolation-critical services while keeping others in the monolith:
| Service | Extraction Priority | Reason |
|---|---|---|
| Tenant & Billing | High | Security boundary, different scaling |
| Print Service | High | SimplyPrint credentials are sensitive |
| Shipping Service | Medium | Sendcloud credentials, different load pattern |
| Orders, Fulfillment | Low | Can remain in core monolith |
Phase 3: Full Microservices (Scale)¶
For enterprise scale (500+ tenants):
Complete the migration as described in the earlier migration path, with these multi-tenant enhancements:
- Per-tenant message queues in NATS for complete isolation
- Tenant-aware API Gateway with quota enforcement
- Separate credential vaults per service
- Regional deployments for data residency compliance
Multi-Tenant Cost Projection¶
| Tenants | Architecture | Monthly Infrastructure Cost (est.) |
|---|---|---|
| 1-10 | Multi-tenant Monolith | $100-200 |
| 10-50 | Multi-tenant Monolith | $200-500 |
| 50-200 | Hybrid (3-4 services) | $500-1,500 |
| 200-500 | Hybrid (5-6 services) | $1,500-4,000 |
| 500+ | Full Microservices | $4,000-10,000+ |
Costs include: Compute, databases, NATS cluster, monitoring, secrets management
Updated Recommendation Summary¶
| Scenario | Recommended Architecture |
|---|---|
| Single-tenant (current) | Modular Monolith (no change) |
| Multi-tenant MVP (1-50 tenants) | Multi-tenant Monolith with tenant context |
| Multi-tenant Growth (50-500 tenants) | Hybrid: Extract Print, Shipping, Tenant services |
| Multi-tenant Scale (500+ tenants) | Full Microservices with NATS |
Key Insight for Multi-Tenancy¶
"Multi-tenancy is the forcing function that often justifies microservices. The isolation, security, and per-tenant scaling requirements naturally align with service boundaries."
If Forma3D Connect pivots to a SaaS model:
- Start with multi-tenant monolith for market validation
- Plan for microservices from day one (maintain clean boundaries)
- Extract services progressively as tenant count and revenue justify the investment
- NATS becomes essential for tenant-scoped event routing and scaling
The current event-driven, modular architecture positions the codebase well for this evolution. The investment in clear domain boundaries pays off significantly when multi-tenancy enters the picture.
Multi-Tenant Security Checklist¶
Before going multi-tenant, implement these security controls:
| Control | Priority | Implementation |
|---|---|---|
| Tenant context injection | Critical | Middleware on every request |
| Automatic query filtering | Critical | Prisma middleware |
| Credential encryption at rest | Critical | Vault (HashiCorp, AWS Secrets Manager) |
| Tenant isolation testing | Critical | Automated tests attempting cross-tenant access |
| Rate limiting per tenant | High | API Gateway with tenant quotas |
| Audit logging with tenant ID | High | All actions logged with tenant context |
| Tenant data export | Medium | GDPR compliance endpoint |
| Tenant data deletion | Medium | Cascade delete with verification |
| Tenant-scoped API keys | Medium | Separate keys per tenant |
| Cross-tenant penetration testing | High | Before production launch |
Domain Boundary Evaluation¶
Status: Current State Analysis
Purpose: Evaluate how well the current codebase maintains domain boundaries and identify improvements for microservices-readiness.
As stated in the recommendation above, maintaining clear domain boundaries within the monolith is critical for a future microservices transition. This section evaluates the current boundaries and proposes improvements.
Evaluation Criteria¶
| Criterion | Description | Ideal State |
|---|---|---|
| Repository Isolation | Each domain accesses only its own repository | No cross-domain repository access |
| Interface-Based Dependencies | Domains depend on interfaces, not implementations | All cross-domain calls through interfaces |
| Event-Driven Communication | Domains communicate via events, not direct calls | Events for state changes, no direct service calls |
| Data Ownership | Each domain owns its data exclusively | No shared tables across domains |
| Module Encapsulation | Modules expose minimal public API | Only services/DTOs exported, not repositories |
| Circular Dependencies | No circular imports between domains | Zero forwardRef() usage |
Current State Assessment¶
1. Repository Access Across Domains¶
Status: ✅ RESOLVED (January 17, 2026)
Previously, several services directly accessed repositories from other domains. This has been resolved by introducing interface-based dependencies.
┌─────────────────────────────────────────────────────────────────────┐
│ 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) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Evidence from codebase:
// apps/api/src/orchestration/orchestration.service.ts
import { PrintJobsRepository } from '../print-jobs/print-jobs.repository';
import { OrdersRepository } from '../orders/orders.repository';
// apps/api/src/fulfillment/fulfillment.service.ts
import { OrdersRepository } from '../orders/orders.repository';
// apps/api/src/sendcloud/sendcloud.service.ts
import { ShipmentsRepository } from '../shipments/shipments.repository';
import { OrdersRepository } from '../orders/orders.repository';
// apps/api/src/cancellation/cancellation.service.ts
import { OrdersRepository } from '../orders/orders.repository';
import { PrintJobsRepository } from '../print-jobs/print-jobs.repository';
Impact: If we extract any service to a microservice, we must also extract all the repositories it depends on, or create API calls to fetch the data. This creates tight coupling.
2. Module Export Patterns¶
Status: ✅ RESOLVED (January 17, 2026)
Modules no longer export repositories directly. Only interface tokens are exported for cross-domain access:
// apps/api/src/orders/orders.module.ts
@Module({
providers: [OrdersService, OrdersRepository],
exports: [OrdersService, OrdersRepository], // ❌ Repository exported
})
// apps/api/src/print-jobs/print-jobs.module.ts
@Module({
providers: [PrintJobsService, PrintJobsRepository],
exports: [PrintJobsService, PrintJobsRepository], // ❌ Repository exported
})
Ideal Pattern:
@Module({
providers: [OrdersService, OrdersRepository],
exports: [OrdersService], // ✅ Only service exported
})
3. Circular Dependencies¶
Status: ⚠️ PARTIAL (January 17, 2026)
Most forwardRef() usages have been eliminated through interface-based dependencies. Some remain for the RetryQueue pattern:
// apps/api/src/orchestration/orchestration.module.ts
imports: [forwardRef(() => PrintJobsModule), forwardRef(() => OrdersModule)];
// apps/api/src/sendcloud/sendcloud.module.ts
imports: [forwardRef(() => OrdersModule), forwardRef(() => RetryQueueModule)];
Circular Dependency Graph:
4. Event-Driven Communication¶
Status: GOOD
The codebase correctly uses events for cross-domain state changes:
// Orders → Orchestration (via event)
this.eventEmitter.emit(ORDER_EVENTS.CREATED, { orderId, ... });
// PrintJobs → Orchestration (via event)
this.eventEmitter.emit(PRINT_JOB_EVENTS.COMPLETED, { printJob, orderId });
// Orchestration → Fulfillment (via event)
this.eventEmitter.emit(ORCHESTRATION_EVENTS.ORDER_READY_FOR_FULFILLMENT, { orderId });
// Orchestration → Sendcloud (via event)
@OnEvent(ORCHESTRATION_EVENTS.ORDER_READY_FOR_FULFILLMENT)
async handleOrderReadyForFulfillment(event) { ... }
Positive patterns:
- Events carry sufficient data for consumers
- Events trigger downstream workflows
- Event constants are centralized in domain folders
5. Data Ownership¶
Status: GOOD (with caveats)
Each domain has clear table ownership:
| Domain | Owned Tables | Foreign Keys To |
|---|---|---|
| Orders | Order, LineItem |
- |
| PrintJobs | PrintJob |
LineItem (orders) |
| Shipments | Shipment |
Order (orders) |
| ProductMappings | ProductMapping, AssemblyPart |
- |
| EventLog | EventLog |
Order (optional) |
| RetryQueue | RetryQueue |
- |
Concern: The PrintJob → LineItem and Shipment → Order foreign keys create data coupling. In a microservices world, these would need to be replaced with:
- Denormalized copies of needed data (e.g.,
orderId,customerName) - API calls for data retrieval
- Event-carried state transfer
6. Interface Boundaries¶
Status: ✅ RESOLVED (January 17, 2026)
Formal interfaces now exist between domains in libs/domain-contracts. Services depend on interfaces via injection tokens:
// Direct dependency on concrete class
constructor(
private readonly ordersRepository: OrdersRepository, // ❌ No interface
private readonly printJobsService: PrintJobsService, // ❌ No interface
) {}
Ideal Pattern:
// Interface in orders domain
export interface IOrdersService {
findById(id: string): Promise<Order>;
updateStatus(id: string, status: OrderStatus): Promise<Order>;
}
// Consumer depends on interface
constructor(
@Inject('IOrdersService')
private readonly ordersService: IOrdersService, // ✅ Interface
) {}
Domain Boundary Scorecard¶
| Domain | Repository Isolation | Event Usage | Interface Boundary | Circular Deps | Score |
|---|---|---|---|---|---|
| Orders | ✅ Self-contained | ✅ Emits events | ✅ IOrdersService | ✅ Via interface | 4/4 |
| PrintJobs | ✅ Self-contained | ✅ Emits events | ✅ IPrintJobsService | ✅ Via interface | 4/4 |
| Orchestration | ✅ Uses interfaces | ✅ Emits & listens | ✅ Uses interfaces | ✅ No circular | 4/4 |
| Fulfillment | ✅ Uses interfaces | ✅ Listens to events | ✅ IFulfillmentSvc | ✅ No circular | 4/4 |
| Sendcloud | ✅ Uses interfaces | ✅ Emits events | ✅ Uses interfaces | ⚠️ forwardRef(RQ) | 3.5/4 |
| Cancellation | ✅ Uses interfaces | ✅ Listens to events | ✅ Uses interfaces | ✅ No circular | 4/4 |
| Shipments | ✅ Self-contained | ❌ No events | ❌ No interface | ✅ No circular | 2/4 |
| ProductMappings | ✅ Self-contained | ❌ No events | ❌ No interface | ✅ No circular | 2/4 |
Overall Score: 27.5/32 (86%) ✅ (Updated January 17, 2026)
Improvement Recommendations¶
Priority 1: Stop Exporting Repositories¶
Effort: Low | Impact: High
Repositories should be internal implementation details. Only services should be exported.
// BEFORE: apps/api/src/orders/orders.module.ts
exports: [OrdersService, OrdersRepository],
// AFTER
exports: [OrdersService], // Repository is internal
Action Items:
- Remove
OrdersRepositoryfromOrdersModule.exports - Remove
PrintJobsRepositoryfromPrintJobsModule.exports - Remove
ShipmentsRepositoryfromShipmentsModule.exports - Update all consumers to use services instead of repositories
Priority 2: Create Domain Interfaces¶
Effort: Medium | Impact: High
Define interfaces for cross-domain interactions:
// libs/domain-contracts/src/orders.interface.ts
export interface IOrdersService {
findById(id: string): Promise<OrderDto | null>;
findByShopifyOrderId(shopifyOrderId: string): Promise<OrderDto | null>;
updateStatus(id: string, status: OrderStatus): Promise<OrderDto>;
getOrdersForFulfillment(): Promise<OrderDto[]>;
}
// libs/domain-contracts/src/print-jobs.interface.ts
export interface IPrintJobsService {
createPrintJobsForLineItem(lineItem: LineItemDto): Promise<PrintJobDto[]>;
findByOrderId(orderId: string): Promise<PrintJobDto[]>;
cancelJobsForOrder(orderId: string): Promise<void>;
}
New folder structure:
libs/
domain-contracts/
src/
index.ts
orders.interface.ts
print-jobs.interface.ts
fulfillment.interface.ts
shipments.interface.ts
Priority 3: Replace Repository Access with Service Calls¶
Effort: High | Impact: Critical
Refactor services to use domain services instead of repositories:
// BEFORE: apps/api/src/orchestration/orchestration.service.ts
constructor(
private readonly printJobsRepository: PrintJobsRepository,
private readonly ordersRepository: OrdersRepository,
) {}
async handleOrderCreated(event: OrderCreatedEvent) {
const order = await this.ordersRepository.findById(event.orderId);
// ...
}
// AFTER
constructor(
@Inject('IPrintJobsService')
private readonly printJobsService: IPrintJobsService,
@Inject('IOrdersService')
private readonly ordersService: IOrdersService,
) {}
async handleOrderCreated(event: OrderCreatedEvent) {
const order = await this.ordersService.findById(event.orderId);
// ...
}
Priority 4: Break Circular Dependencies¶
Effort: Medium | Impact: Medium
Restructure to eliminate forwardRef():
Key changes:
- Remove direct module imports between coordination layer and core domain
- Use event subscriptions instead of direct service calls where possible
- For synchronous needs, use interfaces injected via tokens
Priority 5: Add Correlation IDs to Events¶
Effort: Low | Impact: Medium
Prepare for distributed tracing:
// BEFORE
interface OrderCreatedEvent {
orderId: string;
shopifyOrderId: string;
lineItemCount: number;
}
// AFTER
interface OrderCreatedEvent {
correlationId: string; // Add tracing ID
orderId: string;
shopifyOrderId: string;
lineItemCount: number;
timestamp: Date; // Add timestamp
}
// Generate in middleware
const correlationId = req.headers['x-correlation-id'] || uuid();
Priority 6: Consider Event Outbox Pattern¶
Effort: High | Impact: High (for future)
For reliable event publishing in a microservices future:
// Instead of direct emit
this.eventEmitter.emit(ORDER_EVENTS.CREATED, event);
// Use outbox pattern
await this.prisma.$transaction([
// 1. Persist the business change
prisma.order.create({ data: orderData }),
// 2. Persist the event in outbox table
prisma.outbox.create({
data: {
eventType: ORDER_EVENTS.CREATED,
payload: JSON.stringify(event),
status: 'PENDING',
},
}),
]);
// Separate process publishes events from outbox
This ensures events are never lost even if the app crashes after the database write.
Migration-Ready Architecture¶
After implementing the improvements, the architecture would look like:
Implementation Roadmap¶
| Phase | Tasks | Effort | Dependencies | Status |
|---|---|---|---|---|
| Phase 0 | Add correlation IDs to all events | 2 days | None | ✅ Complete |
| Phase 1 | Create domain-contracts lib with interfaces | 3 days | None | ✅ Complete |
| Phase 2 | Stop exporting repositories | 1 week | Phase 1 | ✅ Complete |
| Phase 3 | Refactor OrchestrationService to use interfaces | 3 days | Phase 2 | ✅ Complete |
| Phase 4 | Refactor FulfillmentService to use interfaces | 2 days | Phase 2 | ✅ Complete |
| Phase 5 | Refactor SendcloudService to use interfaces | 2 days | Phase 2 | ✅ Complete |
| Phase 6 | Refactor CancellationService to use interfaces | 2 days | Phase 2 | ✅ Complete |
| Phase 7 | Remove all forwardRef() usages | 2 days | Phases 3-6 | ⚠️ Partial (RetryQueue remains) |
| Phase 8 | Add event outbox pattern (optional) | 1 week | None | ⏳ Pending |
Completed: January 17, 2026 (Phases 0-6 complete, Phase 7 partial)
Summary¶
| Aspect | Previous State | Current State (Jan 17, 2026) |
|---|---|---|
| Repository Access | Cross-domain access common | ✅ Only within owning module |
| Module Exports | Repositories exported | ✅ Only interface tokens |
| Interfaces | None | ✅ All cross-domain via interfaces |
| Circular Dependencies | forwardRef() used | ⚠️ Mostly resolved (RetryQueue remains) |
| Event Correlation | No correlation IDs | ✅ All events have correlation ID |
| Event Reliability | Direct emit | ⏳ Outbox pattern (optional, pending) |
Key Insight (Updated January 17, 2026):
The codebase now has clean domain boundaries with interface-based dependencies, correlation ID propagation, and encapsulated repositories. This positions it well for future microservices extraction with minimal refactoring. The investment in boundary improvements has been completed, significantly improving code maintainability and reducing the effort required for potential future service decomposition.
References: - ADR-032: Domain Boundary Separation with Interface Contracts - Phase 5b in Implementation Plan