Skip to content

Build a custom Facility

This advanced guide shows how to package and consume a small custom JoinedWorkz Facility with Java and Maven. It starts with the supported core extension contract. A focused advanced step adds one Profile Strategy and one calculated Core Model property.

If you only want to use an existing Facility, follow Profiles and platforms. You do not need a custom .profile file for normal application development.

Scope

This guide covers Maven-based Facility development and consumption. It does not establish a Studio authoring workflow or a general compatibility promise for every internal Core Model or generator-runtime class.

1. What you are building

A custom Facility is a Maven JAR that contains:

  • a .profile resource declaring an Outlet, Cartridge and Platform;
  • a Java Cartridge implementation;
  • one or more generators used by that Cartridge;
  • optionally Strategies and injected helpers that calculate Core Model properties; and
  • optionally templates or shared CMN models.

The Profile DSL has no facility declaration. Facility is the packaging, versioning and publication boundary around the profile and its implementation.

The canonical example is maintained in the joinedworkz-examples repository under example-custom-facility. Its release-pinned source remains the authoritative implementation; the small excerpts below explain its public contracts:

text
example-custom-facility/
├── text-report-facility/
│   ├── model/TextReport.profile
│   ├── pom.xml
│   └── src/main/java/org/joinedworkz/examples/textreport/
│       ├── TextReportCartridge.java
│       ├── TextReportGenerator.java
│       ├── TextReportLabelFormatter.java
│       └── TextReportLabelStrategy.java
└── text-report-consumer/
    ├── model/
    │   ├── additional-text-report-wrapper.cmn
    │   ├── example.cmn
    │   └── imported-base.cmn
    ├── pom.xml
    └── src/test/java/.../TextReportGenerationTest.java

The Facility and Consumer are deliberately separate Maven modules. The Facility must already be built, installed by an earlier Reactor module or published before the Consumer runs JoinedWorkz generation.

2. Create the Facility module

Use an independent artifact version for your own Facility and one exact released JoinedWorkz version for all JoinedWorkz APIs:

xml
<groupId>org.joinedworkz.examples</groupId>
<artifactId>text-report-facility</artifactId>
<version>1.0.0</version>

<properties>
    <joinedworkz.version>1.3.81</joinedworkz.version>
    <maven.compiler.release>21</maven.compiler.release>
</properties>

Package the directory containing the Profile as a Maven resource:

xml
<build>
    <resources>
        <resource>
            <directory>model</directory>
        </resource>
    </resources>
</build>

With this configuration, model/TextReport.profile is stored as TextReport.profile at the root of the Facility JAR, where the JoinedWorkz Maven generator can discover it on the Consumer's compile classpath.

Declare every API used by the implementation directly:

xml
<dependencies>
    <dependency>
        <groupId>org.joinedworkz.core</groupId>
        <artifactId>org.joinedworkz.core.facility</artifactId>
        <version>${joinedworkz.version}</version>
    </dependency>
    <dependency>
        <groupId>org.joinedworkz.core</groupId>
        <artifactId>org.joinedworkz.core.model</artifactId>
        <version>${joinedworkz.version}</version>
    </dependency>
    <dependency>
        <groupId>org.joinedworkz.facilities</groupId>
        <artifactId>common-base</artifactId>
        <version>${joinedworkz.version}</version>
    </dependency>
    <dependency>
        <groupId>javax.inject</groupId>
        <artifactId>javax.inject</artifactId>
        <version>1</version>
    </dependency>
</dependencies>
  • org.joinedworkz.core.facility supplies AbstractCartridge, AbstractStrategy, Outlet and Outlets.
  • org.joinedworkz.core.model is required only for the transformed model types the generator actually reads.
  • common-base supplies the imported Base Profile, Base Platform and StandardOutput Outlet. It is a real Profile dependency even though a Java bytecode dependency analyzer may not see its use.
  • javax.inject supplies the injection annotations used by this example.

Do not rely on undeclared transitive dependencies. A Facility should make its compile-time and Profile dependencies visible in its own POM.

3. Define the Profile

TextReport.profile declares the complete wiring:

profile
package org.joinedworkz.examples.textreport.profile

import org.joinedworkz.facilities.common.profiles.base

outlet generatedTextReport specialization of StandardOutput
    directory="./target/generated-text-report"

strategy TextReportLabelStrategy
    implementation="org.joinedworkz.examples.textreport.TextReportLabelStrategy"

cartridge TextReportCartridge
    implementation="org.joinedworkz.examples.textreport.TextReportCartridge"
    outlets=generatedTextReport

platform TextReport specialization of Base {
    apply cartridge TextReportCartridge

    contribute to complextype {
        property reportLabel: STRING strategy=TextReportLabelStrategy
    }
}

Each relationship is required:

  1. The import makes Base and StandardOutput available.
  2. The specialized Outlet inherits the replaceable-output behavior of StandardOutput and chooses a default directory below target.
  3. The Strategy declaration names the Java implementation that calculates reportLabel.
  4. The complextype Contribution associates that Strategy with a typed Core Model property.
  5. The Cartridge implementation names the Java Cartridge class to instantiate.
  6. outlets=generatedTextReport binds the Outlet to that Cartridge.
  7. apply cartridge TextReportCartridge activates the Cartridge for the TextReport Platform.
  8. specialization of Base inherits the active Base behavior.

The last point means the Platform can also generate normal Base output. Assertions about “exactly one generated file” must therefore be scoped to generatedTextReport, not to every directory written by the complete Platform.

For the exact supported declarations and properties, see the Profile author reference.

The calculated-property lifecycle is explained below. Property propagation is a separate advanced contract; use Profile-controlled property propagation only when a value must cross a supported model relationship.

4. Calculate a Core Model property with a Strategy

TextReportLabelStrategy extends AbstractStrategy. JoinedWorkz creates it through the effective Platform Setting and supplies its formatter through dependency injection:

java
@Singleton
public class TextReportLabelStrategy extends AbstractStrategy {

    @Inject
    protected TextReportLabelFormatter textReportLabelFormatter;

    @Override
    public Object apply(CmnObject cmnObject) {
        if (cmnObject instanceof CmnComplexType complexType) {
            return textReportLabelFormatter.format(complexType);
        }
        return null;
    }
}

The formatter implements the example's deterministic naming rule:

java
public String format(CmnComplexType complexType) {
    return "Type " + complexType.getName();
}

For an applicable complex type, a non-null result becomes the calculated Core property reportLabel. An explicit CMN value with the same name keeps priority. The Strategy and helper are stateless singletons; they do not retain per-model state or construct fallback dependencies.

The effective Setting performs construction and injection. Defining and supporting a custom Setting for implementation replacement is outside this guide; do not infer a concrete override recipe from this example. The complete documented lifecycle and its current limits are described in Profile Strategies and calculated properties.

5. Implement the Cartridge

Extend AbstractCartridge and override the Properties-aware entry point:

java
@Singleton
public class TextReportCartridge extends AbstractCartridge {

    @Inject
    protected TextReportGenerator textReportGenerator;

    @Override
    public void apply(
            CmnObject cmnObject,
            Outlets outlets,
            Properties joinedWorkzProperties) {
        if (!(cmnObject instanceof CmnModel cmnModel)) {
            return;
        }

        Outlet textReportOutlet = outlets.get(textReportOutletName());
        if (textReportOutlet == null) {
            throw new IllegalStateException(
                    "Required outlet '" + textReportOutletName()
                    + "' is not configured for TextReportCartridge. "
                    + "Bind it with outlets=" + textReportOutletName()
                    + " in the profile.");
        }

        textReportGenerator.generate(cmnModel, textReportOutlet);
    }

    protected String textReportOutletName() {
        return "generatedTextReport";
    }
}

The public Cartridge contract is:

java
public void apply(
        CmnObject cmnObject,
        Outlets outlets,
        Properties joinedWorkzProperties)

joinedWorkzProperties is non-null; it is empty when the Consumer has no joinedworkz.properties file. The Cartridge should:

  • accept CmnObject and explicitly handle only the transformed model objects it supports;
  • resolve every required Outlet by its Profile name;
  • fail with a diagnostic that identifies missing Profile wiring;
  • orchestrate its own generators; and
  • use dependency injection for replaceable generators and helpers instead of constructing fallback instances.

Only depend on the Core Model types you actually need. This example reads CmnModel, CmnObject, CmnComplexType and CmnNamedObject; it does not imply that every Core Model or runtime class is part of the same extension contract.

If the Cartridge has model contracts that must hold before output is written, implement the separate Cartridge validation and diagnostics contract. In particular, validate only the current model and leave imported-model orchestration and Studio-marker routing to the framework.

6. Consume the completed Core property and write through the Outlet

The generator receives its selected Outlet from the Cartridge and writes through Outlet.generateFile. Its Outlet-facing excerpt is:

java
@Singleton
public class TextReportGenerator {

    public void generate(CmnModel model, Outlet outlet) {
        outlet.generateFile(reportFileName(model), render(model));
    }

    protected String reportLabel(CmnComplexType complexType) {
        String reportLabel = complexType.getString("reportLabel");
        if (reportLabel == null) {
            throw new IllegalStateException(
                    "Required Core property 'reportLabel' is missing for CMN "
                    + "complex type '" + complexType.getName() + "'.");
        }
        return reportLabel;
    }

    protected String reportFileName(CmnModel model) {
        return model.getNamespace().replace('.', '/')
                + "/model-summary.txt";
    }
}

The generator reads the completed reportLabel; it does not inject or invoke the Strategy and does not duplicate the formatter rule. A missing required property stops generation with a message that identifies the affected model element. If absence is an expected model-contract violation, report it during Cartridge validation; the exception above guards an unexpected broken invariant in this deliberately small example.

Writing through the Outlet preserves the configured directory, layer routing, path validation and generated-file behavior. Do not bypass it with direct filesystem writes.

Use deterministic paths and content:

  • do not include timestamps or machine-specific absolute paths;
  • sort collections whose source order is not part of the output contract, as the complete example does for rendered model-element labels; and
  • derive distinct relative paths for distinct model namespaces.

CMN model namespaces are expected to be unique within a build. See File headers and packages for the namespace contract.

7. Consume the Facility

The Consumer adds the custom Facility to its compile classpath:

xml
<properties>
    <joinedworkz.version>1.3.81</joinedworkz.version>
    <text-report-facility.version>1.0.0</text-report-facility.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.joinedworkz.examples</groupId>
        <artifactId>text-report-facility</artifactId>
        <version>${text-report-facility.version}</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

Configure cmn-maven-plugin with the same JoinedWorkz version. The Maven plugin reference contains the complete execution configuration.

7.1 Direct consumption and explicit override

The smallest Consumer imports the Profile package and selects its Platform:

cmn
package org.joinedworkz.examples.textreport.consumer

import org.joinedworkz.examples.textreport.profile

platform TextReport

type Example reportLabel='Explicit label for Example' {
}

Build the Facility first and then build the Consumer independently:

bash
mvn -f text-report-facility/pom.xml clean install
mvn -f text-report-consumer/pom.xml clean verify

The custom Outlet then contains:

text
text-report-consumer/target/generated-text-report/
└── org/joinedworkz/examples/textreport/consumer/
    └── model-summary.txt

The canonical example generates this exact report:

text
JoinedWorkz text report
namespace=org.joinedworkz.examples.textreport.consumer
modelElements=1
elements:
- Explicit label for Example

The explicit value retains priority over the calculated result Type Example and prevents that result from being stored under reportLabel. It proves property priority; the generator still consumes only the effective Core Model property.

7.2 Additional Platform processing for an import

The same Consumer contains a separate advanced pair. The imported model keeps its stable Base Platform:

cmn
package org.joinedworkz.examples.textreport.imported

import org.joinedworkz.facilities.common.profiles.base

platform Base

type ImportedExample {
}

The wrapper imports that model and selects TextReport:

cmn
package org.joinedworkz.examples.textreport.wrapper

import org.joinedworkz.examples.textreport.imported
import org.joinedworkz.examples.textreport.profile

platform TextReport

Because TextReport specializes Base, the imported model already has the Base Cartridge applications and receives only the missing TextReportCartridge through the wrapper. The complete assertion for the custom Outlet is:

text
text-report-consumer/target/generated-text-report/
└── org/joinedworkz/examples/textreport/
    ├── consumer/model-summary.txt
    ├── imported/model-summary.txt
    └── wrapper/model-summary.txt

The imported report retains the imported model's namespace and contains - Type ImportedExample. ImportedExample does not set reportLabel, so the value demonstrates that the Strategy result reached the generated output. If the wrapper is removed, its platform selection no longer applies the TextReportCartridge to the imported model: the directly selected Consumer report remains, while the imported report is no longer generated.

See Imported model processing and diagnostics for Cartridge selection, finalized, ordering, output routing and validation ownership.

The full example keeps its build commands in its own README. Generated files below target/generated-text-report are replaceable output: do not edit or commit them.

8. Test the extension contract

At minimum, test:

  • the exact relative output path and content;
  • deterministic output across clean repeated generation;
  • a non-null Strategy result becoming a calculated Core property;
  • an explicit model value retaining priority and a null result leaving the property absent;
  • the Strategy and helper being supplied and reused through dependency injection;
  • the generator consuming the effective property without rerunning the Strategy;
  • two model namespaces producing two distinct paths;
  • an imported model receiving a missing Cartridge only while its wrapper is present;
  • a missing required Outlet producing an actionable error;
  • an invalid Cartridge implementation name failing the Consumer build;
  • an invalid Strategy implementation name failing the Consumer build;
  • a missing required calculated property producing an actionable error;
  • the Facility JAR containing its .profile resource and implementation classes, including the Strategy and helper; and
  • a Consumer build outside the Facility source Reactor.

Compile and test every downstream project that consumes generated output. A successful Facility build alone does not prove that the packaged Profile can be discovered or that the generated files are usable.

9. Add configuration properties only with implemented behavior

The minimal TextReport Facility does not declare or read its own joinedworkz.properties key. If your Facility needs project configuration, the Profile DSL can register typed properties with a Cartridge, but that declaration does not implement their effect: the Cartridge or an injected helper must read the non-null Properties object and apply the behavior.

The registry validates configured values but does not rewrite the Properties object: missing keys remain absent, blank values remain empty and case-insensitive enumerations retain their configured spelling. Resolve the declared default, blank and case policy in your implementation.

Before publishing a key:

  • implement and test its effect in the consuming Cartridge;
  • cover valid, blank, missing and invalid values as applicable;
  • verify the resulting output or diagnostic; and
  • document its namespace, values, default, blank and case handling, lifecycle, output effect and minimum JoinedWorkz version.

The Profile author reference shows the declaration syntax. The complete metadata and diagnostics contract is in Custom Facilities and additional properties.

10. Version and publish the Facility

Before publishing:

  1. replace every JoinedWorkz development version with one exact released version;
  2. keep the Maven plugin and all JoinedWorkz dependencies aligned to that release;
  3. give the Facility its own version and compatibility statement;
  4. inspect the JAR for the Profile resource and implementation classes;
  5. build a separate Consumer against the installed or published artifact;
  6. document the Facility's Platforms, Outlets, properties, ownership and lifecycle; and
  7. publish to the Maven repository used by its Consumers.

Do not publish an example, guide or Facility release that requires JoinedWorkz SNAPSHOT artifacts. Version alignment and lifecycle rules are summarized in Releases, compatibility and support.

11. Licensing and current limits

Developing and using your own Facility through Maven does not require a JoinedWorkz Studio license. The same applies to publishing your own Facility artifact inside your organization. You determine the license of that artifact; its JoinedWorkz dependencies retain their respective licenses.

This statement does not define external redistribution rights. See Licensing and components for the general product boundary.

The first supported authoring path and the focused Strategy extension do not cover:

  • custom Settings or Strategy use outside the documented calculated-property contract;
  • property Contributions beyond the documented Strategy, lifecycle and propagation contracts, Redirections or stereotype conventions;
  • explicit generator source tags;
  • authoring or loading custom Facilities in JoinedWorkz Studio;
  • Xtext/EMF, build-adapter or release-process customization; or
  • a stability promise for Core APIs not used by this example.

These constructs may exist in the language or built-in Facilities, but syntax availability alone is not a supported end-to-end authoring contract.