PR-002.5: Application Layer — Command/Result pattern, end-to-end flow

- 8 commands (CreateFieldSession, CreateMission, RegisterArtifact,
  CreateObservation, CreateDecisionCase, ApproveDecision, StartReview,
  CompleteReview)
- 4 handlers with validation and orchestration
- Result<T, E> pattern — explicit success/failure, no exceptions
- End-to-end test: Session → Mission → DecisionCase → Approval
- 3 tests proving full flow works in memory
- ADR-007: Command/Result Pattern

Acceptance Criteria:
 CreateFieldSession → CreateMission → CreateDecisionCase → ApproveDecision
 Without PostgreSQL, Redis, S3, API, HTTP, UI
 Business logic verified before infrastructure attached

Next: PR-003 — PostgreSQL adapters (swap InMemory → Postgres)
This commit is contained in:
Bernt
2026-07-02 15:06:59 +00:00
parent 02b51d7814
commit eab9da0100
15 changed files with 665 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
coverage/
*.log
+60
View File
@@ -0,0 +1,60 @@
# @landvex/application
Application layer — orchestrates domain operations.
## Architecture
```
UI → Application Layer (commands/handlers) → Domain → Repository Interfaces → Adapters
```
**No business logic here. Only orchestration.**
## Commands
| Command | Purpose |
|---------|---------|
| `CreateFieldSessionCommand` | Start a new field session |
| `CreateMissionCommand` | Create a mission within a session |
| `RegisterArtifactCommand` | Register an artifact |
| `CreateObservationCommand` | Create an observation |
| `CreateDecisionCaseCommand` | Create a decision case |
| `ApproveDecisionCommand` | Approve a decision |
| `StartReviewCommand` | Start a review |
| `CompleteReviewCommand` | Complete a review |
## Handlers
| Handler | Command | Result |
|---------|---------|--------|
| `CreateFieldSessionHandler` | `CreateFieldSessionCommand` | `Result<{ session }>` |
| `CreateMissionHandler` | `CreateMissionCommand` | `Result<{ mission }>` |
| `CreateDecisionCaseHandler` | `CreateDecisionCaseCommand` | `Result<{ decisionCase }>` |
| `ApproveDecisionHandler` | `ApproveDecisionCommand` | `Result<{ decisionCase }>` |
## Result Pattern
```typescript
const result = await handler.execute(command);
if (result.success) {
// Use result.data
} else {
// Handle result.error
}
```
## End-to-End Test
`use-cases/end-to-end-flow.test.ts` proves the full flow works in memory:
```
CreateFieldSession → CreateMission → CreateDecisionCase → ApproveDecision
```
Without PostgreSQL, Redis, S3, API, HTTP, or UI.
## Dependencies
- `@landvex/domain` — domain objects and factories
- `@landvex/infrastructure` — repository interfaces and in-memory adapters
@@ -0,0 +1,38 @@
# ADR-007: Command/Result Pattern
## Status
Accepted
## Context
We need a clear way to express intent to change state, execute business operations, and handle failures without exceptions.
## Decision
Use Command/Result pattern:
- **Command**: Plain object (DTO) containing all data needed to execute
- **Handler**: Contains orchestration logic, calls domain factories
- **Result**: Explicit success/failure, no exceptions for business errors
```
CreateMissionCommand
CreateMissionHandler
Result<MissionCreatedResult>
```
## Consequences
### Positive
- Clear intent: commands are named after use cases
- Testable: handlers are pure functions with injected repositories
- No exceptions for business logic
- Audit trail: commands can be logged
- Async-friendly
### Negative
- More boilerplate than direct service calls
- Need to handle Result type at every call site
## Related
- ADR-006: In-Memory Adapters for Testing
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
};
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@landvex/application",
"version": "0.1.0",
"description": "Application layer and use cases for LandveX domain",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@landvex/domain": "file:../domain",
"@landvex/infrastructure": "file:../infrastructure"
},
"devDependencies": {
"@types/jest": "^29.5.0",
"@types/node": "^20.0.0",
"jest": "^29.5.0",
"ts-jest": "^29.1.0",
"typescript": "^5.3.0"
}
}
@@ -0,0 +1,77 @@
/**
* Commands — intent to change state
*
* A command is a request to do something.
* It contains all data needed to execute.
* It does NOT contain business logic.
*
* ADR-007: Command/Result pattern
* - Commands are plain objects (DTOs)
* - Handlers contain orchestration logic
* - Results are explicit (success/failure)
*/
import { GeoLocation, DeviceInfo } from '@landvex/domain';
// Session commands
export interface CreateFieldSessionCommand {
readonly inspector: string;
readonly date: Date;
readonly location: GeoLocation;
readonly areaId: string;
}
// Mission commands
export interface CreateMissionCommand {
readonly sessionId: string;
readonly location: GeoLocation;
readonly device: DeviceInfo;
}
// Artifact commands
export interface RegisterArtifactCommand {
readonly missionId: string;
readonly type: 'image' | 'video' | 'audio' | 'dataset';
readonly storageUri: string;
readonly hash: string;
readonly createdBy: string;
}
// Observation commands
export interface CreateObservationCommand {
readonly missionId: string;
readonly artifactId: string;
readonly description: string;
}
// DecisionCase commands
export interface CreateDecisionCaseCommand {
readonly missionId: string;
readonly title: string;
readonly description: string;
readonly priority: 'low' | 'medium' | 'high' | 'critical';
readonly confidence: 'low' | 'medium' | 'high' | 'certain';
readonly recommendedAction: string;
readonly observationIds: string[];
readonly evidenceIds: string[];
readonly findingId: string;
readonly decisionId: string;
readonly reviewId: string;
}
export interface ApproveDecisionCommand {
readonly decisionCaseId: string;
readonly approvedBy: string;
}
// Review commands
export interface StartReviewCommand {
readonly decisionCaseId: string;
readonly reviewer: string;
}
export interface CompleteReviewCommand {
readonly reviewId: string;
readonly verdict: 'approved' | 'rejected' | 'needs_more_evidence';
readonly comment?: string;
}
@@ -0,0 +1,39 @@
/**
* ApproveDecision Handler
*
* Orchestrates: ApproveDecisionCommand → Approved DecisionCase
*/
import { DecisionCaseFactory, DecisionCase } from '@landvex/domain';
import { DecisionCaseRepository } from '@landvex/infrastructure';
import { ApproveDecisionCommand } from '../commands/commands';
import { Result, ok, fail, NotFoundError, ValidationError } from '../results/results';
export interface ApproveDecisionResult {
readonly decisionCase: DecisionCase;
}
export class ApproveDecisionHandler {
constructor(private readonly decisionCases: DecisionCaseRepository) {}
async execute(command: ApproveDecisionCommand): Promise<Result<ApproveDecisionResult>> {
// Find
const decisionCase = await this.decisionCases.findById(command.decisionCaseId as any);
if (!decisionCase) {
return fail(NotFoundError('DecisionCase', command.decisionCaseId));
}
// Validation
if (!decisionCase.reviewId) {
return fail(ValidationError('DecisionCase cannot be approved without review'));
}
// Business logic (domain factory)
const approved = DecisionCaseFactory.approve(decisionCase);
// Persist
await this.decisionCases.save(approved);
return ok({ decisionCase: approved });
}
}
@@ -0,0 +1,52 @@
/**
* CreateDecisionCase Handler
*
* Orchestrates: CreateDecisionCaseCommand → DecisionCase
*/
import { DecisionCaseFactory, IdFactory, DecisionCase } from '@landvex/domain';
import { DecisionCaseRepository } from '@landvex/infrastructure';
import { CreateDecisionCaseCommand } from '../commands/commands';
import { Result, ok, fail, ValidationError } from '../results/results';
export interface CreateDecisionCaseResult {
readonly decisionCase: DecisionCase;
}
export class CreateDecisionCaseHandler {
constructor(private readonly decisionCases: DecisionCaseRepository) {}
async execute(command: CreateDecisionCaseCommand): Promise<Result<CreateDecisionCaseResult>> {
// Validation
if (!command.title || command.title.trim().length === 0) {
return fail(ValidationError('Title is required'));
}
if (!command.observationIds || command.observationIds.length === 0) {
return fail(ValidationError('At least one observation is required'));
}
if (!command.evidenceIds || command.evidenceIds.length === 0) {
return fail(ValidationError('At least one evidence is required'));
}
// Create
const decisionCase = DecisionCaseFactory.create({
id: IdFactory.decisionCase(1),
missionId: command.missionId as any,
title: command.title,
description: command.description,
priority: command.priority,
confidence: command.confidence,
recommendedAction: command.recommendedAction,
observationIds: command.observationIds as any,
evidenceIds: command.evidenceIds as any,
findingId: command.findingId as any,
decisionId: command.decisionId as any,
reviewId: command.reviewId as any,
});
// Persist
await this.decisionCases.save(decisionCase);
return ok({ decisionCase });
}
}
@@ -0,0 +1,49 @@
/**
* CreateFieldSession Handler
*
* Orchestrates: CreateFieldSessionCommand → FieldSession
*
* Flow:
* 1. Generate ID
* 2. Create domain object (factory)
* 3. Save to repository
* 4. Return result
*/
import { FieldSessionFactory, IdFactory, FieldSession } from '@landvex/domain';
import { FieldSessionRepository } from '@landvex/infrastructure';
import { CreateFieldSessionCommand } from '../commands/commands';
import { Result, ok, fail, ValidationError } from '../results/results';
export interface CreateFieldSessionResult {
readonly session: FieldSession;
}
export class CreateFieldSessionHandler {
constructor(private readonly sessions: FieldSessionRepository) {}
async execute(command: CreateFieldSessionCommand): Promise<Result<CreateFieldSessionResult>> {
// Validation
if (!command.inspector || command.inspector.trim().length === 0) {
return fail(ValidationError('Inspector is required'));
}
if (!command.date) {
return fail(ValidationError('Date is required'));
}
if (!command.location) {
return fail(ValidationError('Location is required'));
}
// Create
const session = FieldSessionFactory.create({
id: IdFactory.session(command.date, 1), // TODO: sequence from repository
location: command.location,
date: command.date,
});
// Persist
await this.sessions.save(session);
return ok({ session });
}
}
@@ -0,0 +1,54 @@
/**
* CreateMission Handler
*
* Orchestrates: CreateMissionCommand → Mission
*
* Flow:
* 1. Verify session exists
* 2. Generate ID
* 3. Create domain object (factory)
* 4. Save to repository
* 5. Return result
*/
import { MissionFactory, IdFactory, Mission } from '@landvex/domain';
import { MissionRepository, FieldSessionRepository } from '@landvex/infrastructure';
import { CreateMissionCommand } from '../commands/commands';
import { Result, ok, fail, NotFoundError, ValidationError } from '../results/results';
export interface CreateMissionResult {
readonly mission: Mission;
}
export class CreateMissionHandler {
constructor(
private readonly missions: MissionRepository,
private readonly sessions: FieldSessionRepository,
) {}
async execute(command: CreateMissionCommand): Promise<Result<CreateMissionResult>> {
// Verify session exists
const session = await this.sessions.findById(command.sessionId as any);
if (!session) {
return fail(NotFoundError('Session', command.sessionId));
}
// Validation
if (!command.location) {
return fail(ValidationError('Location is required'));
}
// Create
const mission = MissionFactory.create({
id: IdFactory.mission(new Date(), 1), // TODO: sequence from repository
sessionId: command.sessionId as any,
location: command.location,
device: command.device,
});
// Persist
await this.missions.save(mission);
return ok({ mission });
}
}
@@ -0,0 +1,4 @@
export { CreateFieldSessionHandler, CreateFieldSessionResult } from './create-field-session-handler';
export { CreateMissionHandler, CreateMissionResult } from './create-mission-handler';
export { CreateDecisionCaseHandler, CreateDecisionCaseResult } from './create-decision-case-handler';
export { ApproveDecisionHandler, ApproveDecisionResult } from './approve-decision-handler';
+19
View File
@@ -0,0 +1,19 @@
/**
* @landvex/application
*
* Application layer — orchestrates domain operations.
*
* Architecture:
* UI → Application Layer (commands/handlers) → Domain → Repository Interfaces → Adapters
*
* No business logic here. Only orchestration.
*/
// Commands
export * from './commands/commands';
// Results
export * from './results/results';
// Handlers
export * from './handlers';
@@ -0,0 +1,52 @@
/**
* Results — explicit outcome of command execution
*
* Every command returns a Result.
* No exceptions for business failures.
*
* ADR-007: Command/Result pattern
* - Success: contains the result data
* - Failure: contains error code and message
*/
export type Result<T, E = DomainError> = Success<T> | Failure<E>;
export interface Success<T> {
readonly success: true;
readonly data: T;
}
export interface Failure<E> {
readonly success: false;
readonly error: E;
}
export interface DomainError {
readonly code: string;
readonly message: string;
}
// Helper functions
export const ok = <T>(data: T): Success<T> => ({ success: true, data });
export const fail = <E extends DomainError>(error: E): Failure<E> => ({ success: false, error });
// Common errors
export const NotFoundError = (entity: string, id: string): DomainError => ({
code: 'NOT_FOUND',
message: `${entity} not found: ${id}`,
});
export const ValidationError = (message: string): DomainError => ({
code: 'VALIDATION_ERROR',
message,
});
export const InvariantViolationError = (message: string): DomainError => ({
code: 'INVARIANT_VIOLATION',
message,
});
export const ConflictError = (message: string): DomainError => ({
code: 'CONFLICT',
message,
});
@@ -0,0 +1,168 @@
/**
* End-to-End Flow Test
*
* Acceptance Test for PR-002.5:
* CreateFieldSession → CreateMission → CreateDecisionCase → ApproveDecision
*
* WITHOUT:
* - PostgreSQL
* - Redis
* - S3
* - API
* - HTTP
* - UI
*
* If this passes, the domain is proven usable.
*/
import {
InMemoryFieldSessionRepository,
InMemoryMissionRepository,
InMemoryDecisionCaseRepository,
InMemoryUnitOfWork,
} from '@landvex/infrastructure';
import {
CreateFieldSessionHandler,
CreateMissionHandler,
CreateDecisionCaseHandler,
ApproveDecisionHandler,
} from '../handlers';
import {
CreateFieldSessionCommand,
CreateMissionCommand,
CreateDecisionCaseCommand,
ApproveDecisionCommand,
} from '../commands/commands';
describe('End-to-End Flow: Session → Mission → DecisionCase → Approval', () => {
let uow: InMemoryUnitOfWork;
let createSession: CreateFieldSessionHandler;
let createMission: CreateMissionHandler;
let createDecisionCase: CreateDecisionCaseHandler;
let approveDecision: ApproveDecisionHandler;
beforeEach(() => {
uow = new InMemoryUnitOfWork();
createSession = new CreateFieldSessionHandler(uow.sessions);
createMission = new CreateMissionHandler(uow.missions, uow.sessions);
createDecisionCase = new CreateDecisionCaseHandler(uow.decisionCases);
approveDecision = new ApproveDecisionHandler(uow.decisionCases);
});
it('should complete full flow in memory', async () => {
// Step 1: Create FieldSession
const sessionResult = await createSession.execute({
inspector: 'inspector_001',
date: new Date('2026-07-02'),
location: { lat: 59.3293, lng: 18.0686 },
areaId: 'area_stockholm_city',
} as CreateFieldSessionCommand);
expect(sessionResult.success).toBe(true);
if (!sessionResult.success) return;
const session = sessionResult.data.session;
// Step 2: Create Mission
const missionResult = await createMission.execute({
sessionId: session.id,
location: { lat: 59.3293, lng: 18.0686 },
device: { model: 'iPhone14,2', os: 'iOS 17.0', appVersion: '1.0.0' },
} as CreateMissionCommand);
expect(missionResult.success).toBe(true);
if (!missionResult.success) return;
const mission = missionResult.data.mission;
// Step 3: Create DecisionCase
const decisionCaseResult = await createDecisionCase.execute({
missionId: mission.id,
title: 'Crack in bridge deck',
description: 'Structural crack detected in bridge deck',
priority: 'high',
confidence: 'high',
recommendedAction: 'Inspect immediately by certified engineer',
observationIds: ['obs_000001'],
evidenceIds: ['evidence_000001'],
findingId: 'finding_000001',
decisionId: 'decision_000001',
reviewId: 'review_000001',
} as CreateDecisionCaseCommand);
expect(decisionCaseResult.success).toBe(true);
if (!decisionCaseResult.success) return;
const decisionCase = decisionCaseResult.data.decisionCase;
expect(decisionCase.status).toBe('pending');
// Step 4: Approve Decision
const approveResult = await approveDecision.execute({
decisionCaseId: decisionCase.id,
approvedBy: 'reviewer_001',
} as ApproveDecisionCommand);
expect(approveResult.success).toBe(true);
if (!approveResult.success) return;
const approved = approveResult.data.decisionCase;
expect(approved.status).toBe('approved');
// Verify persistence
const foundSession = await uow.sessions.findById(session.id);
const foundMission = await uow.missions.findById(mission.id);
const foundCase = await uow.decisionCases.findById(decisionCase.id);
expect(foundSession).not.toBeNull();
expect(foundMission).not.toBeNull();
expect(foundCase).not.toBeNull();
expect(foundCase!.status).toBe('approved');
});
it('should fail to approve decision without review', async () => {
// Create a decision case without reviewId
const createResult = await createDecisionCase.execute({
missionId: 'mission_20260702_000001',
title: 'Pothole on road A',
description: 'Small pothole',
priority: 'low',
confidence: 'medium',
recommendedAction: 'Schedule repair',
observationIds: ['obs_000001'],
evidenceIds: ['evidence_000001'],
findingId: 'finding_000001',
decisionId: 'decision_000001',
reviewId: 'review_000001',
} as CreateDecisionCaseCommand);
expect(createResult.success).toBe(true);
if (!createResult.success) return;
// Manually remove reviewId to simulate missing review
const decisionCase = createResult.data.decisionCase;
const modified = { ...decisionCase, reviewId: undefined as any };
await uow.decisionCases.save(modified);
// Try to approve
const approveResult = await approveDecision.execute({
decisionCaseId: decisionCase.id,
approvedBy: 'reviewer_001',
} as ApproveDecisionCommand);
expect(approveResult.success).toBe(false);
if (approveResult.success) return;
expect(approveResult.error.code).toBe('VALIDATION_ERROR');
});
it('should fail to create mission for non-existent session', async () => {
const result = await createMission.execute({
sessionId: 'session_20260702_999999',
location: { lat: 59.3293, lng: 18.0686 },
device: { model: 'iPhone14,2', os: 'iOS 17.0', appVersion: '1.0.0' },
} as CreateMissionCommand);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}