Type-safe fluent DSLs in Java require arity families. You need Predicate1 through Predicate7, Join1First through Join6First, a tuple hierarchy from Tuple1 to Tuple6. Every class in each family is structurally identical — the only variation is the number of type parameters. The conventional approaches are to write them by hand or use Freemarker / Velocity templates. Neither produces valid Java; neither gives the IDE anything to navigate.

Permuplate was built to replace both approaches. The template is a valid, compilable Java class. javac invokes the annotation processor and generates the sibling classes. The IDE can refactor the template. The template’s unit tests pass without a code-generation step.

I built the Drools sandbox DSL this way from the start, with Claude doing the code generation work. The JoinBuilder family — Join1First through Join6First, Join1Second through Join6Second — is generated by a 437-line template. What we hadn’t done was apply Permuplate to the simpler arity classes in the same codebase that were still hand-written.

RuleExtendsPoint: the obvious case

RuleExtendsPoint carried six inner classes. Each was ten lines:

public static class RuleExtendsPoint2<DS, A> {
    private final RuleDefinition<DS> baseRd;
    public RuleExtendsPoint2(RuleDefinition<DS> baseRd) { this.baseRd = baseRd; }
    RuleDefinition<DS> baseRd() { return baseRd; }
}
public static class RuleExtendsPoint3<DS, A, B> { /* identical */ }
public static class RuleExtendsPoint4<DS, A, B, C> { /* identical */ }
// three more

88 lines, no logic, pure structural repetition. The template that replaces it is 37 lines:

@Permute(varName = "i", from = "3", to = "7", className = "RuleExtendsPoint${i}",
         inline = true, keepTemplate = true)
public static class RuleExtendsPoint2<DS,
        @PermuteTypeParam(varName = "k", from = "1", to = "${i-1}",
                          name = "${alpha(k)}") A> {
    private final RuleDefinition<DS> baseRd;
    public RuleExtendsPoint2(RuleDefinition<DS> baseRd) { this.baseRd = baseRd; }
    RuleDefinition<DS> baseRd() { return baseRd; }
}

@PermuteTypeParam on A expands the type parameter list: <DS, A> for RuleExtendsPoint2, <DS, A, B> for RuleExtendsPoint3, through <DS, A, B, C, D, E, F> for RuleExtendsPoint7. The outer class RuleExtendsPoint is preserved by inline mode — its inner class naming convention survives intact, so nothing in the rest of the codebase needed updating.

BaseTuple: the harder case

BaseTuple had five hand-written inner classes — Tuple2 through Tuple6. Each added a new typed field with getter and setter. Each also re-implemented the full get(int) if-chain from scratch:

// Tuple6.get() — 420 lines into the file
if (index == 0) return (T) a;
if (index == 1) return (T) b;
if (index == 2) return (T) c;
if (index == 3) return (T) d;
if (index == 4) return (T) e;
if (index == 5) return (T) f;
throw new IndexOutOfBoundsException(index);

No delegation to the parent. Every class duplicated everything its ancestors already handled. The right design is for each class to handle only its own index and delegate the rest:

// Tuple6.get() — after refactor
if (index == 5) return (T) f;
return super.get(index);

That delegation refactor was the prerequisite. It is a better design independently of any templating — each class expresses only what is new about it. Tuple6 went from 70 lines to 38.

With delegation in place, Tuple1 was a viable template for Tuple2..6. One gap remained: @PermuteDeclr could rename fields — A aB b — but not method declarations. The named getters were the blocker. getA() generating getB() requires renaming the method signature, not just the field. We added ElementType.METHOD to @PermuteDeclr’s @Target and the corresponding transformer logic.

The template handles the full transformation:

@Permute(varName = "i", from = "2", to = "6", className = "Tuple${i}",
         inline = true, keepTemplate = true)
@PermuteExtends("Tuple${i-1}<${typeArgList(1, i-1, 'alpha')}>")
public static class Tuple1<
        @PermuteTypeParam(varName = "k", from = "1", to = "${i}",
                          name = "${alpha(k)}") A> extends BaseTuple {

    @PermuteDeclr(type = "${alpha(i)}", name = "${lower(i)}")
    protected A a;

    @PermuteDeclr(type = "${alpha(i)}", name = "get${alpha(i)}")
    public A getA() { return a; }

    @PermuteDeclr(type = "", name = "set${alpha(i)}")
    public void setA(@PermuteDeclr(type = "${alpha(i)}", name = "${lower(i)}") A a) {
        this.a = a;
    }

    @Override public <T> T get(int index) {
        @PermuteConst("${i-1}") int idx = 0;
        if (index == idx) return unchecked(a);
        return super.get(index);
    }
    // set() mirrors get()
}

@PermuteTypeParam expands the class type parameters. @PermuteDeclr on the field renames the declaration and propagates through every body usage — including this.a in the setter. @PermuteDeclr on getA() renames the method name and return type. @PermuteConst sets the correct index literal in get() and set(). @PermuteExtends wires the inheritance chain: Tuple2 extends Tuple1<A>, Tuple3 extends Tuple2<A, B>, and so on.

The generated Tuple2 is exactly what you’d write by hand:

public static class Tuple2<A, B> extends Tuple1<A> {
    protected B b;
    public Tuple2() { this.size = 2; }
    public Tuple2(A a, B b) { super(a); this.b = b; this.size = 2; }
    public B getB() { return b; }
    public void setB(B b) { this.b = b; }
    @Override public <T> T get(int index) {
        int idx = 1;
        if (index == idx) return unchecked(b);
        return super.get(index);
    }
    // ...
}

What changed

  Lines Status
RuleExtendsPoint (before) 88 6 hand-written inner classes
RuleExtendsPoint (after) 37 1 template generating 5
BaseTuple Tuple1..6 (before) 330 5 hand-written inner classes
BaseTuple with template (after) 133 template + outer class + Tuple0

The five hand-written Tuple variants are gone. The five hand-written RuleExtendsPoint variants are gone. Adding a new method to the Tuple interface means changing the template once. Supporting arity 8 is a one-number change in each template.

The total Drools sandbox now generates 2,755 lines from 654 lines of template source — a 4.2× expansion. The JoinBuilder alone produces 2,166 generated lines from 437 template lines.

The working title for this phase was “the DSL that Permuplate was built for.” It turned out to be the first real stress test of Permuplate itself — the @PermuteDeclr method feature didn’t exist until the DSL needed it.


<
Previous Post
Phase 5: Porting Python's HTTP enrichment pipeline to Java
>
Next Post
Two Fields in the Wrong Place