Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx nx build api --skip-nx-cache

FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist/apps/api ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/main.js"]
35 changes: 34 additions & 1 deletion apps/api/next.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
/** @type {import('next').NextConfig} */
const path = require('path');
const webpack = require('webpack');

const LIBS = path.resolve(__dirname, '../../libs');

// Mirror tsconfig.base.json paths for webpack
const pathMappings = [
// /internal subpaths first (more specific)
['@tiny-store/modules-inventory/internal', 'modules/inventory/src/internal.ts'],
['@tiny-store/modules-orders/internal', 'modules/orders/src/internal.ts'],
['@tiny-store/modules-payments/internal', 'modules/payments/src/internal.ts'],
['@tiny-store/modules-shipments/internal', 'modules/shipments/src/internal.ts'],
// Public APIs
['@tiny-store/modules-inventory', 'modules/inventory/src/index.ts'],
['@tiny-store/modules-orders', 'modules/orders/src/index.ts'],
['@tiny-store/modules-payments', 'modules/payments/src/index.ts'],
['@tiny-store/modules-shipments', 'modules/shipments/src/index.ts'],
['@tiny-store/shared-infrastructure', 'shared/infrastructure/src/index.ts'],
['@tiny-store/shared-domain', 'shared/domain/src/index.ts'],
];

const nextConfig = {
serverExternalPackages: ['typeorm', 'sqlite3', 'reflect-metadata'],
Expand All @@ -9,10 +28,24 @@ const nextConfig = {
...config.resolve.alias,
'@/lib': path.resolve(__dirname, 'src/app/lib'),
};

// Use NormalModuleReplacementPlugin for @tiny-store/* paths
for (const [from, to] of pathMappings) {
const escaped = from.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&');
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(
new RegExp(`^${escaped}$`),
path.join(LIBS, to)
)
);
}

if (!config.resolve.extensions.includes('.ts')) {
config.resolve.extensions.push('.ts');
}
}
return config;
},
};

module.exports = nextConfig;

156 changes: 82 additions & 74 deletions apps/api/src/app/lib/register-listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { EventStoreRepository } from '@tiny-store/shared-infrastructure';
import { OrderPlacedListener } from '@tiny-store/modules-inventory';
import { OrderCancelledListener } from '@tiny-store/modules-inventory';
import { OrderPaymentFailedListener } from '@tiny-store/modules-inventory';
import { registerStockSyncWorker } from '@tiny-store/modules-inventory';

// Orders
import { InventoryReservedListener } from '@tiny-store/modules-orders';
Expand All @@ -16,69 +17,31 @@ import { PaymentFailedListener } from '@tiny-store/modules-orders';
import { ShipmentCreatedListener } from '@tiny-store/modules-orders';

// Payments
import { OrderConfirmedListener } from '@tiny-store/modules-payments';
import { ProcessPaymentHandler } from '@tiny-store/modules-payments';

// Payments - Jobs
import { registerPaymentProcessingWorker } from '@tiny-store/modules-payments';

// Inventory - Jobs
import { registerStockSyncWorker } from '@tiny-store/modules-inventory';

// Shipments
import { OrderPaidListener } from '@tiny-store/modules-shipments';
import { CreateShipmentHandler } from '@tiny-store/modules-shipments';
import { registerLabelGenerationWorker } from '@tiny-store/modules-shipments';

// Orders - for accessing order details
// Orders - for accessing order details (used by payments + shipments)
import { GetOrderHandler } from '@tiny-store/modules-orders';

export function registerListeners(dataSource: DataSource): void {
const eventBus = EventBus.getInstance();
const eventStoreRepository = new EventStoreRepository(dataSource);

// Store all events
eventBus.subscribe('OrderPlaced', async (event) => {
await eventStoreRepository.save(event);
});
eventBus.subscribe('OrderConfirmed', async (event) => {
await eventStoreRepository.save(event);
});
eventBus.subscribe('OrderRejected', async (event) => {
await eventStoreRepository.save(event);
});
eventBus.subscribe('OrderPaid', async (event) => {
await eventStoreRepository.save(event);
});
eventBus.subscribe('OrderPaymentFailed', async (event) => {
await eventStoreRepository.save(event);
});
eventBus.subscribe('OrderShipped', async (event) => {
await eventStoreRepository.save(event);
});
eventBus.subscribe('OrderCancelled', async (event) => {
await eventStoreRepository.save(event);
});
eventBus.subscribe('InventoryReserved', async (event) => {
await eventStoreRepository.save(event);
});
eventBus.subscribe('InventoryReservationFailed', async (event) => {
await eventStoreRepository.save(event);
});
eventBus.subscribe('InventoryReleased', async (event) => {
await eventStoreRepository.save(event);
});
eventBus.subscribe('PaymentProcessed', async (event) => {
await eventStoreRepository.save(event);
});
eventBus.subscribe('PaymentFailed', async (event) => {
await eventStoreRepository.save(event);
});
eventBus.subscribe('ShipmentCreated', async (event) => {
await eventStoreRepository.save(event);
});

// Inventory listeners
// ── Extraction Configuration ────────────────────────────────
// Modules listed in EXTRACTED_MODULES run as independent services
// and are skipped during monolith listener registration.
const EXTRACTED_MODULES = new Set(
(process.env['EXTRACTED_MODULES'] || '')
.split(',')
.map((m) => m.trim().toLowerCase())
.filter(Boolean)
);

// ── Per-Module Registration Functions ───────────────────────
// Each module defines its own listener wiring. Adding a new module
// means adding one function and one entry to MODULE_REGISTRY.

function registerInventoryListeners(dataSource: DataSource, eventBus: EventBus): void {
const orderPlacedListener = new OrderPlacedListener(dataSource);
eventBus.subscribe('OrderPlaced', (event) => orderPlacedListener.handle(event));

Expand All @@ -90,7 +53,12 @@ export function registerListeners(dataSource: DataSource): void {
orderPaymentFailedListener.handle(event)
);

// Orders listeners
registerStockSyncWorker(async (data: any) => {
console.log(`[StockSync] Processing ${data.items.length} items from ${data.source}`);
});
}

function registerOrdersListeners(dataSource: DataSource, eventBus: EventBus): void {
const inventoryReservedListener = new InventoryReservedListener(dataSource);
eventBus.subscribe('InventoryReserved', (event) =>
inventoryReservedListener.handle(event)
Expand All @@ -109,11 +77,12 @@ export function registerListeners(dataSource: DataSource): void {

const shipmentCreatedListener = new ShipmentCreatedListener(dataSource);
eventBus.subscribe('ShipmentCreated', (event) => shipmentCreatedListener.handle(event));
}

// Payments listener (custom implementation to get order amount)
function registerPaymentsListeners(dataSource: DataSource, eventBus: EventBus): void {
const processPaymentHandler = new ProcessPaymentHandler(dataSource);
const getOrderHandler = new GetOrderHandler(dataSource);

eventBus.subscribe('OrderConfirmed', async (event) => {
const { orderId } = event.payload;
try {
Expand All @@ -127,9 +96,20 @@ export function registerListeners(dataSource: DataSource): void {
}
});

// Shipments listener (custom implementation to get shipping address)
registerPaymentProcessingWorker(async (data: any) => {
try {
await processPaymentHandler.handle(data);
} catch (error) {
console.error('Error processing payment via queue:', error);
throw error;
}
});
}

function registerShipmentsListeners(dataSource: DataSource, eventBus: EventBus): void {
const createShipmentHandler = new CreateShipmentHandler(dataSource);

const getOrderHandler = new GetOrderHandler(dataSource);

eventBus.subscribe('OrderPaid', async (event) => {
const { orderId } = event.payload;
try {
Expand All @@ -143,21 +123,49 @@ export function registerListeners(dataSource: DataSource): void {
}
});

// Register queue workers
registerLabelGenerationWorker();
registerPaymentProcessingWorker(async (data) => {
try {
await processPaymentHandler.handle(data);
} catch (error) {
console.error('Error processing payment via queue:', error);
throw error; // Re-throw to trigger retry
}
});
registerStockSyncWorker(async (data) => {
console.log(`[StockSync] Processing ${data.items.length} items from ${data.source}`);
// In production: iterate items and call UpdateProductStockHandler per SKU
});

console.log('✅ Event listeners registered');
}

// ── Module Registry ─────────────────────────────────────────
// Single place to add or remove modules. Extraction is controlled
// entirely by the EXTRACTED_MODULES environment variable.
const MODULE_REGISTRY: Record<string, (ds: DataSource, eb: EventBus) => void> = {
inventory: registerInventoryListeners,
orders: registerOrdersListeners,
payments: registerPaymentsListeners,
shipments: registerShipmentsListeners,
};

// ── Event Store (always active) ─────────────────────────────
const ALL_EVENTS = [
'OrderPlaced', 'OrderConfirmed', 'OrderRejected',
'OrderPaid', 'OrderPaymentFailed', 'OrderShipped', 'OrderCancelled',
'InventoryReserved', 'InventoryReservationFailed', 'InventoryReleased',
'PaymentProcessed', 'PaymentFailed', 'ShipmentCreated',
];

// ── Orchestrator ────────────────────────────────────────────
export function registerListeners(dataSource: DataSource): void {
const eventBus = EventBus.getInstance();
const eventStoreRepository = new EventStoreRepository(dataSource);

// Event store captures all events regardless of extraction
for (const eventName of ALL_EVENTS) {
eventBus.subscribe(eventName, (event) => eventStoreRepository.save(event));
}

// Register each non-extracted module
for (const [moduleName, register] of Object.entries(MODULE_REGISTRY)) {
if (EXTRACTED_MODULES.has(moduleName)) {
console.log(`⏭️ ${moduleName} listeners skipped (module extracted)`);
continue;
}
register(dataSource, eventBus);
}

if (EXTRACTED_MODULES.size > 0) {
console.log(`✅ Event listeners registered (extracted: ${[...EXTRACTED_MODULES].join(', ')})`);
} else {
console.log('✅ Event listeners registered');
}
}
44 changes: 44 additions & 0 deletions apps/api/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* L3 Extraction Middleware
*
* When EXTRACTED_MODULES is set (e.g., "orders"), the monolith
* rejects requests to extracted module routes with 410 Gone.
* A reverse proxy (nginx, API gateway) forwards these to the
* extracted service; this middleware is the safety net.
*
* This demonstrates that extraction is a configuration change,
* not a code change.
*/
import { NextRequest, NextResponse } from 'next/server';

const EXTRACTED_MODULES = (process.env['EXTRACTED_MODULES'] || '')
.split(',')
.map((m) => m.trim().toLowerCase())
.filter(Boolean);

const MODULE_ROUTE_PREFIXES: Record<string, string> = {
orders: '/api/orders',
inventory: '/api/inventory',
// Future: payments, shipments, etc.
};

export function middleware(request: NextRequest) {
for (const mod of EXTRACTED_MODULES) {
const prefix = MODULE_ROUTE_PREFIXES[mod];
if (prefix && request.nextUrl.pathname.startsWith(prefix)) {
return NextResponse.json(
{
error: 'Gone',
message: `The ${mod} module has been extracted to a dedicated service.`,
service: `${mod}-service`,
},
{ status: 410 }
);
}
}
return NextResponse.next();
}

export const config = {
matcher: '/api/:path*',
};
18 changes: 18 additions & 0 deletions apps/orders-service/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Orders Service — L3 Extraction
# Uses the same libs/modules/orders code as the monolith.
# Only the entry point and database connection differ.

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx tsc -p apps/orders-service/tsconfig.json

FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist/apps/orders-service ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 3001
CMD ["node", "dist/apps/orders-service/src/main.js"]
20 changes: 20 additions & 0 deletions apps/orders-service/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "orders-service",
"root": "apps/orders-service",
"sourceRoot": "apps/orders-service/src",
"projectType": "application",
"targets": {
"build": {
"executor": "nx:run-commands",
"options": {
"command": "npx tsc -p apps/orders-service/tsconfig.json"
}
},
"serve": {
"executor": "nx:run-commands",
"options": {
"command": "npx ts-node -r tsconfig-paths/register apps/orders-service/src/main.ts"
}
}
}
}
Loading