# ERP Module Architecture Blueprint

A standardized, production-ready architecture for building decoupled domain modules in the ERP system. Based on the Purchases module implementation.

## Overview

Each module follows **Port-Based Clean Architecture** with strategic dependency injection at the service level. This ensures:
- Testability through interface-based dependencies
- Flexibility to swap implementations (JSON ↔ Postgres)
- Minimal coupling to legacy codebase
- Clear separation of concerns (domain → application → infrastructure)

---

## Module Structure

```
lib/modules/{DOMAIN}/
├── domain/
│   ├── {entity}.model.ts          # Commands, domain types, enums
│   └── {entity}.domain.ts          # Pure business logic, validators, calculators
├── application/
│   ├── {entity}.repository.ts      # Repository interface (contract)
│   ├── {entity}-*-port.ts          # Ports: accounting, side-effects, reference-data, id-generator
│   ├── {entity}-application-service.ts  # Main orchestrator (composition root)
│   └── {entity}.mapper.ts          # DTO mapping at boundary
└── infrastructure/
    ├── json-{entity}.repository.ts
    ├── postgres-{entity}.repository.ts
    ├── {entity}.repository.factory.ts
    ├── default-{entity}-*-port.ts  # Port adapters
    └── {legacy-helper-name}.ts     # Legacy function wrappers if needed

tests/
└── {domain}/
    ├── {entity}-domain.test.ts          # Domain unit tests
    ├── {entity}-repository.contract.test.ts  # Repository parity tests
    └── {entity}-application-service.test.ts  # Service integration tests
```

---

## Core Ports (Required for Every Module)

A module **must** implement at minimum these ports:

### 1. **Repository Port**
```typescript
export interface I{Entity}Repository {
  // CRUD + core lookups
  find{Entity}ById(id: string): Promise<{Entity} | null>;
  list{Entities}(): Promise<{Entity}[]>;
  create{Entity}(record: Create{Entity}Record): Promise<{Entity}>;
  
  // Domain-specific queries (1-2 methods)
  // Example: findPurchaseOrderBySupplierInvoice()
}
```

**Must handle:**
- Both JSON and Postgres backends
- ID generation pre-passed from service layer
- Consistent error handling (no exceptions on validation, return discriminated unions)

---

### 2. **ID Generator Port** (Standard Template)
```typescript
export interface I{Domain}IdGeneratorPort {
  generate{Entity}Id(): string;
}
```

**Default Implementation:**
- Wraps `nextPrefixedId('{prefix}')` from legacy data.ts
- Never changes ID format; ensures database compatibility
- Single implementation required (rarely overridden)

---

### 3. **Side Effects Port** (Domain-Specific)
```typescript
export interface I{Domain}SideEffectsPort {
  // Domain-specific operations triggered on successful persistence
  // Example: endorseIncomingChecks(), applyInventory(), syncPrices()
  async operation1(input: SomeInput): Promise<void>;
  async operation2(input: AnotherInput): Promise<OutputType>;
}
```

**Guidance:**
- Async only (operations are I/O-bound)
- Swallows errors internally with logging (production resilience)
- Each method is one business capability
- Invoked **after** persistence succeeds

---

### 4. **Accounting Port** (Event-Based)
```typescript
export interface I{Domain}AccountingPort {
  post{Entity}JournalEntry(input: {Entity}JournalInput): Promise<void>;
}
```

**Pattern:**
- Single method per entity type
- Wrapped legacy journal function
- Async to allow future async journal backends
- Invoked on posting status transition

---

### 5. **Reference Data Port** (Shared Lookups)
```typescript
export interface I{Domain}ReferenceDataPort {
  // Settings & configuration
  getSettings(): Record<string, unknown>;
  getDefaultBaseCurrencyCode(): string;
  
  // Lookups for tax, currency conversion, period locks
  getTaxRateFor{Entity}(id: string): number;
  convertCurrency(amount: number, from: string, to: string): number;
  
  // ID generation helper (NOT the main ID generator)
  nextPrefixedId(prefix: string): string;
  
  // Period/close date validation
  assertNotLockedByPeriod(date: string, ...closeDates: string[]): void;
  
  // Async lookups (lazy-loaded reference data)
  async get{ReferenceData}ForCalc(): Promise<ReferenceData[]>;
}
```

**Guidance:**
- Read-only (lookups, configuration, utilities)
- Mix sync/async methods as needed
- Default implementation wraps legacy data.ts functions
- Enables service-level testability via mocking

---

## Application Service Pattern

### Constructor (Composition Root)
```typescript
export class {Entity}ApplicationService {
  constructor(
    private readonly repository: I{Entity}Repository,
    private readonly sideEffects: I{Domain}SideEffectsPort,
    private readonly referenceData: I{Domain}ReferenceDataPort = createDefault{Domain}ReferenceDataPort(),
    private readonly accounting: I{Domain}AccountingPort = createDefault{Domain}AccountingPort(),
    private readonly idGenerator: I{Domain}IdGeneratorPort = createDefault{Domain}IdGeneratorPort()
  ) {}

  static createDefault(): {Entity}ApplicationService {
    return new {Entity}ApplicationService(
      create{Entity}Repository(),
      createDefault{Domain}SideEffectsPort(),
      createDefault{Domain}ReferenceDataPort(),
      createDefault{Domain}AccountingPort(),
      createDefault{Domain}IdGeneratorPort()
    );
  }
}
```

**Key Points:**
- All ports injected (even if using defaults)
- Static factory for action layer
- Default dependencies are Optionals with sensible defaults

### Main Orchestration Method
Name pattern: `async {verb}{Entity}(...command, actor): Promise<Result>`

Example: `async createPurchaseOrder(command, actor): Promise<CreatePurchaseOrderResult>`

**Flow:**
1. **Validate** (no side effects)
2. **Generate ID** from idGenerator port
3. **Persist** to repository with generated ID
4. **Execute side effects** (inventory, checks, transfers, etc.)
5. **Post accounting** if posting status is active
6. **Return** discriminated union (success | error)

---

## Dependency Flow

```
Service (orchestrator)
  ↓ delegates to
Repository (persistence layer)
Side Effects (async operations)
Accounting (journal posting)
Reference Data (lookups)
ID Generator (identity)
  ↓ all depend on
Default port adapters → legacy lib/data.ts functions
```

**Isolation Guarantee:**
- Infrastructure doesn't import service or other repositories
- Application layer imports domain + infrastructure (for adapters, not logic)
- Tests mock all ports; no real I/O

---

## Domain Layer (Pure Business Logic)

### Commands (Input Types)
```typescript
export type {Entity}CreateCommand = {
  // User-provided data
  field1: string;
  field2: number;
  // Derived calculations happen in domain, not in command
};
```

### Domain Types & Enums
```typescript
export type PostingStatus = 'draft' | 'saved' | 'posted';
export type WorkflowStatus = 'active' | 'archived' | 'cancelled';

export type TaxCalculationDetails = {
  method: 'line-level' | 'invoice-level' | 'tax-exempt';
  rate: number;
  amount: number;
};
```

### Domain Functions (Calculators, Validators)
```typescript
// Pure functions (no I/O, no state)
export function calculate{Entity}Totals(command: {Entity}CreateCommand): CalculatedTotals { }
export function validate{Entity}StatusTransition(from: Status, to: Status): ValidationResult { }
export function get{Entity}StatusLabel(status: Status): string { }
```

**Rule:**
- No imports from infrastructure
- No async operations
- Return discriminated unions for validation results (not exceptions)
- Easily testable in isolation

---

## Mapper Layer

Single file: `{entity}.mapper.ts`

Purpose: DTO ↔ Command conversion at action boundary

```typescript
export function map{Service}DtoToCommand(dto: FormData, baseCurrency: string): {Entity}Command {
  // Normalize, filter, type-check user input
  // Return typed command for service
}

export function map{Entity}ToResponse(entity: {Entity}): {Entity}ResponseDto {
  // Transform entity to action response shape
}
```

---

## Repository Adapter Pattern

### JSON Adapter
```typescript
export class Json{Entity}Repository implements I{Entity}Repository {
  async create{Entity}(record: Create{Entity}Record): Promise<{Entity}> {
    // Call legacy helper: legacy{Entity}Create(record, { id: record.id })
    // Preserve all existing ID, orderNumber, status generation
  }
  
  async find{Entity}ById(id: string): Promise<{Entity} | null> {
    // Call: legacy{Entity}List().find(...)
  }
}
```

### Postgres Adapter
```typescript
export class Postgres{Entity}Repository implements I{Entity}Repository {
  async create{Entity}(record: Create{Entity}Record): Promise<{Entity}> {
    // Build PG object with record.id (pre-generated)
    // Use pgSave{Entities}([entity])
  }
  
  async find{Entity}ById(id: string): Promise<{Entity} | null> {
    // Call pgGet{Entities}({ page: 1, pageSize: 5000 })
    // Find in results (simple list scan for now)
  }
}
```

**Important:**
- Both adapters receive pre-generated ID from service
- Neither generates ID internally
- Both follow same interface contract

### Factory Pattern
```typescript
export function create{Entity}Repository(): I{Entity}Repository {
  if (isPostgresProviderEnabled()) {
    return new Postgres{Entity}Repository();
  }
  return new Json{Entity}Repository();
}
```

---

## Port Adapter Pattern

### Accounting Adapter
```typescript
export class Default{Domain}AccountingPort implements I{Domain}AccountingPort {
  async post{Entity}JournalEntry(input: {Entity}JournalInput): Promise<void> {
    try {
      legacy{Entity}Journal(input.field1, input.field2, ...);
    } catch (error) {
      console.error('Failed to post journal:', error);
      // Swallow error; journaling failure ≠ transaction failure
    }
  }
}
```

**Error Handling:**
- Don't throw on adapter errors
- Log for monitoring/debugging
- Service proceeds (journal is audit trail, not critical path)

### Side Effects Adapter (Default)
```typescript
export class Default{Domain}SideEffectsPort implements I{Domain}SideEffectsPort {
  async operation1(input): Promise<void> {
    try {
      legacyOperation1(input.field1, input.field2);
    } catch (error) {
      console.error('Failed to execute side effect:', error);
    }
  }
}
```

---

## Testing Strategy

### 1. **Domain Tests** (Unit)
```typescript
// tests/{domain}/{entity}-domain.test.ts
test('calculate totals with line-level tax', () => {
  const result = calculatePurchaseTotals(command);
  assert.equal(result.taxAmount, 10);
  assert.equal(result.grandTotal, 110);
});

test('status transition validation blocks invalid states', () => {
  const result = validateStatusTransition('posted', 'draft');
  assert.equal('error' in result, true);
});
```

**What:** Pure functions only
**Run:** Always, no env flags, no DB setup

---

### 2. **Repository Contract Tests** (Integration)
```typescript
// tests/{domain}/{entity}-repository.contract.test.ts
async function runRepositoryContractSuite(name, createRepo) {
  test(`${name}: create + getById + list contract`, async () => {
    const repo = createRepo();
    
    const payload = createRecordPayload({ id: 'test-id-1' });
    const created = await repo.create{Entity}(payload);
    
    assert.equal(created.id, payload.id, 'repository preserves pre-generated id');
    
    const fetched = await repo.get{Entity}ById(created.id);
    assert.ok(fetched, 'created entity retrievable');
  });
}

runRepositoryContractSuite('json', () => new Json{Entity}Repository());
if (process.env.RUN_PG_CONTRACT_TESTS === '1') {
  runRepositoryContractSuite('postgres', () => new Postgres{Entity}Repository());
}
```

**What:** Both adapters must pass identical contract
**Why:** Ensures no data loss on backend switch
**Run:** JSON always; Postgres gated by env flag

---

### 3. **Service Tests** (Integration)
```typescript
// tests/{domain}/{entity}-application-service.test.ts

function createRepositoryMock(): I{Entity}Repository { /* mock impl */ }
function createSideEffectsMock(): I{Domain}SideEffectsPort { /* mock impl */ }
function createReferenceDataMock(): I{Domain}ReferenceDataPort { /* mock impl */ }
// ... other mocks

test('draft {entity} -> no side effects executed', async () => {
  const service = new {Entity}ApplicationService(repo, side, ref, acct, idGen);
  const result = await service.create{Entity}(draftCommand, actor);
  
  assert.equal('success' in result, true);
  assert.deepEqual(sideCalls, []); // No side effects for draft
});

test('posted {entity} -> all side effects executed in order', async () => {
  const service = new {Entity}ApplicationService(repo, side, ref, acct, idGen);
  const result = await service.create{Entity}(postedCommand, actor);
  
  assert.equal('success' in result, true);
  assert.deepEqual(sideCalls, ['op1', 'op2', 'op3']); // Exact order
});
```

**What:** Mocked repository, side effects, ports; full service flow
**Why:** Verify orchestration without DB
**Run:** Always, no env flags needed

---

## Conventions

### Naming

| Element | Pattern | Example |
|---------|---------|---------|
| Domain type | `{Entity}CreateCommand` | `PurchaseOrderCreateCommand` |
| Domain function | `calculate{Entity}Totals()` | `calculatePurchasetTotals()` |
| Repository interface | `I{Entity}Repository` | `IPurchaseOrderRepository` |
| Port interface | `I{Domain}{Purpose}Port` | `IPurchaseAccountingPort` |
| Default adapter | `Default{Domain}{Purpose}Port` | `DefaultPurchaseAccountingPort` |
| Service | `{Entity}ApplicationService` | `PurchaseOrderApplicationService` |
| Result type | `{Action}{Entity}Result` | `CreatePurchaseOrderResult` |

### File Naming
- Domain files: `{entity}.model.ts`, `{entity}.domain.ts`
- Application ports: `{entity}-{purpose}-port.ts`
- Infrastructure adapters: `default-{entity}-{purpose}-port.ts`, `{type}-{entity}.repository.ts`
- Tests: `{entity}-{layer}.test.ts`

### Prefix Codes
- Purchase Order: `po`
- Purchase Log: `po-log`
- Postgres convention: Always 2-3 letters, lowercase: `po`, `so`, `inv`, `cr`

---

## Shared Concerns (Cross-Cutting)

### What Stays in `lib/`
- `lib/data.ts`: Legacy JSON read/write helpers
- `lib/postgres/`: SQL client + query builders
- `lib/types.ts`: Shared domain types (Customer, Supplier, Invoice, etc.)
- `lib/config.ts`: Environment flags (DB provider, feature gates)
- `lib/auth.ts`: Session & permission checks

### What Moves to Module
- Business logic (calculations, validations) → domain/
- Orchestration (sequencing, error handling) → application service
- Backend adapters (JSON/Postgres) → infrastructure/

### Module ↔ Action Layer Contract
```typescript
// action layer uses service
const service = {Entity}ApplicationService.createDefault();
const result = await service.create{Entity}(mapped command, actor);

// service returns result, not entity
if ('error' in result) {
  return { error: result.error };
}

// action layer handles revalidation/cache clearing
revalidatePath('/admin/{domain}/{entities}');
return { success: true, ...result };
```

---

## Standardized Package.json Scripts

```json
{
  "scripts": {
    "test:{domain}": "tsx --test tests/{domain}/**/*.test.ts",
    "test:{domain}:domain": "tsx --test tests/{domain}/{entity}-domain.test.ts",
    "test:{domain}:contracts": "tsx --test tests/{domain}/*-repository.contract.test.ts",
    "test:{domain}:service": "tsx --test tests/{domain}/{entity}-application-service.test.ts"
  }
}
```

---

## Migration Path (From Legacy)

### Step 1: Extract Domain Model
- Define command types, enums, validators
- Move pure business logic from actions.ts into domain functions
- Add unit tests

### Step 2: Create Service Skeleton
- Define repository interface with minimal CRUD
- Create application service with single orchestrator method
- Add mock implementations for testing

### Step 3: Build Adapters
- Implement JSON adapter (wrapping legacy functions)
- Implement Postgres adapter (wrapping pgGet/pgSave)
- Add repository contract tests

### Step 4: Abstract Dependencies
- Extract reference-data lookups → ReferenceDataPort
- Extract side effects → SideEffectsPort
- Extract ID generation → IdGeneratorPort
- Extract accounting operations → AccountingPort

### Step 5: Wire Action Layer
- Update action functions to use service
- Preserve response contracts (no breaking changes)
- Route revalidation through action, not service

### Step 6: Expand Service
- Add update/delete orchestration methods as needed
- Test new flows with same port pattern
- Future-proof with event publishing (if needed)

---

## Anti-Patterns to Avoid

❌ **Don't:**
- Pass raw PurchaseOrder entity to ports (use POJOs + interfaces)
- Let repository generate IDs (service responsibility)
- Throw exceptions in domain layer (use discriminated unions)
- Make side effects blocking (services should not wait for journal posts)
- Import service from infrastructure (go through factory instead)
- Skip repository contract tests (ensure backend parity)
- Nest port dependencies more than 1 level deep

✅ **Do:**
- Keep domain functions pure (no I/O, no state mutation)
- Test ports with mocks (no real DB in unit tests)
- Make each port a single concern (UnidResponsibility Principle)
- Return discriminated unions for errors (not exceptions)
- Log errors in adapters, swallow them for resilience
- Keep default adapters thin (thin wrapper over legacy functions)
- Document port contracts clearly

---

## Checklist for New Module

- [ ] Domain model types & enums defined
- [ ] Domain validator & calculator functions (pure, testable)
- [ ] Repository interface with CRUD + 1-2 domain queries
- [ ] 5 Required ports (repository, accounting, side-effects, reference-data, id-generator)
- [ ] Application service with constructor DI + static createDefault()
- [ ] JSON adapter (wrapping legacy helpers)
- [ ] Postgres adapter (wrapping pg functions)
- [ ] Repository factory (DB provider check)
- [ ] 5 Default port adapters (thin wrappers)
- [ ] Mapper for DTO → Command at boundary
- [ ] Domain unit tests (100% domain functions)
- [ ] Repository contract tests (both JSON + Postgres)
- [ ] Service integration tests (mocked ports)
- [ ] package.json test scripts added
- [ ] Action layer wired to service (no breaking changes)

---

## Resources

- **Purchases Module:** Reference implementation (`lib/modules/purchases/`)
- **Domain Layer:** `purchase.model.ts`, `purchase.domain.ts`
- **Application Ports:** `purchase-*-port.ts` files
- **Infrastructure:** `json-purchase-order.repository.ts`, `postgres-purchase-order.repository.ts`
- **Tests:** `tests/purchases/` for patterns & examples
