Part III — Model-Driven Development · Chapter 5

Meta-models, DSLs and code generation

~40 min read4 interactive widgets4 plates

In this chapter

  1. Meta-modelling nomenclature
  2. Why meta-models are important
  3. Model-driven whatever, and code generation
  4. Domain-specific languages
  5. DSL versus GPL
  6. DSL engineering: semantics and execution
  7. External and internal DSLs
  8. Tools for MDD, and Xtext
  9. The Sheduler running example
  10. Validation and scoping rules
  11. Execution engine, generator, interpreter
  12. Check your understanding

1. Meta-modelling nomenclature

The lecture goals are stated in three lines: understand metamodelling, understand domain specific languages, practice with model driven development in Xtext. The first goal needs a vocabulary that is built by asking the same question three times.

TermDefinitionExample
(Abstract) Language(abstract) syntax + semantics
ModelThe abstract language by which we describe the possible entities involved in a domainYour domain model from Chapters 2-4
Meta-modelThe abstract language by which we describe modelsUML is the meta-model behind object-oriented programming
Meta-meta-modelThe abstract language by which we describe meta-modelsMOF (Meta-Object Facility) is the meta-meta-model behind UML, according to OMG (Object Management Group)

Three remarks accompany the definition of model, and they explain why the word is used differently here than in ordinary speech: the model abstracts a number of similar systems rooted in the domain; each system is an instance of the model it has been designed from; a model is a template for several systems.

The ladder is generated by one recurring question — in which language is this expressed? — applied first to the model, then to the meta-model. It stops at MOF by convention, because MOF is expressed in terms of itself.

2. Why meta-models are important

The slide devoted to this question is short and unusually practical:

Key idea

This is the concrete payoff of the whole abstraction ladder, and it connects directly to the exam requirement of targeting two or more platforms: the fastest route from a known platform to an unknown one is to enumerate the meta-model elements you already master and to look for their expression in the new language. Learning a platform then costs a translation table, not a second career.

3. Model-driven whatever, and code generation

The lecture acknowledges the terminological fog directly: several slightly similar names create confusion — model-driven engineering / development / architecture — and it recommends reading Martin Fowler's article on Model-Driven Software Architecture to clarify. Despite the names, the key ideas are two:

The second idea has a prerequisite, spelled out on the following slide. The assumption is that the model is expressed by means of some formal language, where formal ≈ interpretable by a machine, and:

Formality is a prerequisite for automation.

The chain that formality enables has three links: the model is parsed by a machine; the model is transformed into another formal language (for instance an OO programming language); the transformed model is rendered into a file (for instance source code).

Is UML adequate? Is it the only choice?

The lecture answers with a qualified no:

The second objection is the fatal one for DDD purposes: a notation that domain experts cannot read cannot be the place where the ubiquitous language lives. That is the opening through which domain-specific languages enter.

4. Domain-specific languages

Domain-specific languages (DSL) are programming, description or specification languages targeting one particular class of problems: they are not meant to address all possible problems, but just the ones they are designed for. This is the opposite of general-purpose languages (GPL), which target as many classes of problems as possible — the programming languages you have learned so far are GPLs.

And then the sentence that ties this chapter back to Chapter 2: DSLs may act as custom meta-models for a given domain. Where UML gives you one fixed meta-model designed for software engineers, a DSL lets you define a meta-model whose vocabulary is the ubiquitous language of your domain.

Examples of DSLs you may already know: regular expressions for text processing, SQL for database querying, CSS for styling web pages, HTML for describing web page content, DOT for graph visualisation, PlantUML for UML diagram visualisation, Gherkin for Behaviour-Driven Development, VHDL for hardware description.

DOT: a DSL to visualise graphs. It describes graphs for the sake of their visualisation; the most common implementation is the Graphviz toolkit.

digraph G {
    size ="4,4";
    main [shape=box];
    main -> parse [weight=8];
    parse -> execute;
    main -> init [style=dotted];
    main -> cleanup;
    execute -> { make_string; printf}
    init -> make_string;
    edge [color=red];
    main -> printf [style=bold,label="100 times"];
    make_string [label="make a string"];
    node [shape=box,style=filled,color=".7 .3 1.0"];
    execute -> compare;
}

Gherkin: a DSL to write BDD tests in a human-friendly way. It describes behavioural tests for software systems, that is, what the system should do in a given scenario. Its syntax is very flexible and it seems like natural language, so stakeholders and engineers can agree on a set of behavioural specifications written in it. The most common implementation is Cucumber, which allows the semi-automated translation of Gherkin specifications into executable tests.

Scenario: Verify withdraw at the ATM works correctly
Given John has 500$ on his account
When John ask to withdraw 200$
And John inserts the correct PIN
Then 200$ are dispensed by the ATM
And John has 300$ on his account

VHDL: a DSL to design hardware circuits — the logic gates and their interconnections. It seems like an ordinary programming language, but "variables" are indeed signals and "functions" are indeed circuits. Technologies exist to translate VHDL into hardware circuits automatically (for instance Xilinx Vivado), or to simulate the behaviour of the circuit, either in software or in FPGA.

DFF : process(RST, CLK) is
begin
if RST = '1' then
Q <= '0';
elsif rising_edge(CLK) then
Q <= D;
end if;
end process DFF;

A DSL for financial accounting (Strumenta; details at tomassetti.me/financial-accounting-dsl). Goal: use a DSL to describe taxes, pension contributions, and general financial calculations. Read it as the strongest argument for DSLs: the text below is reviewable by an accountant.

pension contribution InpsTerziario paid by owner {
    considered_salary = (taxable of IRES for employer - amount of IRES for employer
                         - amount of IRAP for employer) by ownership share
    rate = brackets [to 46,123] -> 22.74%,
                    [to 76,872] -> 23.74%,
                    [above]     -> 0%
    amount = (rate for considered_salary) with minimum 3,535.61
}

Benefits of adopting a DSL

5. DSL versus GPL

DSLs are not a replacement for GPLs: they are complementary. The difference is admittedly fuzzy, so the lecture clarifies it with a comparison along eight dimensions.

DimensionGPLDSL
Domainanyclear boundary
Syntactical constructsmany and composablefew and static
ExpressivenessTuring-completepossibly, less than Turing-complete
Customisabilitymaximisedminimised / confined / absent
Defined bycompanies or committeesteams of domain experts
User baselarge, anonymous, widespreadsmall, accessible, local
Evolutionslow, well-structuredfast-paced
Deprecationvery slowfeasible, often abrupt
Editor's note

Read the last three rows together: they describe an economy, not a technology. A DSL can evolve quickly and be deprecated abruptly because its user base is small, local and reachable — you can call every user. This is the same locality argument that governs bounded contexts in Chapter 4: a small, owned, well-bounded thing can change; a large anonymous one cannot.

6. DSL engineering: semantics and execution

Most often the focus is on the syntax of a DSL, because that is how users perceive it. Yet the semantics is equally important: that is what dictates how the DSL works, and it is what engineers (DSL implementers) focus upon. Intuitively, semantics is given to languages by writing the machinery supporting their execution, in three aspects:

  1. Conversion into runnable code (translation or interpretation)
  2. leveraging on an execution engine (the library functionalities supporting the runnable code)
  3. in turn relying on a software platform (JVM, .NET, and so on)

And the division of labour: the role of DSL engineers mostly focuses on steps 1 and 2, other than defining the syntax.

Two ways to convert a DSL into runnable code

ApproachDefinitionNames and examples
Translation Translates a DSL script into a language for which an execution engine on a given target platform exists Also called code generation or transpilation if the target language is high-level (Java, JS, C#) — e.g. Xtend and TypeScript, despite being GPLs, are transpiled into Java and JS. Also called compilation if the target is low-level (assembly, JVM bytecode, CRL) — e.g. Java is compiled into JVM bytecode
Interpretation The execution engine is able to parse and execute the DSL script directly Also called runtime interpretation or runtime compilation if the engine can compile the script into runnable code — e.g. 2P-Kt is a GPL interpreted by a custom execution engine written in Kotlin, running on the JVM

In both cases there are technical prerequisites: a parser for the actual syntax of the DSL should exist or be generated, and the execution engine for the target platform should exist. The engine is unavoidable: be it internal or external, transpiled or interpreted, the DSL needs an execution engine — a library providing the functionalities of the DSL. This is no different from any other library supporting some given domain, except that hacks could be exploited to ease the adoption of the target DSL syntax.

7. External and internal DSLs

Everything discussed so far concerns external DSLs: languages whose syntax is totally custom, hence requiring a custom parser. The alternative is an internal (or embedded) DSL: the syntax is a subset of some pre-existing GPL, one whose syntax is flexible enough to allow customisation.

Creating internal DSLs is described as a recent trend, enabled by the wide adoption of flexible GPLs such as Kotlin, Groovy or Scala, which come with ad-hoc constructs: trailing-lambda convention, infix notation, operator overloading. Examples you may already know: the Kotlin DSL for Gradle, and SBT.

plugins {
    `java-library`
}

dependencies {
    api("junit:junit:4.13")
    implementation("junit:junit:4.13")
    testImplementation("junit:junit:4.13")
}

sourceSets {
    main {
        java.srcDir("src/core/java")
    }
}

java {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
}

The lecture reads that file as follows: the domain is build automation; this is pure Kotlin plus the Gradle library, which contains an execution engine; and the Gradle library is designed to be used as a Kotlin DSL.

Key aspects of internal DSLs

AspectConsequence
Eased adoptionUsers may already know the GPL, hence they may use the DSL without learning a new language, and they may reuse the same toolkits available for the GPL (debugger, IDE)
Simplified engineeringNo need to write and maintain a custom parser (the GPL provides it), nor custom toolkits (the GPL ones may be reused)
Tight integrationThe DSL may exploit the constructs of the GPL, and this is commonly desired; but the DSL is technologically and syntactically bound to the GPL, and this is commonly undesired
For the exam

The internal/external distinction is a favourite because it forces a trade-off to be stated rather than a fact recalled. External: full syntactic freedom, at the cost of writing and maintaining a parser and a toolchain. Internal: zero parser work and free tooling, at the cost of being technologically and syntactically bound to the host GPL. Note that the trade-off cuts both ways in the same sentence of the slides: tight integration is desired for constructs and undesired for coupling.

8. Tools for MDD, and Xtext

ToolRole
Eclipse's XtextWidespread tool for MDD
JetBrains' MPSMain competitor of Xtext
LangiumClone of Xtext, but based on TypeScript
ANTLROnly parser generation, for Java, JS, Python, .Net, C++
Language Server Protocol (LSP)De-facto standard protocol among IDEs, providing IDE-like capabilities as-a-service, making it easier to support multiple IDEs for the same language — a must-have feature for any MDD tool

About Xtext

Xtext is a framework for MDD and, in particular, external DSLs. The characterisation given in the lecture is worth reading twice, because it collapses two activities that are usually separate:

From a single grammar file, Xtext automatically generates the full language infrastructure: model interfaces and classes (EMF compliant), parser, validator (with pluggable rules), transpiler stub, scoping (with pluggable rules), IDE support via LSP, syntax colouring, test stubs, and more.

9. The Sheduler running example

The running example is the task scheduling domain, and the language is called ShedulerSheduler ≡ Shell + Scheduler. The domain description, in the style of Chapter 2:

The plan of work is stated up front: use Xtext's meta-modelling language to define the domain of task scheduling; simultaneously define the syntax of the DSL; add scoping and validation rules; design and implement the execution engine (exploiting Java's ScheduledExecutorService); and finally create a code generator producing Java code from the DSL.

Project structure

sheduler-lang/
├── build.gradle
├── gradle/
├── gradle.properties
├── gradlew
├── gradlew.bat
├── it.unibo.spe.mdd.sheduler/
│   ├── build.gradle
│   └── src/main/java/it/unibo/spe/mdd/sheduler/sheduler
│       ├── GenerateSheduler.mwe2
│       └── Sheduler.xtext
├── it.unibo.spe.mdd.sheduler.ide/
│   └── build.gradle
├── it.unibo.spe.mdd.sheduler.web/
│   └── build.gradle
└── settings.gradle
Sub-projectRole
rootJust the container of the others
shedulerWhere the domain is modelled and the language is defined, including parser, validator, scoping. Two very important files: Sheduler.xtext (modelling and language definition) and GenerateSheduler.mwe2 (configuration of the automated generation of scoping, validation, generation and testing facilities)
ideGeneric IDE support via LSP; depends on sheduler. You do not really need to touch anything in here: the LSP code is generated. May be packed into a runnable jar starting the LSP server
webThe web-based playground; depends on ide. Also generated; may be packed into a runnable jar

Relevant Gradle tasks

The grammar

This is the heart of the chapter: a file that is simultaneously a meta-model and a syntax definition.

A program in the resulting language reads like this — paste it into the web playground started by jettyRun at http://localhost:8080, and press Ctrl+Space to see the auto-completion menu:

pool {
    schedule task greetWorldFrequently {
        command "echo hello"
        entry point "/bin/sh -c"
        in 5 minutes
        repeat every 1 hours
    }
}

From the grammar Xtext generates the model interfaces: TaskPoolSet, TaskPool, Task, AbsoluteTime, Date, ClockTime, RelativeTime, TimeSpan, the TimeUnit enum, and a ShedulerFactory with one creation method per type — and classes are generated too. Compare that list with Chapter 3: a factory plus a composition hierarchy of entities and value-like objects, obtained without writing a line of Java.

10. Validation and scoping rules

A grammar decides what can be parsed. It cannot decide what is meaningful: 25:99 parses perfectly as a ClockTime. That gap is filled by two pluggable mechanisms whose stubs Xtext generates for you.

Validation

Validation rules are defined in the ShedulerValidator class (package it.unibo.spe.mdd.sheduler.validation, Gradle sub-project sheduler-lang/it.unibo.spe.mdd.sheduler). The stub class is generated when running generateXtextLanguage, which simply triggers the execution of GenerateSheduler.mwe2.

package it.unibo.spe.mdd.sheduler.validation;
import it.unibo.spe.mdd.sheduler.sheduler.*;
import org.eclipse.xtext.validation.Check;
import org.eclipse.xtext.validation.CheckType;

public class ShedulerValidator extends AbstractShedulerValidator {
    @Check(CheckType.FAST)
    public void ensureDateIsValid(Date date) {
        if (date.getYear() < 0) {
            error("Year must be positive", date, ShedulerPackage.Literals.DATE__YEAR, 0);
        }
        if (date.getMonth() < 1 || date.getMonth() > 12) {
            error("Month must be between 1 and 12", date, ShedulerPackage.Literals.DATE__MONTH, 0);
        }
        if (date.getDay() < 1 || date.getDay() > 31) {
            error("Day must be between 1 and 31", date, ShedulerPackage.Literals.DATE__DAY, 0);
        }
    }
}

The remarks are the kind of detail that only matters when you sit in front of the IDE, which is exactly why they are worth memorising:

Exercise 1 — custom validation rules

Write custom validation rules covering the following constraints (utility methods are available in class TimeUtils):

Scoping

Scoping rules are defined in the ShedulerScopeProvider class (package it.unibo.spe.mdd.sheduler.scoping), whose stub is likewise generated by generateXtextLanguage.

public class ShedulerScopeProvider extends AbstractShedulerScopeProvider {
    @Override
    public IScope getScope(EObject context, EReference reference) {
        return super.getScope(context, reference);
    }
}

Exercise 2 — custom scoping rules

Write a custom scoping policy for the before and after properties of tasks, such that:

11. Execution engine, generator, interpreter

Giving semantics to the language means choosing one of the two approaches of §6 — interpretation or translation — and both require the definition of an execution engine, a library providing the functionalities of the DSL.

Designing the engine by asking what the platform already offers

The reasoning on the slide is a small masterclass in engineering economy:

The design insights follow: define a custom notion of ShedulerTask encapsulating the command, optionally the shell, the initial delay, optionally the period, optionally the tasks to be executed before/after, and the functionality for executing the command via ProcessBuilder; and a custom notion of ShedulerRuntime leveraging the ScheduledExecutorService API to schedule those tasks for execution.

public class ShedulerRuntime {
    private final ScheduledExecutorService delegate;

    public ShedulerRuntime(ScheduledExecutorService delegate) {
        this.delegate = Objects.requireNonNull(delegate);
    }

    public void schedule(SheduleTask task) {
        if (task.isPeriodic()) {
            delegate.scheduleWithFixedDelay(
                task.asRunnable(),
                task.getDelay().toMillis(),
                task.getPeriod().toMillis(),
                TimeUnit.MILLISECONDS
            );
        } else {
            delegate.schedule(
                task.asRunnable(),
                task.getDelay().toMillis(),
                TimeUnit.MILLISECONDS
            );
        }
    }
}
public Process executeAsync() throws IOException {
    return new ProcessBuilder(entrypoint, command).inheritIO().start();
}

public Runnable asRunnable() {
    return () -> {
        try {
            executeAsync();
        } catch (IOException e) {
            e.printStackTrace();
        }
    };
}

What generated code looks like

Given this DSL program:

pool myPool {
    schedule task greetFrequently {
        command "echo hello"
        entry point "/bin/sh -c"
        in 5 minutes
        repeat every 1 hours
    }
    schedule task greetOnce {
        command "echo hello"
        entry point "/bin/bash -c"
        at 2030/10/11 12:13
    }
}
pool otherPool {
    schedule task shutdownAfter1Day {
        command "sudo shutdown now"
        entry point "/bin/zsh -c"
        in 1 days
    }
}

the generator is expected to emit Java of this shape — note that each pool becomes a method, and that the relative and absolute times of the DSL have become Duration.parse and LocalDateTime.parse calls:

public static void main(String[] args) {
    ShedulerRuntime runtime = new ShedulerRuntime(Executors.newScheduledThreadPool(1));
    pool_myPool(runtime);
    pool_otherPool(runtime);
}

private static void pool_myPool(ShedulerRuntime runtime) {
    ShedulerTask task0 = ShedulerTask.in("greetFrequently", "echo hello", "/bin/sh -c", Duration.parse("PT5M"));
    task0.setPeriodic(Duration.parse("PT1H"));
    runtime.schedule(task0);
    ShedulerTask task1 = ShedulerTask.at("greetOnce", "echo hello", "/bin/bash -c", LocalDateTime.parse("2030-10-11T12:13"));
    runtime.schedule(task1);
}

Exercises 3, 4 and 5

ExerciseTaskThe key part
3 — code generator Write a generator producing code with the structure shown above. ShedulerRuntime and ShedulerTask may be generated as they are in the example Generating the main, where components are assembled together, from doGenerate(Resource, IFileSystemAccess2, IGeneratorContext), calling fsa.generateFile(...) once per output file
4 — interpreter No code generation, just a main: parse the DSL, convert each Task into a ShedulerTask, run each task via a ShedulerRuntime. Here ShedulerRuntime and ShedulerTask are part of the runtime Loading the resource through the injected ResourceSet provider, then iterating taskPools.getPools() and pool.getTasks()
5 — task dependencies Add support for before/after dependencies to the execution engine — the parser already supports that, the execution engine requires some refactoring — then update the generator and the interpreter accordingly The asymmetry between syntax and semantics: the grammar accepted the feature from day one, the engine did not
Key idea

Exercises 3 and 4 produce the same behaviour from the same model by two different routes, which is the cleanest possible demonstration of the point made in §6: a DSL script is a model, and its semantics is whatever machinery you attach to it. Exercise 5 then shows the cost distribution of MDD: adding a feature to the language is a grammar edit, while adding it to the semantics is real work.

Check your understanding

Define model, meta-model and meta-meta-model, with one example each.

Model: the abstract language by which we describe the possible entities involved in a domain (a template for several systems, each system being an instance of it). Meta-model: the abstract language by which we describe models — UML is the meta-model behind object-oriented programming. Meta-meta-model: the abstract language by which we describe meta-models — MOF (Meta-Object Facility) is the meta-meta-model behind UML according to the OMG.

Why should identifying the meta-model be the first thing you do with a new technology?

Because if you grasp the meta-model, you grasp the essence of the technology, and the same meta-model may be shared by many other technologies. Learning a new OOP language then reduces to asking how classes, methods and objects are expressed in it. Note also that when you model a domain you are always exploiting some meta-model, whether you are aware of it or not.

What are the two key ideas of "model-driven whatever"?

(1) The software engineering workflow should start by modelling the domain carefully (for instance with DDD), as opposed to focussing on algorithms and data structures. (2) The production of a runnable implementation should be automated as much as possible, for instance by generating code from models, as opposed to writing code by hand. The second idea has a prerequisite: formality is a prerequisite for automation — the model must be machine-interpretable so that it can be parsed, transformed into another formal language and rendered into a file such as source code.

Is UML an adequate source for code generation?

Only partly. UML does have a formal syntax and semantics, reified into graphical representation rules, but they are rarely enforced by software tools. Moreover UML focuses on software and is therefore practical only for software engineers — which disqualifies it as a language shared with domain experts.

Give four differences between a DSL and a GPL.

Any four of: domain (clear boundary vs any); syntactical constructs (few and static vs many and composable); expressiveness (possibly less than Turing-complete vs Turing-complete); customisability (minimised/confined/absent vs maximised); defined by (teams of domain experts vs companies or committees); user base (small, accessible, local vs large, anonymous, widespread); evolution (fast-paced vs slow and well-structured); deprecation (feasible, often abrupt vs very slow).

Distinguish translation from interpretation, and name their sub-cases.

Translation converts a DSL script into a language for which an execution engine exists: code generation / transpilation when the target is high-level (Java, JS, C#), compilation when it is low-level (assembly, JVM bytecode). Interpretation: the execution engine parses and executes the script directly, possibly as runtime compilation. Both need a parser and an execution engine.

What do you gain and lose with an internal DSL?

You gain adoption (users already know the host GPL and reuse its toolkits: debugger, IDE) and you gain engineering effort (no custom parser, no custom toolkits, because the GPL supplies both). You lose independence: the DSL is technologically and syntactically bound to the GPL, which the slides call commonly undesired, even as the tight integration with GPL constructs is commonly desired.

What does Xtext generate from a single grammar file?

The full language infrastructure: model interfaces and classes (EMF compliant), the parser, a validator with pluggable rules, a transpiler stub, scoping with pluggable rules, IDE support via LSP with syntax colouring, and test stubs. Meta-modelling and DSL definition are done simultaneously, because the Xtext language for defining languages is itself a meta-modelling language.

In an Xtext validator, what determines which rule applies to which model element?

The type of the method parameter, plus the presence of the @Check annotation. The method name is meaningless. The optional CheckType decides when the rule runs: FAST on every modification, NORMAL (the default) on save, EXPENSIVE only on explicit validation.

What is scoping for, and where can it apply in the Sheduler language?

Scoping decides which model elements a cross-reference may resolve to: the IScope is a container of the EObjects admissible as values of an EReference. In Sheduler this can only concern the before and after properties of tasks — the only references in the grammar, written with square brackets as [Task].

Which JDK facilities support the Sheduler execution engine, and what does each provide?

ScheduledExecutorService for scheduling in the future (schedule for one-shot tasks, scheduleWithFixedDelay for periodic ones) and ProcessBuilder for running a command through a shell entry point. The engine wraps them into ShedulerRuntime and ShedulerTask.

Exercise 5 asks to support before/after dependencies. Why is the parser already done?

Because the grammar already declares 'before' before=[Task] and 'after' after=[Task], so the concrete syntax and the generated model classes already carry the feature. What is missing is the semantics: the execution engine needs refactoring, and the generator and interpreter must be updated. This is the general shape of MDD costs — syntax is cheap, semantics is not.