Problem
@nx/enforce-module-boundaries is enabled in eslint.config.mjs, but depConstraints is still the generated wildcard and no package carries nx.tags:
depConstraints: [{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }]
So we currently get entry-point enforcement (no deep imports into another package’s source) and cycle detection – both valuable – but every one of the 30 packages may depend on every other. The port/adapter structure that already exists in the codebase is a convention, not a constraint.
Proposed tags
Two axes, in each package’s package.json under nx.tags.
scope: – dataset, pipeline, search, sparql, distribution, task, shared
type:
| Tag |
Packages |
type:model |
dataset |
type:port |
task-runner, sparql-importer, sparql-server, distribution-downloader |
type:domain |
pipeline, search, distribution-health, distribution-probe |
type:adapter |
sparql-qlever, sparql-anything, task-runner-docker, task-runner-native, search-typesense, dataset-registry-client |
type:app |
search-api-server, search-indexer |
type:util |
text-normalization, fastify-rdf, docgen |
Proposed constraints
type:model → (nothing internal)
type:port → type:model, type:port
type:domain → type:model, type:port, type:domain, type:util
type:adapter → anything
type:app → anything
The load-bearing rule is type:domain must not depend on type:adapter: it keeps framework packages off concrete infrastructure. type:port → type:port/model keeps contracts light, which is what makes them substitutable.
Deliberately not included: “only type:app may depend on type:adapter”. Adapter-to-adapter composition is legitimate – see sparql-qlever below.
One violation
pipeline → dataset-registry-client. In packages/pipeline/src/selector.ts, pipeline defines the DatasetSelector port and ships a concrete implementation holding a Client:
private readonly registry: Client;
Client and Paginator are value imports from @lde/dataset-registry-client, so the framework package carries a concrete registry dependency.
Fix, following the convention already established by pipeline-console-reporter, pipeline-void, pipeline-shacl-validator and pipeline-shacl-sampler – pipeline defines the ports, pipeline-* packages implement them (ConsoleReporter implements ProgressReporter):
Extract the registry-backed DatasetSelector into a new package, e.g. pipeline-registry-selector, depending on @lde/pipeline and @lde/dataset-registry-client. pipeline keeps the interface and drops the dependency.
Not into dataset-registry-client itself: that would make a generic registry client depend on @lde/pipeline, pulling the framework into every consumer that only wants to query the registry.
Why extract, given there is only one implementation?
The port stays in pipeline – that is where it belongs, since pipeline is the client that declares what it needs. Only the implementation moves, and the reason is not the number of implementations but the dependencies each one drags in:
| Package |
External dependencies |
pipeline-console-reporter |
chalk, ora, log-symbols, pretty-ms |
distribution-downloader |
filenamify-url |
dataset-registry-client |
ldkit, @traqula/parser-sparql-1-1, @traqula/generator-sparql-1-1, @traqula/rules-sparql-1-1 |
The rule this reflects, and which the repo already follows: an implementation lives with its interface, unless it forces that package to carry dependencies it otherwise would not.
LastModifiedDownloader stays alongside the Downloader interface – filenamify-url costs nothing, and splitting would produce two shallow packages.
ConsoleReporter is already split, because co-locating it would force every pipeline consumer, including a headless server, to install terminal formatting libraries.
- The registry-backed
DatasetSelector is the heaviest of the three: it pulls ldkit plus a full SPARQL parser and generator stack into everyone who imports the framework.
With this resolved the tag scheme is green across all 30 packages, with no allow entries.
Checked and deliberately not flagged
pipeline → sparql-importer / sparql-server – both are ports, not adapters. sparql-importer/src/index.ts exports interface Importer plus result types; the concrete importer is sparql-qlever. Depending on these is the correct direction.
sparql-qlever → task-runner-docker and task-runner-native – createQlever.ts is a deliberate factory. It takes mode: 'docker' | 'native' and hides both runner classes from callers, enforcing the invariant that importer and server share one runner. Widening the interface to let callers wire runners themselves would be worse.
distribution-downloader exports both the Downloader interface and LastModifiedDownloader. Cohesive; splitting it to satisfy the type:port rule would produce two shallow packages. Tagged type:port as-is.
Steps
- Add
nx.tags to every package’s package.json.
- Replace the wildcard
depConstraints with the rules above.
- Extract
pipeline-registry-selector; drop dataset-registry-client from pipeline.
- Confirm
nx run-many -t lint is green.
Note
packages/sparql-anything/package.json yields no name when read – worth checking, since it will be invisible to the tag scheme either way.
Problem
@nx/enforce-module-boundariesis enabled ineslint.config.mjs, butdepConstraintsis still the generated wildcard and no package carriesnx.tags:So we currently get entry-point enforcement (no deep imports into another package’s source) and cycle detection – both valuable – but every one of the 30 packages may depend on every other. The port/adapter structure that already exists in the codebase is a convention, not a constraint.
Proposed tags
Two axes, in each package’s
package.jsonundernx.tags.scope:–dataset,pipeline,search,sparql,distribution,task,sharedtype:type:modeldatasettype:porttask-runner,sparql-importer,sparql-server,distribution-downloadertype:domainpipeline,search,distribution-health,distribution-probetype:adaptersparql-qlever,sparql-anything,task-runner-docker,task-runner-native,search-typesense,dataset-registry-clienttype:appsearch-api-server,search-indexertype:utiltext-normalization,fastify-rdf,docgenProposed constraints
The load-bearing rule is
type:domainmust not depend ontype:adapter: it keeps framework packages off concrete infrastructure.type:port → type:port/modelkeeps contracts light, which is what makes them substitutable.Deliberately not included: “only
type:appmay depend ontype:adapter”. Adapter-to-adapter composition is legitimate – seesparql-qleverbelow.One violation
pipeline→dataset-registry-client. Inpackages/pipeline/src/selector.ts,pipelinedefines theDatasetSelectorport and ships a concrete implementation holding aClient:ClientandPaginatorare value imports from@lde/dataset-registry-client, so the framework package carries a concrete registry dependency.Fix, following the convention already established by
pipeline-console-reporter,pipeline-void,pipeline-shacl-validatorandpipeline-shacl-sampler–pipelinedefines the ports,pipeline-*packages implement them (ConsoleReporter implements ProgressReporter):Extract the registry-backed
DatasetSelectorinto a new package, e.g.pipeline-registry-selector, depending on@lde/pipelineand@lde/dataset-registry-client.pipelinekeeps the interface and drops the dependency.Not into
dataset-registry-clientitself: that would make a generic registry client depend on@lde/pipeline, pulling the framework into every consumer that only wants to query the registry.Why extract, given there is only one implementation?
The port stays in
pipeline– that is where it belongs, sincepipelineis the client that declares what it needs. Only the implementation moves, and the reason is not the number of implementations but the dependencies each one drags in:pipeline-console-reporterchalk,ora,log-symbols,pretty-msdistribution-downloaderfilenamify-urldataset-registry-clientldkit,@traqula/parser-sparql-1-1,@traqula/generator-sparql-1-1,@traqula/rules-sparql-1-1The rule this reflects, and which the repo already follows: an implementation lives with its interface, unless it forces that package to carry dependencies it otherwise would not.
LastModifiedDownloaderstays alongside theDownloaderinterface –filenamify-urlcosts nothing, and splitting would produce two shallow packages.ConsoleReporteris already split, because co-locating it would force everypipelineconsumer, including a headless server, to install terminal formatting libraries.DatasetSelectoris the heaviest of the three: it pullsldkitplus a full SPARQL parser and generator stack into everyone who imports the framework.With this resolved the tag scheme is green across all 30 packages, with no
allowentries.Checked and deliberately not flagged
pipeline→sparql-importer/sparql-server– both are ports, not adapters.sparql-importer/src/index.tsexportsinterface Importerplus result types; the concrete importer issparql-qlever. Depending on these is the correct direction.sparql-qlever→task-runner-dockerandtask-runner-native–createQlever.tsis a deliberate factory. It takesmode: 'docker' | 'native'and hides both runner classes from callers, enforcing the invariant that importer and server share one runner. Widening the interface to let callers wire runners themselves would be worse.distribution-downloaderexports both theDownloaderinterface andLastModifiedDownloader. Cohesive; splitting it to satisfy thetype:portrule would produce two shallow packages. Taggedtype:portas-is.Steps
nx.tagsto every package’spackage.json.depConstraintswith the rules above.pipeline-registry-selector; dropdataset-registry-clientfrompipeline.nx run-many -t lintis green.Note
packages/sparql-anything/package.jsonyields nonamewhen read – worth checking, since it will be invisible to the tag scheme either way.