DEV Community

Cover image for Building EDIFlow - Presentation Layer: CLI, DI Container & Wiring (Part 5)
hello-ediflow
hello-ediflow

Posted on

Building EDIFlow - Presentation Layer: CLI, DI Container & Wiring (Part 5)

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)        │  │  │  │
│  │  │  └───────────────────────────┘  │  │  │
│  │  └─────────────────────────────────┘  │  │
│  └───────────────────────────────────────┘  │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.');
  }
}
Enter fullscreen mode Exit fullscreen mode

What's happening here:

  1. Auto-detection — the command looks at the first characters to decide EDIFACT vs X12. No --standard flag needed in most cases.
  2. 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.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)