Series: Building EDIFlow - A Clean Architecture Journey in TypeScript (Part 5/6)
Reading Time: ~8 minutes
Recap β Where We Left Off
In Part 4, we implemented the Infrastructure Layer β EDIFACT/X12 parsers, builders, validators, the file-based repository, and 13 data packages with 126β319 message definitions each.
Now it's time for the outermost layer β the Presentation Layer. In EDIFlow, that's a CLI. But the patterns apply equally to a REST API, a web UI, or any other entry point.
βββββββββββββββββββββββββββββββββββββββββββββββ
β π₯ PRESENTATION (CLI) β β You are here
β Commands Β· DI Container Β· Output β
β βββββββββββββββββββββββββββββββββββββββββ β
β β Infrastructure (Parsers, Repos) β β
β β βββββββββββββββββββββββββββββββββββ β β
β β β Application (Use Cases, Ports) β β β
β β β βββββββββββββββββββββββββββββ β β β
β β β β Domain (Entities) β β β β
β β β βββββββββββββββββββββββββββββ β β β
β β βββββββββββββββββββββββββββββββββββ β β
β βββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββ
The Presentation Layer has one job: convert user input into Use Case calls, and Use Case output into user-friendly results.
Why a CLI? β The Fastest Way to Prove Your Architecture
Why did we build a CLI instead of a REST API or a web UI?
1. Zero-friction developer experience. A CLI lets any developer try EDIFlow in 10 seconds: npx @ediflow/cli parse invoice.edi. No server setup, no browser, no configuration. Just pipe in a file and get JSON out. For an open-source library that needs adoption, this is critical.
2. The ultimate integration test. The CLI exercises every single layer β from parsing raw bytes (Infrastructure) through Use Cases (Application) to formatted output (Presentation). If npx @ediflow/cli parse works, all four layers work. It's a vertical slice through the entire architecture.
3. Clean Architecture makes it replaceable. Because the CLI is just a thin wrapper around Use Cases, adding a REST API or a Lambda handler later is trivial β they'd call the same UseCaseFactory with the same DIContainer. The CLI doesn't contain business logic; it only translates command-line arguments into Use Case inputs.
4. Scripting & CI/CD. EDI processing often happens in automated pipelines β validate incoming files, convert to JSON, check against schemas. A CLI fits naturally into bash scripts, GitHub Actions, and cron jobs. A web UI doesn't.
In short: the CLI is the simplest possible Presentation Layer that proves Clean Architecture works end-to-end, while delivering immediate value to developers.
The DI Container β Where Everything Gets Wired
This is the single place where all layers connect. In a framework like NestJS, this would be a module with providers. In EDIFlow, it's a pure TypeScript class:
export class DIContainer {
private static instance: DIContainer;
public readonly useCaseFactory: UseCaseFactory;
public readonly repository: IMessageStructureRepository;
public readonly structureMappingService: StructureMappingService;
private constructor() {
// EDIFACT infrastructure
const edifactParser = new EdifactMessageParser(
new EdifactDelimiterDetector(),
new EdifactTokenizer(),
new EdifactSegmentParser()
);
const edifactBuilder = new EdifactMessageBuilder();
// X12 infrastructure
const x12Parser = new X12MessageParser(
new X12DelimiterDetector(),
new X12SegmentParser(),
new X12EnvelopeParser()
);
const x12Builder = new X12MessageBuilder();
// Register parsers and builders by standard
const parsers = new Map([['EDIFACT', edifactParser], ['X12', x12Parser]]);
const builders = new Map([['EDIFACT', edifactBuilder], ['X12', x12Builder]]);
// Wire Application Layer
const validationService = new EDIMessageValidationService();
this.useCaseFactory = new UseCaseFactory(parsers, builders, validationService);
this.repository = new FileBasedMessageStructureRepository(DATA_PACKAGES_BASE_PATH);
this.structureMappingService = new StructureMappingService();
}
static getInstance(): DIContainer {
if (!DIContainer.instance) {
DIContainer.instance = new DIContainer();
}
return DIContainer.instance;
}
}
Why a Singleton? The parsers and repository don't hold mutable state between calls. Creating them once and reusing them is safe and avoids repeated initialization of data package caches.
Why not a DI framework? Because we have ~10 dependencies. A framework like tsyringe or inversify would add complexity for a problem that a plain constructor solves.
The key insight: this is the only file that imports from all layers simultaneously. Domain doesn't know Infrastructure. Application doesn't know Infrastructure. Only this container does.
Commands β The User's Entry Point
Each CLI command follows the same pattern: parse input β call Use Case β format output.
ParseCommand β The Most Complex One
export class ParseCommand {
constructor(
private readonly useCaseFactory: UseCaseFactory,
private readonly repository?: IMessageStructureRepository,
private readonly structureMappingService?: StructureMappingService
) {}
register(program: Command): void {
program
.command('parse')
.argument('<file>', 'EDI file path')
.option('--output-type <type>', 'edi-message | business-object')
.option('--property-parse-mode <mode>', 'code | name | camelCase | snake_case | kebab-case')
.action(async (file, options) => {
await this.execute(file, options);
});
}
async execute(file: string, options: any): Promise<void> {
const content = readEDIFile(file);
const standard = this.detectStandard(content); // UNA/UNB β EDIFACT, ISA β X12
// Phase 1: Parse EDI β EDIMessage
const parseUseCase = this.useCaseFactory.createParseUseCase(standard);
const result = parseUseCase.execute({
message: content,
standard: this.parseStandard(standard),
});
if (!result.success) {
throw new Error(ErrorHandler.formatMultiple(result.errors));
}
// Phase 2 (optional): EDIMessage β Business Object
if (options.outputType === 'business-object' && this.repository) {
const structure = await this.repository.getMessageStructure(
standard, result.metadata.version.value, result.metadata.messageType.value
);
if (structure && this.structureMappingService) {
const mappedUseCase = this.useCaseFactory.createParseUseCase(standard, this.structureMappingService);
const mapped = mappedUseCase.execute({
message: content,
standard: this.parseStandard(standard),
returnTypedObject: true,
messageStructure: structure,
mappingKeyStrategy: options.propertyParseMode || 'code',
});
this.writeOutput(mapped.businessObject, options);
return;
}
}
this.writeOutput(this.formatEDIMessageResult(result), options);
}
private detectStandard(content: string): string {
if (content.startsWith('UNB') || content.startsWith('UNA')) return 'EDIFACT';
if (content.startsWith('ISA')) return 'X12';
throw new Error('Unable to detect EDI standard.');
}
}
What's happening here:
-
Auto-detection β the command looks at the first characters to decide EDIFACT vs X12. No
--standardflag needed in most cases. - Two-phase parsing β Phase 1 always runs (raw segments). Phase 2 only runs if the user wants business objects AND a data package is installed.
- Graceful fallback β if no data package is found, it warns and returns raw segments instead of crashing.
The Four Commands
# Parse: EDI file β JSON output
npx @ediflow/cli parse invoice.edi
npx @ediflow/cli parse invoice.edi --output-type business-object
# Validate: Check EDI against rules
npx @ediflow/cli validate invoice.edi
# Build: JSON β EDI string
npx @ediflow/cli build order.json --standard edifact --version d20b --message ORDERS
# Export Schema: Generate JSON Schema for a message type
npx @ediflow/cli export-schema --standard x12 --version 004010 --message 850
Each command is a class with register() and execute(). All injected via the DI Container.
Output Formatting β Supporting Multiple Formats
The CLI supports JSON and YAML output, with an option to strip empty values:
# Compact JSON (default)
npx @ediflow/cli parse invoice.edi
# Clean output β remove empty strings, null values, empty arrays
npx @ediflow/cli parse invoice.edi --skip-empty true
# Write to file
npx @ediflow/cli parse invoice.edi -o result.json
The OutputFormatter handles serialization and the --skip-empty flag recursively removes noise from the output β essential when dealing with EDI messages that have hundreds of optional fields.
How It All Connects β The Full Stack in One Call
When a user runs npx @ediflow/cli parse invoice.edi --output-type business-object, here's what happens:
CLI β ParseCommand
β DIContainer.getInstance()
β EdifactMessageParser (Infrastructure)
β EdifactDelimiterDetector.detect() β reads UNA
β EdifactTokenizer.tokenize() β splits segments
β EdifactSegmentParser.parseSegment() β parses elements
β ParseEDIUseCase.execute() (Application)
β IMessageParser.parse() β delegates to EDIFACT parser
β StructureMappingService.map() β Phase 2: business object
β FileBasedMessageStructureRepository (Infrastructure)
β loads ORDERS.json from data package
β MessageStructureBuilder.build()
β OutputFormatter.toJSON() β pretty-print result
Five layers. One call. No layer knows about the layers above or below it.
Lessons Learned
β Auto-detection makes the CLI feel smart β users don't need to specify the standard. The first 3 characters tell you if it's EDIFACT or X12.
β Graceful degradation β if a data package isn't installed, the CLI still works. It returns raw segments instead of business objects, with a helpful warning.
β Singleton DI Container is fine for CLI tools β no request scoping needed, no concurrent state. Simple is better.
β Commander.js for the CLI β no custom argument parsing. Commander handles flags, help text, and validation. We just define commands.
β οΈ The DI Container imports everything β this is intentional. It's the composition root. But it means the CLI package depends on all other packages. For a library this is fine β for a microservice architecture, you'd split differently.
What's Next β Part 6: Lessons Learned & The Road Ahead
The final part of the series. What worked? What didn't? What would we do differently? And where does EDIFlow go from here?
β Part 1: Why Clean Architecture?
β Part 2: Domain Layer
β Part 3: Application Layer
β GitHub: @ediflow/core
β If this series helped you understand Clean Architecture in TypeScript β a star on GitHub keeps the project going: github.com/ediflow-lib/core
Do you use a DI container or plain constructor injection in your TypeScript projects? What's your experience? Drop a comment.
United States
NORTH AMERICA
Related News
How Brazeβs CTO is rethinking engineering for the agentic area
11h ago
Amazon Employees Are 'Tokenmaxxing' Due To Pressure To Use AI Tools
22h ago
KDE Receives $1.4 Million Investment From Sovereign Tech Fund
2h ago
Instagramβs new βInstantsβ feature combines elements from Snapchat and BeReal
2h ago
Six Claude Code Skills That Close the AI Agent Feedback Loop
2h ago