The DSL That Generated Itself
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 a → B 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.