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.
Top comments (0)