ice` with capability interfaces that express what the workflow needs, not how it's implemented.
// contracts/identity.capability.ts
export interface IdentityCapability {
reserveEmail(email: string): Promise<OperationResult<UserId, IdentityError>>;
persistProfile(data: UserProfilePayload): Promise<OperationResult<UserRecord, IdentityError>>;
}
// contracts/risk.capability.ts
export interface RiskCapability {
evaluateDevice(deviceId: string): Promise<OperationResult<DeviceScore, RiskError>>;
validateNetwork(ip: string): Promise<OperationResult<NetworkStatus, RiskError>>;
}
// contracts/referral.capability.ts
export interface ReferralCapability {
resolveCode(code: string): Promise<OperationResult<ReferralLink, ReferralError>>;
linkAccounts(referrerId: UserId, newUserId: UserId): Promise<OperationResult<ReferralRecord, ReferralError>>;
}
Step 2: Implement an Explicit Workflow Handler
The workflow replaces the orchestrator method. It executes steps sequentially, maps errors to HTTP boundaries, and manages transaction scope explicitly.
// workflows/registration.workflow.ts
import { OperationResult, ok, err } from '../shared/result';
export class RegistrationWorkflow {
constructor(
private readonly identity: IdentityCapability,
private readonly risk: RiskCapability,
private readonly referral: ReferralCapability,
private readonly analytics: AnalyticsCapability,
) {}
async execute(payload: RegistrationRequest): Promise<OperationResult<RegistrationResponse, WorkflowError>> {
// 1. Risk evaluation (read-only, no transaction needed)
const deviceCheck = await this.risk.evaluateDevice(payload.deviceId);
if (deviceCheck.isFailure()) return err(WorkflowError.RISK_CHECK_FAILED);
const networkCheck = await this.risk.validateNetwork(payload.ip);
if (networkCheck.isFailure()) return err(WorkflowError.RISK_CHECK_FAILED);
// 2. Referral resolution (read-only)
let referralLink: ReferralLink | null = null;
if (payload.referralCode) {
const linkResult = await this.referral.resolveCode(payload.referralCode);
if (linkResult.isFailure()) return err(WorkflowError.INVALID_REFERRAL);
referralLink = linkResult.value;
}
// 3. Transactional boundary: identity creation + referral linking
const txResult = await this.identity.reserveEmail(payload.email);
if (txResult.isFailure()) return err(WorkflowError.EMAIL_TAKEN);
const userRecord = await this.identity.persistProfile({
email: payload.email,
hashedPassword: payload.passwordHash,
source: payload.adSource,
riskProfile: { ip: payload.ip, deviceId: payload.deviceId },
});
if (userRecord.isFailure()) return err(WorkflowError.PERSISTENCE_FAILED);
const newUser = userRecord.value;
// 4. Post-creation side effects (idempotent, fire-and-forget safe)
if (referralLink) {
await this.referral.linkAccounts(referralLink.ownerId, newUser.id);
}
await this.analytics.trackEvent('user.registered', {
userId: newUser.id,
source: payload.adSource,
riskScore: deviceCheck.value.score,
});
return ok({ userId: newUser.id, email: newUser.email });
}
}
Step 3: Map Workflow Errors at the Transport Boundary
The workflow returns explicit results. The controller or NestJS interceptor translates them to HTTP responses. This keeps business logic free from transport concerns.
// adapters/http/registration.controller.ts
@Controller('auth')
export class RegistrationController {
constructor(private readonly workflow: RegistrationWorkflow) {}
@Post('register')
async handle(@Body() payload: RegistrationRequest): Promise<ApiResponse> {
const result = await this.workflow.execute(payload);
if (result.isSuccess()) {
return { status: 201, data: result.value };
}
const errorMap: Record<WorkflowError, number> = {
[WorkflowError.EMAIL_TAKEN]: 409,
[WorkflowError.INVALID_REFERRAL]: 400,
[WorkflowError.RISK_CHECK_FAILED]: 403,
[WorkflowError.PERSISTENCE_FAILED]: 500,
};
const httpStatus = errorMap[result.error] ?? 500;
throw new HttpException(result.error, httpStatus);
}
}
Architecture Decisions & Rationale
- Capabilities over Services: Capabilities define intent (
reserveEmail, evaluateDevice) rather than implementation details. This prevents domain leakage and makes mocking trivial.
- Explicit Transaction Boundaries: The workflow identifies where ACID guarantees are required (user creation + referral linking) and isolates them. Read-only steps run outside the transaction, reducing lock contention.
- Result Pattern at Boundaries: Using
OperationResult<T, E> forces explicit error handling. The compiler prevents silent failures, and the workflow remains pure business logic.
- Idempotent Side Effects: Analytics and bonus accrual are decoupled from the core transaction. If they fail, the user is still created. This matches production reality where observability shouldn't block core flows.
Pitfall Guide
1. The Orchestrator God Object
Explanation: The orchestrator accumulates conditional logic, error mapping, and execution order knowledge. It becomes the single point of failure for refactoring.
Fix: Extract execution order into a workflow handler. Use a step-based pipeline or explicit state machine. Keep the orchestrator thin or eliminate it entirely.
2. Implicit Transaction Boundaries
Explanation: Developers assume each service manages its own transaction. Cross-service flows end up with partial commits when intermediate steps fail.
Fix: Define transaction scope at the workflow level. Use database transactions explicitly (BEGIN/COMMIT/ROLLBACK) or leverage NestJS transactional decorators that span multiple capability calls.
3. Capability Leakage
Explanation: Extracted services reach into each other's data or call internal methods not exposed in the contract. This recreates coupling under a new name.
Fix: Enforce strict interface boundaries. Use dependency injection to inject only the capability contract, not the concrete implementation. Run static analysis to detect cross-module imports.
4. Error Handling Fragmentation
Explanation: Mixing throw, Result, and null returns creates inconsistent error paths. The orchestrator spends more time translating errors than executing logic.
Fix: Standardize on a single error representation across the feature boundary. Use discriminated unions or a Result monad. Map to HTTP status codes only at the transport layer.
5. Testing the Wiring, Not the Flow
Explanation: Unit tests mock every service and verify the orchestrator calls them in order. This tests implementation details, not business behavior.
Fix: Write behavior-driven tests against the workflow. Provide fake capability implementations that return predefined results. Assert on final state and side effects, not call counts.
6. Ignoring Idempotency in Distributed Steps
Explanation: Retry mechanisms or message queues cause duplicate execution. Without idempotency keys, bonuses are double-awarded or analytics are duplicated.
Fix: Attach unique correlation IDs to workflow executions. Design side-effect capabilities to be idempotent by checking existing records before creating.
7. Over-Engineering with Premature Microservices
Explanation: Teams split services into separate deployables before the domain is stable. Network latency, distributed transactions, and versioning complexity explode.
Fix: Keep capabilities in a single deployable unit until scaling demands otherwise. Use modular monolith patterns with clear bounded contexts. Split only when independent deployment velocity is proven necessary.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single feature with 3β5 domain interactions | Explicit Workflow + Capabilities | Keeps transactional integrity, reduces coupling | Low (refactoring only) |
| Cross-feature events requiring async processing | Event-Driven Workflow + Message Queue | Decouples execution, enables scaling | Medium (infrastructure + serialization) |
| High-throughput registration with external risk APIs | Async Workflow + Retry + Idempotency | Handles latency, prevents duplicate state | Medium-High (queue + monitoring) |
| Legacy monolith with tight service coupling | Strangler Fig + Capability Wrappers | Gradual migration without rewrite | High (phased effort) |
Configuration Template
// modules/registration/registration.module.ts
import { Module } from '@nestjs/common';
import { RegistrationWorkflow } from './workflows/registration.workflow';
import { IdentityCapability } from './contracts/identity.capability';
import { RiskCapability } from './contracts/risk.capability';
import { ReferralCapability } from './contracts/referral.capability';
import { AnalyticsCapability } from './contracts/analytics.capability';
import { IdentityRepository } from './infrastructure/identity.repository';
import { RiskEngineAdapter } from './infrastructure/risk.engine';
import { ReferralStore } from './infrastructure/referral.store';
import { AnalyticsTracker } from './infrastructure/analytics.tracker';
@Module({
providers: [
RegistrationWorkflow,
{ provide: IdentityCapability, useClass: IdentityRepository },
{ provide: RiskCapability, useClass: RiskEngineAdapter },
{ provide: ReferralCapability, useClass: ReferralStore },
{ provide: AnalyticsCapability, useClass: AnalyticsTracker },
],
exports: [RegistrationWorkflow],
})
export class RegistrationModule {}
Quick Start Guide
- Identify the feature boundary: List all operations triggered by a single user action (e.g., registration). Group them by transactional requirement.
- Define capability contracts: Create interfaces that express intent, not implementation. Place them in a
contracts/ directory.
- Build the workflow handler: Implement a class that executes steps sequentially, manages transaction scope, and returns explicit results.
- Wire dependencies: Register capabilities in your NestJS module. Inject the workflow into your controller or command handler.
- Test the flow: Write integration tests using fake capability implementations. Verify final state, error mapping, and side-effect execution.