Series: Building EDIFlow - A Clean Architecture Journey in TypeScript (Part 3/6)
Reading Time: ~10 minutes
Recap — Where We Left Off
In Part 2, we built the Domain Layer — the core entities (EDIMessage, EDISegment, EDIElement), value objects (Standard, Version, MessageType, Delimiters), and business rules.
Now it's time for the Application Layer — the part that orchestrates everything. It sits between your domain logic and the outside world:
┌─────────────────────────────────────────┐
│ Infrastructure (parsers, repos) │
│ │
│ ┌───────────────────────────────────┐ │
│ │ 🔥 APPLICATION LAYER │ │ ← You are here
│ │ Use Cases · Services · Ports │ │
│ │ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Domain (entities) │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
The golden rule: the Application Layer knows the Domain, but knows nothing about Infrastructure.
It talks to the outside world through Ports — interfaces that infrastructure must implement.
Ports — Only Output, No Input
In Clean Architecture, you'll often read about Input Ports (how the outside world calls you) and Output Ports (what you need from the outside world).
In EDIFlow, we only have Output Ports. Here's why:
Input Ports (Driving Ports) define how external actors invoke Use Cases. In EDIFlow, we currently have a CLI that calls Use Cases — and a REST API or web UI may come later. But we still don't need Input Port interfaces. Why? Because the DTO is the contract. Whether the CLI, a REST controller, or a React frontend invokes the Use Case, they all do the same thing: build a ParseEDIInputDTO and call useCase.execute(dto). A separate IParseEDIUseCase interface wouldn't add value — the method signature is identical regardless of who calls it.
And if that ever changes? Adding an Input Port interface is a 5-minute refactoring — extract an interface from the Use Case, done. The architecture supports it without any structural changes. We just don't add abstractions before they're needed.
Output Ports (Driven Ports) define what the Application Layer needs from Infrastructure — and that's where the real value is:
IMessageParser — "Give me something that can parse"
export interface IMessageParser {
parse(ediString: string, config?: ParserConfig): EDIMessage;
getSupportedStandard(): Standard;
canParse(ediString: string): boolean;
getConfiguration(): ParserConfig;
}
The Application Layer says: "I need something that can parse an EDI string and return an EDIMessage. I don't care if it's EDIFACT, X12, or anything else."
Implementations: EdifactMessageParser, X12MessageParser — both in the Infrastructure layer.
IMessageBuilder — "Give me something that can build"
export interface IMessageBuilder {
build(message: EDIMessage, options?: MessageBuilderOptions): string;
getSupportedStandard(): Standard;
}
Takes a domain EDIMessage, returns a raw EDI string. Format-agnostic — EDIFACT gets ' terminators, X12 gets ~.
IValidationService — "Give me something that can validate"
export interface IValidationService<T> {
validate(entity: T, context?: ValidationContext): ValidationResult;
}
Generic — works with EDIMessage but could validate anything. Uses Strategy + Chain of Responsibility patterns: swap validators, chain them.
IMessageStructureRepository — "Give me message definitions"
export interface IMessageStructureRepository {
getMessageStructure(standard: string, version: string, messageType: string): Promise<MessageStructureDTO | null>;
getAllMessageTypes(standard: string, version: string): Promise<string[]>;
hasMessageStructure(standard: string, version: string, messageType: string): Promise<boolean>;
getCodeList(standard: string, version: string, codeListCode: string): Promise<Set<string> | null>;
}
This is the port that data packages (@ediflow/edifact-d20b, @ediflow/x12-004010, ...) implement. 126–319 JSON definitions per package, loaded at runtime through this single interface.
Why only Output Ports?
| Input Port (Driving) | Output Port (Driven) | |
|---|---|---|
| Direction | Outside → Application | Application → Infrastructure |
| EDIFlow | ❌ Not needed — DTO is the contract, CLI/API/UI all call execute(dto)
|
✅ All infrastructure contracts |
| Benefit | Useful when entry points need different method signatures | Essential — decouples Application from Infrastructure |
The rule of thumb: if every caller uses the same execute(dto) method, you don't need an Input Port — the DTO is your contract.
Why this matters for EDIFlow: The EDIFACT parser, the X12 parser, the HIPAA validator — they all implement these same Output Port interfaces. The Application Layer doesn't know any of them exist. When we added X12 support months after EDIFACT, zero code changed in the Application Layer.
Use Cases — One Class, One User Intention
A Use Case represents one thing a user wants to do. Not more. In EDIFlow we have three:
| Use Case | User Intention |
|---|---|
ParseEDIUseCase |
"I want to parse this EDI string" |
ValidateMessageUseCase |
"I want to validate this message" |
BuildEDIUseCase |
"I want to build an EDI string from data" |
ParseEDIUseCase — The Real Code
Here's what actually runs when you call service.parse():
export class ParseEDIUseCase {
constructor(
private readonly messageParser: IMessageParser,
private readonly structureMappingService?: StructureMappingService
) {}
execute(input: ParseEDIInputDTO): ParseEDIOutputDTO {
try {
// Guard: empty input
if (!input.message || input.message.trim().length === 0) {
return this.createErrorResult(input, {
code: 'EMPTY_MESSAGE',
message: 'Message cannot be empty',
});
}
// Guard: parser compatibility
if (!this.messageParser.canParse(input.message)) {
return this.createErrorResult(input, {
code: 'UNSUPPORTED_FORMAT',
message: 'Parser does not support this message format',
});
}
// Phase 1: EDI String → EDIMessage
const message = this.messageParser.parse(input.message, input.parserConfig);
// Phase 2: EDIMessage → Business Object (optional)
let businessObject: unknown;
if (input.returnTypedObject && input.messageStructure && this.structureMappingService) {
businessObject = this.structureMappingService.map(message, input.messageStructure);
}
return {
success: true,
message,
businessObject,
metadata: {
standard: message.standard,
version: message.version,
messageType: message.messageType,
segmentCount: message.segments.length,
},
};
} catch (error) {
return this.createErrorResult(input, {
code: 'PARSE_ERROR',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
}
Notice the pattern:
- Validate input — fail fast
-
Delegate to port —
this.messageParser.parse() - Optionally enrich — Phase 2 mapping
- Return a DTO — never expose domain internals directly
No framework. No decorators. No magic. Just TypeScript classes with constructor injection.
ValidateMessageUseCase — Composing Validation Phases
Validation is more complex — it runs in three phases, each building on the previous:
export class ValidateMessageUseCase {
constructor(private readonly validationService: IValidationService<EDIMessage>) {}
execute(input: ValidateMessageInputDTO): ValidateMessageOutputDTO {
// Phase 1: Syntax validation
const syntaxResult = this.validationService.validate(input.message);
// Phase 2: Structure validation (segments in correct order?)
const structureResult = this.performStructuralValidation(input, syntaxResult);
// Phase 3: Code list validation (valid code values?)
const finalResult = this.performCodeValidation(input, structureResult);
return this.buildSuccessResponse(input.message, finalResult);
}
}
The interesting part: in strict mode, Phase 2 only runs if Phase 1 passed. Phase 3 only runs if Phase 2 passed. This prevents cascading error messages that confuse users.
BuildEDIUseCase — From Business Object to EDI
Building works in reverse — you provide either an EDIMessage directly, or a business object that gets unmapped:
export class BuildEDIUseCase {
constructor(
private readonly messageBuilder: IMessageBuilder,
private readonly envelopeBuilder?: IEnvelopeBuilder,
private readonly structureMappingService?: StructureMappingService
) {}
execute(input: BuildEDIInputDTO): BuildEDIOutputDTO {
// Get message from either direct input or business object unmap
let message = this.getMessageFromInput(input);
// Optionally wrap with envelope (UNB/UNH for EDIFACT, ISA/GS for X12)
if (input.includeEnvelope) {
message = this.wrapWithEnvelope(message, input);
}
// Optionally validate before building
if (input.validateBeforeBuild) {
const error = this.validateMessage(message);
if (error) return error;
}
// Build the EDI string
const ediString = this.messageBuilder.build(message, {
delimiters: input.delimiters,
format: input.format || OutputFormat.COMPACT
});
return { success: true, ediString };
}
}
This is the round-trip feature: parse() converts EDI → JSON, build() converts JSON → EDI. Same message structure definition drives both directions.
DTOs — Type-Safe Input/Output
Every Use Case has a Request (Input) and a Response (Output) DTO. No domain objects leak out:
export interface ParseEDIInputDTO {
message: string;
standard: Standard;
strictMode?: boolean;
parserConfig?: ParserConfig;
returnTypedObject?: boolean;
messageStructure?: MessageStructureDTO;
mappingKeyStrategy?: MappingKeyStrategy;
}
And the output uses a discriminated union — TypeScript narrows the type based on success:
export type ParseEDIOutputDTO =
| {
success: true;
message: EDIMessage;
businessObject?: unknown;
metadata: { standard: Standard; version: Version; messageType: MessageType; segmentCount: number; };
}
| {
success: false;
errors: ParseError[];
metadata: { standard: Standard; segmentCount: number; };
};
When you check result.success === true, TypeScript knows result.message exists. When it's false, TypeScript knows result.errors exists. No runtime checks needed.
The Factory — Wiring Without a Framework
How do Use Cases get their dependencies? Through a Factory:
export class UseCaseFactory {
constructor(
private readonly parsers: Map<string, IMessageParser>,
private readonly builders: Map<string, IMessageBuilder>,
private readonly validationService: IValidationService<EDIMessage>
) {}
createParseUseCase(standard: string, mapper?: StructureMappingService): ParseEDIUseCase {
const parser = this.parsers.get(standard.toUpperCase());
if (!parser) {
throw new Error(`No parser registered for standard: ${standard}`);
}
return new ParseEDIUseCase(parser, mapper);
}
createBuildUseCase(standard: string, mapper?: StructureMappingService): BuildEDIUseCase {
const builder = this.builders.get(standard.toUpperCase());
if (!builder) {
throw new Error(`No builder registered for standard: ${standard}`);
}
return new BuildEDIUseCase(builder, undefined, mapper);
}
createValidateUseCase(): ValidateMessageUseCase {
return new ValidateMessageUseCase(this.validationService);
}
}
No DI container. No @Injectable(). Just a Map of implementations and a factory method. Simple, testable, no framework coupling.
Testing a Use Case — Easy When Dependencies Are Interfaces
Because Use Cases depend only on interfaces, testing is trivial:
import { describe, it, expect, vi } from 'vitest';
import { ParseEDIUseCase } from './ParseEDIUseCase';
import { Standard } from '@domain';
describe('ParseEDIUseCase', () => {
it('returns error for empty message', () => {
const mockParser = { canParse: vi.fn(), parse: vi.fn(), /* ... */ };
const useCase = new ParseEDIUseCase(mockParser);
const result = useCase.execute({
message: '',
standard: Standard.EDIFACT,
});
expect(result.success).toBe(false);
expect(result.errors![0].code).toBe('EMPTY_MESSAGE');
});
it('delegates to parser and returns structured result', () => {
const mockMessage = { standard: Standard.EDIFACT, segments: [], /* ... */ };
const mockParser = {
canParse: vi.fn().mockReturnValue(true),
parse: vi.fn().mockReturnValue(mockMessage),
getConfiguration: vi.fn().mockReturnValue({ delimiters: {} }),
};
const useCase = new ParseEDIUseCase(mockParser);
const result = useCase.execute({
message: "UNH+1+ORDERS:D:96A:UN'",
standard: Standard.EDIFACT,
});
expect(result.success).toBe(true);
expect(mockParser.parse).toHaveBeenCalledOnce();
});
});
No HTTP server to spin up. No database. No parsers. Just a mock that implements the interface.
Lessons Learned
After building this layer, here's what I'd do the same again — and what I'd change:
✅ Keep Use Cases stupid simple — they validate input, call a port, return a DTO. If a Use Case is complex, logic belongs in a Domain Service or the Use Case is doing too much.
✅ Discriminated unions for output DTOs — TypeScript's type narrowing eliminates an entire class of null-check bugs.
✅ Ports (interfaces) first — define what you need before building what provides it. The EDIFACT parser and X12 parser were built months apart, both implementing the same IMessageParser. Zero changes to the Application Layer.
✅ Optional dependencies work fine — StructureMappingService as optional constructor parameter is genuinely optional functionality (Phase 2 mapping). Not every user needs business object mapping. Keeping it optional in the same Use Case avoids creating a second ParseToBusinessObjectUseCase that would duplicate the entire parsing pipeline. Sometimes a simple ? is the right call.
What's Next — Part 4: Infrastructure Layer
In the next part, we'll show how the interfaces we defined here get implemented — the EDIFACT tokenizer, X12 envelope parser, file-based repositories, and how data packages with 126–319 message definitions per package are loaded at runtime.
All of this is already built and running in production. Part 4 will walk through the real implementation code.
This is where the architecture pays off: the EDIFACT parser and the X12 parser were built months apart — both implementing the same IMessageParser interface. Zero changes needed in the Application Layer.
→ Part 1: Why Clean Architecture?
→ Part 2: Domain Layer
→ GitHub: @ediflow/core
⭐ If this series is useful — a star on GitHub helps others find it: github.com/ediflow-lib/core
What patterns do you use in your Application Layer? Controllers? Mediators? Interactors? I'd love to hear — drop a comment.
Top comments (0)