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
Amazon Employees Are 'Tokenmaxxing' Due To Pressure To Use AI Tools
20h ago
UCP Variant Data: The #1 Reason Agent Checkouts Fail
6h ago

DΓ©cryptage technique : Comment builder un tΓ©lΓ©chargeur de vidΓ©os Reddit performant (DASH, HLS & WebAssembly)
16h ago
How Brazeβs CTO is rethinking engineering for the agentic area
10h ago
Encryption Protocols for Secure AI Systems: A Practical Guide
20h ago