Architecture - phax/peppol-cli GitHub Wiki

Architecture

Status: proposed, except where an ADR is referenced.

Language and build

Aspect Choice Notes
Language Java The libraries this tool is built on are Java.
Baseline Java 25 ADR-005. The shared ph-parent-pom currently defaults java.version to 17, so this project overrides that property.
Build Maven Consistent with the rest of the ecosystem; parent ph-parent-pom.
CLI framework picocli ADR-006. Already used by norma-cli, kaltblut-cli, ph-redact-cli, the en16931-*-cli tools and phoss-peppol-mcp (4.7.7).
Logging SLF4J, with a runtime binding in the distribution only Log output goes to stderr. Default level is quiet; --verbose/--debug raise it.
Licence Apache License 2.0 ADR-008. Consistent with the rest of the ecosystem, and the licence every planned dependency already uses.

Build note, verified: several peppol-commons modules generate sources with JAXB/wsimport into target/generated-sources/xjc and need mvn generate-sources before IDE use. Worth stating in CONTRIBUTING so a first checkout does not look broken.

Dependencies

The tool is deliberately a thin command line layer over existing libraries. Verified against the local sources:

Need Library / module
Identifiers, code list enums, Seat ID / SPID handling peppol-commons β†’ peppol-id (with edelivery-id, peppol-id-datatypes; also brings ph-security from 13.1.0 for the certificate-based Seat ID extraction in SPIDHelper)
Identifier value checks, country derivation, GLN/GTIN check digits peppol-id-checks, which already depends on com.helger.masterdata:ph-masterdata β€” the latter provides GTINValidator (GLN, GTIN-8/12/13/14, SSCC) and IBAN handling
SML descriptors, networks, trust stores, CA checkers peppol-commons β†’ peppol-commons (ESML, EPeppolNetwork, PeppolTrustStores, PeppolTrustedCA)
SMP read/write, DNS discovery, SMP JSON peppol-commons β†’ peppol-smp-client (brings ph-dns + dnsjava). DNSSEC validation of the U-NAPTR lookups needs peppol-commons 13.1.2 (setDnsSecValidation) over ph-web/ph-dns 11.4.7 (com.helger.dns.dnssec)
SMP core, signature verification peppol-commons β†’ smp-client-base. It also carries the OASIS BDXR SMP 1.0/2.0 clients, which this project does not use β€” see ADR-007
SML management (SOAP 1.1, mTLS) peppol-commons β†’ peppol-sml-client. Note SML 1.3.0 Β§4.2.2/Β§4.3.2: every SML call needs a client certificate issued under the Peppol root CA, so this whole area is unusable without configured key material β€” which is why it sits in M3 and why peppol config validate should check the keystore loads before a write is attempted.
Business cards peppol-commons β†’ peppol-directory-businesscard (data model v1/v2/v3)
Directory indexing com.helger.peppol.directory:peppol-directory-client 1.0.0 (PDClient) β€” mTLS, indexer interface
Directory search com.helger.peppol.directory:peppol-directory-searchclient β€” PDSearchClient incl. searchAllPages, PDSearchClient, PDSearchQuery, PDSearchRateLimitException. No ph-config or key store dependency, since the search API is public
Directory search model com.helger.peppol.directory:peppol-directory-searchapi 1.0.0 β€” PDResultListMarshaller, EPDSearchAPIField, CPDSearchAPI, and the JAXB types ResultListType / MatchType / EntityType in com.helger.pd.searchapi.v1; depends only on ph-xml/ph-jaxb
MLS / SBDH peppol-commons β†’ peppol-mls, peppol-sbdh. Not peppol-mlr β€” MLR is end of life, see Rejected features
Phorm validation com.helger:phorm-client (PhormClient, PhormValidationResult)
VESID parsing ph-diver (DVRCoordinate)
Local document type detection ddd-model (DocumentDetailsDeterminator, DocumentDetails) β€” a compile dependency of phorm-client, so available either way
Validation report model phive-result β€” resolved: this is the artifactId, confirmed as a compile dependency of phorm-client

Only add a dependency outside this set when nothing in it does the job. In particular: no second HTTP client, no second JSON library, no second collections library.

> TODO(verify): the full transitive dependency set and its licences once the module list is fixed β€” the SOAP/JAX-WS stack behind peppol-sml-client and the dnsjava dependency are the ones most likely to surprise on size or on Java 25.

Module split

Four modules, mirroring the -core / -cli split already used by kaltblut and norma:

Module Contains Depends on
peppol-cli-core All the actual work: identifier operations, discovery, SMP/SML/Directory/Phorm access, certificate checks, code list access. Returns typed result objects. No picocli, no System.out, no exit codes. The libraries above
peppol-cli-config Config resolution, file discovery, profiles, secret handling, redaction. ph-config
peppol-cli-output Renderers: the text renderer and the JSON envelope renderer. Turns core result objects into output. ph-json
peppol-cli-commands The picocli command tree. Parses arguments, calls core, hands results to output, maps failures to exit codes. Thin by design. all of the above, picocli

The point of the split is that the interesting logic is testable without a command line, and that the JSON contract lives in one module rather than being spread across command classes.

> TODO(decide): is four modules over-engineered for a tool this size? A single module with clean internal packages would build faster and is easier to navigate. The counter-argument is that peppol-cli-core is potentially reusable, and that the boundary is what stops output formatting leaking into logic.

Main class following the ecosystem's convention (com.helger.kaltblut.cli.KaltblutMain): com.helger.peppol.cli.PeppolCliMain. > TODO(verify): confirm the intended base package and groupId (see [Naming](Naming)).

Cross-cutting design notes

  • Command results are objects, not print statements. Every core operation returns a typed result; rendering happens once, in the output module. This is what makes the text and JSON outputs consistent by construction.
  • One exception-to-exit-code mapping, in the commands module. Core throws typed exceptions; nothing else decides an exit code.
  • --config before ConfigFactory. As noted in Configuration, ConfigFactory reads config.file during static initialisation, so the CLI builds its own MultiConfigurationValueProvider rather than relying on that timing.
  • Network profile values come from the libraries, via EPeppolNetwork and ESML β€” not from constants in this project.
  • Startup cost is a feature requirement. Avoid eager classloading of the JAXB and SOAP stacks for commands that do not need them; a peppol pid validate should not pay for peppol-sml-client.

Packaging

Artefact Purpose Notes
Fat JAR The single-file fallback that works everywhere maven-shade-plugin with a mainClass, as in kaltblut-cli and norma-cli. Watch for SPI/META-INF/services merging β€” peppol-commons uses SPIs for identifier validators and transport profiles, so the shade plugin needs ServicesResourceTransformer, or those registrations are silently lost. > TODO(verify): confirm that transformer is configured; this is a classic and quiet shading bug.
Distribution archive .tar.gz and .zip with bin/peppol, bin/peppol.cmd, lib/, completion scripts, LICENSE and README maven-assembly-plugin. Launcher resolves JAVA_HOME, checks the Java version and prints a clear message if it is below 25.
jlink image Self-contained archive with a bundled runtime Removes the Java 25 prerequisite at the cost of size and per-platform builds. > TODO(decide): worth it, or is "install a JRE" acceptable?
Container image For CI use and for anyone without a Java runtime In scope β€” see the Docker section below. Multi-stage build, JRE-only runtime stage, multi-arch.
Native image Deliberately deferred β€” Open Questions #1.

Distribution channels

Ordered by effort. The pattern in every case is: one signed, versioned release archive plus its SHA-256, and each channel points at it.

Channel Effort Notes
GitHub Releases low The source of truth every other channel consumes. Attach archives plus a SHA256SUMS file.
Maven Central low The fat JAR, consistent with the other projects.
Docker low Docker Hub under phelger/, matching phelger/phoss-ap. Arguably the single easiest way for a CI pipeline to use the tool, since it needs no Java runtime on the agent. See below.
Homebrew (own tap) low–medium The realistic first step. See below.
SDKMAN! medium Purpose-built for JVM CLI tools and a natural fit for this audience. > TODO(verify): SDKMAN's current vendor onboarding requirements.
Scoop / WinGet medium Depends on the Windows support decision, Open Questions #8.
Homebrew core high Only worth attempting once the tool is established. See below.
Linux distro packages high Best left to distro maintainers; provide a clean tarball and they can.

Docker

A container is a first-class channel here, not an afterthought: a CI agent can use the tool with no Java runtime installed, which removes the Java 25 prerequisite entirely for the audience most likely to script against it.

Image: phelger/peppol-cli on Docker Hub, following the existing phelger/phoss-ap convention. > TODO(decide): also publish to GHCR (ghcr.io/phax/peppol-cli)? It costs one more push step and some users prefer it.

Build, matching the ecosystem's existing Dockerfiles (phorm, phase4-peppol-standalone): multi-stage, with the Apache 2.0 licence header comment at the top. The runtime stage needs only a JRE β€” phorm already builds on a JRE 25 base image, so current bases are available. A JRE-only or jlink-trimmed runtime stage keeps the image small; > TODO(decide): eclipse-temurin:25-alpine, a jlink-produced runtime, or a distroless base?

Container-specific design points, which differ from the server images in this ecosystem because this is a short-lived CLI:

  • ENTRYPOINT is the executable, CMD is --help. That makes docker run --rm phelger/peppol-cli smp lookup … read as the command it is, and a bare docker run self-documents.
  • Run as a non-root user. The tool writes nothing except its cache, and that only when asked.
  • Configuration by environment variable is the natural container idiom, and it is free: ph-config already maps peppol.cli.network to PEPPOL_CLI_NETWORK, so docker run -e PEPPOL_CLI_NETWORK=prod … works with no extra code. Mounting a config directory read-only (-v ~/.config/peppol-cli:/config:ro) is the alternative for people with many settings.
  • Never bake key material into the image. Keystores are mounted read-only at run time. This must be stated explicitly in the documentation, because it is exactly the mistake people make.
  • Do not allocate a TTY when piping. docker run -t corrupts a piped stream, so --output json | jq requires docker run --rm without -t. Colour and progress already switch off automatically when stdout is not a TTY, so the default behaviour is correct β€” but the footgun is common enough to document with a worked example.
  • Exit codes pass through docker run unchanged, which is what makes the container usable in CI. Our range (0–7, see CLI Conventions) stays clear of Docker's own reserved 125–127, so a pipeline can always tell a tool verdict from a container failure.
  • Multi-arch linux/amd64 and linux/arm64 via buildx. A pure-JVM image is architecture-agnostic in content, so this is only about the base image.
  • Tags: X.Y.Z, X.Y and latest. Documentation should recommend pinning an exact version in CI and treat latest as a convenience for interactive use only.
  • Document a shell alias, so people are not typing a long docker run line repeatedly:
    alias peppol='docker run --rm -e PEPPOL_CLI_NETWORK -v "$HOME/.config/peppol-cli:/config:ro" phelger/peppol-cli'
  • Image size is where a native image would pay off most β€” a native binary in a minimal base would be a fraction of a JVM image. That is a genuine argument for Open Questions #1, and a reason to keep the Dockerfile's runtime stage easy to swap.

A packaging test should run the built image and assert a known command's output and exit code, exactly as for the fat JAR β€” see Testing Strategy.

Homebrew plan

Two distinct paths, and they should not be confused.

Step 1 β€” own tap (recommended starting point). A repository named homebrew-tap under the same GitHub account makes the tool installable as:

brew tap phax/tap
brew install peppol-cli
# or in one step
brew install phax/tap/peppol-cli

A tap has no notability requirements, no review queue and no gatekeeper, and it can be published the same day as the first release. What it takes:

  1. A public repository homebrew-tap (the homebrew- prefix is what makes brew tap phax/tap work).
  2. A formula Formula/peppol-cli.rb with: desc, homepage, url pointing at the release archive, its sha256, license "Apache-2.0", a depends_on "openjdk", an install block that puts the JAR under libexec and creates a wrapper in bin, a test do block, and a livecheck block so brew livecheck can spot new versions.
  3. A release step that computes the SHA-256, updates url and sha256 in the formula and commits it β€” trivially automatable from the release workflow.

Java-specific formula points to get right:

  • The JAR must not be put directly in bin. The convention is to install it into libexec and generate a small wrapper script that invokes java -jar, so openjdk is found via Homebrew rather than the user's PATH. Homebrew provides a helper for exactly this β€” > TODO(verify): the current helper name and signature (bin.write_jar_script, as used by existing Java formulae) against the Homebrew Formula Cookbook, rather than relying on memory.
  • The Java 25 requirement is the main open risk. depends_on "openjdk" tracks Homebrew's current default JDK, which is a moving target, and versioned formulae (openjdk@21, openjdk@17) only exist for some versions. > TODO(verify): whether Homebrew's openjdk formula is currently at 25 or later, and whether an openjdk@25 exists. If neither, the choice is to relax the baseline for the Homebrew build, ship a jlink image as a cask, or wait.
  • Completion scripts install via bash_completion.install / zsh_completion.install / fish_completion.install, which is why peppol completion <shell> should be able to write them at build time.
  • Both arm64 and x86_64 macOS must work. A pure-Java formula is architecture-independent, which is one real advantage of not going native first.

Step 2 β€” homebrew-core (later, optional). Getting into homebrew-core means brew install peppol-cli with no tap, but the bar is much higher:

  • Notability. homebrew-core declines formulae for projects without a track record; the documented guideline is a threshold of GitHub stars, forks and watchers. > TODO(verify): the current thresholds in Homebrew's "Acceptable Formulae" document β€” they have changed over time and should not be quoted from memory.
  • A stable, versioned release (not a snapshot, not a git HEAD build), with a stable archive URL.
  • An open source licence Homebrew accepts β€” Apache 2.0 qualifies.
  • No duplication of an existing formula, and it must not be trivially replaceable by a one-line script.
  • It must pass brew audit --strict --online --new and the CI build (brew test-bot), which includes a meaningful test do block. A good test here: run peppol pid validate on a known-good identifier and assert the output, since that needs no network.
  • Maintenance obligation. Once in core, every release should be followed by a brew bump-formula-pr. That is an ongoing commitment, which is the real reason to start with a tap.

Practical sequence: ship releases β†’ publish the tap β†’ let usage accumulate β†’ only then consider core.

Testing

The full plan β€” six test tiers from hermetic unit tests to manually-approved live write tests, fixture discipline, and the golden-file tests that make the JSON contract enforceable β€” is in Testing Strategy. It is planned as part of development from M1, not added afterwards.

Two architectural consequences worth stating here, because they constrain the module split above:

  • peppol-cli-core must be usable without a command line and without the network, or T1 and T2 are impossible. That is the main justification for keeping it separate from peppol-cli-commands.
  • The stubs (SMP, DNS resolver, Phorm) belong in a shared test-support module, so every tier can reuse them.

Known library gaps

Several features in the Feature Catalogue need capabilities the libraries do not currently have β€” a GLN check-digit validator, a Directory search client, a reusable Phorm client, runtime code list access for diffing, and per-user config discovery. They are catalogued with their verified evidence and their proper home in Library Gaps. Most belong upstream rather than in this project.

⚠️ **GitHub.com Fallback** ⚠️