A unified language for logic
By Tzvi Melamed
We did it! We built a beautiful web app using the latest technology, and it meets all of our business goals. The UX team is thrilled with our implementation of their design mocks, and our product manager is excited to see everything working in the real world. The team celebrates with a cake and custom laptop stickers to commemorate the project.
The team gathers together in a meeting room, and the product manager is excited to get everyone on board with the next big goal that the company wants to pursue. A mobile app! Everyone is excited about the idea. As the engineering team gets to work, they realize that nearly all of their work needs to be redone. The web app was designed using isomorphic rendering, so all the application logic is written using typescript to run on the server in Node.js and the client's web browser.
The team worked really hard to make sure they could adapt to changing industry trends or requirements, so the database is abstracted into a nice service interface that can be easily swapped out. The domain is isolated into its own project using the best that domain-driven design has to offer. All the latest typescript features were employed to make the domain objects type-safe and generic over various front-end frameworks or methodologies they might decide to employ.
The team starts to explore using React Native to keep their domain models, but they soon realize that the overhead of a javascript runtime makes their app seem janky during key user interactions. Moreover, the product team wants to use features on iOS that can only be accessed with SwiftUI: rich notifications, home screen widgets, etc. React Native can't be used for these interfaces, so the team looks at building out services with React Native that can be invoked from their SwiftUI code.
What if this whole issue could be solved by simply changing a few lines of configuration code? On the topic of Reversibility, The Pragmatic Programmer writes about what is a universal sentiment in software engineering:
With every critical decision, the project team commits to a smaller target—a narrower version of reality that has fewer options…How can you plan for this kind of architectural volatility? You can't…What you can do is make it easy to change.1
In modern software engineering, much care is taken to postpone making certain business decisions until absolutely necessary. That said, when building a domain model, one of the first decisions required is to choose a programming language.
If you eventually want to create a web app using web technologies to minimize client code and standards like HTML to leverage the web’s ecosystem of accessibility, etc., you’ll need to have business logic accessible from JavaScript. You might try to avoid committing to JavaScript, using something that can easily work over a C ffi instead. You keep business logic on the application server rather than the client, and then whichever language you use on the server can probably invoke your domain code as C. But then, even for just basic CRUD operations, a web form would need to duplicate some logic for validation of central value objects or else make network requests for everything.
This may lead you to want to write a domain model in JavaScript; however, JavaScript is not efficient as a language. If you decide to build a mobile app with significant animation requirements, you don’t want your native code to require a JavaScript runtime for the domain logic.
You might decide, then, to write your domain code in C, C++, Zig, or Rust and, for the web use case, compile to wasm with a JavaScript wrapper. This sounds reasonable, but you still have to ship a wasm module to the client that may be unnecessary and add indirection to your most performance and resource-constrained users running on slow connections or using performance-constrained mobile phones.
Existing landscape
The flutter programming ecosystem has a tool called pigeon 2 that allows you to specify an interface for your code and then implement that interface for each platform target. The pigeon tool takes an interface defined using dart code and generates stubs for various platforms to implement each piece of the interface. Pigeon then handles the serialization, deserialization, and transport of the data between native and dart code over a platform channel. I think this is a step in the right direction, although you still end up having to write the implementation code for each platform.
Haxe 3 is a programming language that can compile to many targets such as JavaScript, C++, Java, and Python. Haxe optimizes for maintaining the structure of your code in the target language, which adds unnecessary overhead. For example, consider the following hello world program in Haxe:
class Test {
static function main() {
trace("Haxe is great!");
}
}
This compiles into the following JavaScript code:
// Generated by Haxe 4.3.1
(function ($global) {
'use strict';
class Test {
static main() {
console.log('Test.hx:3:', 'Haxe is great!');
}
}
class haxe_iterators_ArrayIterator {
constructor(array) {
this.current = 0;
this.array = array;
}
hasNext() {
return this.current < this.array.length;
}
next() {
return this.array[this.current++];
}
}
{
}
Test.main();
})({});
Now, compare this with what a JavaScript programmer might write by hand:
console.log(“Haxe is great!”);
The code generated by Haxe added a lot of unnecessary complexity (code for iterators), indirection (an IIFE, a class, a static method definition, and an invocation), and sought to maintain a feature parity with the Haxe ecosystem that was likely not necessary or desired (ex by including the source code lines in trace
).
The ULL approach
I propose an alternative solution, which I'm calling Unified Logic Language (ULL)
. ULL makes use of learnings across the industry from projects like pigeon and Haxe, and is based on the following invariants:
- Any interface that the programmer marks as exposed will be exposed in the compiled code (similar to how pigeon uses an interface for its interop)
- The output code should make use of zero-cost abstractions and be as fast and small as reasonable code written in the target language by hand.
Let’s look at some examples. First, a hello world program:
// in hello_world.ull
println(“Hello, world!”);
This would generate the following JavaScript:
console.log(“Hello, world!”);
Rust code:
fn main() {
println!(“Hello, world!”);
}
Java:
public class HelloWorld {
public static void main(String[] args) {
System.out.println(“Hello, world!”);
}
}
Let’s try something a bit more complex:
// in basic_math.ull
class Adder {
pub Adder() {}
pub fn add(a: number, b: number) -> number {
return a + b;
}
}
let adder = new Adder();
let result = adder.add(3, 4);
println(“3 + 4 = “ + result);
This results in the following code:
console.log(“3 + 4 = 7”);
fn main() {
println!(“3 + 4 = 7”);
}
public class BasicMath {
public static void main(String[] args) {
System.out.println(“3 + 4 = 7”);
}
}
Notice that, since nothing was exposed to the end user, all of the code was simplified using constant folding and dead code elimination into a single print statement.
What if we write something that exposes the adder to the user?
// in adder.ull
expose class Adder {
expose Adder() {}
expose fn add(a: number, b: number) -> number {
return a + b;
}
}
let adder = new Adder();
let result = adder.add(3, 4);
println(“3 + 4 = “ + result);
class Adder {
constructor() {}
add(a, b) {
return a + b;
}
}
console.log(“3 + 4 = 7”);
pub struct Adder;
impl Adder {
pub fn add<T, U: Add<Rhs = T>>(a: U, b: T) -> U::Output {
a + b
}
}
fn main() {
println!(“3 + 4 = 7”);
}
public class Adder {
public Adder() {}
public <N extends Number> N add(N a, N b) {
return a + b;
}
public static void main(String[] args) {
System.out.println(“3 + 4 = 7”);
}
}
Notice that we’ve used implicit subtyping for numbers in order to produce code that works in languages where numeric types differ. This concept comes from the Ezno project4. In ULL, we choose to leave certain behaviour as implementation-defined by default to avoid generating unnecessary code. For example, if you try to divide 2 integers like 5/2, ULL leaves the result ambiguously specified. In JavaScript, where numbers are represented by floating points, the result would be 2.5. In C, the result would be 2. If you do care about the behaviour, we still allow you to specify this. For example, the integer division operator will insert a Math.floor
in JavaScript, and precise division will cause code in rust to be either limited to floating point inputs or integer types to be converted to floating point types before dividing. Again, when the programmer cares about this behavior, we allow them to specify it.
Note also that, although the interface for the Adder is exposed, the code using the adder is still optimized. This follows the principle in ULL that the only invariants are the exposed interface and optimal implementation code. The structure of the implementation code is not guaranteed to follow the structure of the input code.
Why?
By implementing domain logic in ULL, you can expose a nice interface for your entities, value objects, and services, and share the logic of your domain across platforms and programming languages without sacrificing performance of code size.
Where can I find this?
ULL is still just a concept. If anyone has ideas for iterating on the concepts of ULL, or wants to discuss collaborating on its implementation, please reach out to me on github or Twitter. I’m @TzviPM.
What’s next?
With ULL allowing developers to write language and platform agnostic code, there are other application areas aside from domain logic that could benefit from this approach.
One area for exploration is in UI components. Mitosis5, created by the team at builder.io, allows developers to create ui components that can be compiled into framework-specific components for react, angular, qwik, and others. I imagine a world where this type of platform can be extended to component libraries for SwiftUI, Jetpack Compose, Flutter, and React Native, among others.
Currently, I am not aware of any cross-platform UI framework that compiles code into SwiftUI and Jetpack compose, and this ends up posing some limitations on which platform features can be represented. For example, Home Screen widgets on iOS and android rich notifications do not offer a way for developers to write ui code in dart/flutter nor react native. Currently, the solution for both of those ecosystems is to write the platform features in the native languages and link to some of the application services / business logic in react native / flutter through various forms of application channels.
Another area to explore is multiple implementation strategies per programming language, potentially allowing libraries to be written to hook into the compilation process. I could imagine, for example, JavaScript code being written to an interface that is compiled for a server with “actual code” and also for a client with network calls that call the corresponding methods on the server. Similarly, an LSP server could be written to an interface and the implementation could be swapped out between plain-old functions, ffi, efficient messaging with channels and shared memory to a separate process or thread, or using some form of rpc protocol over a network. This approach could be used for a service-oriented architecture to avoid rpc overhead while enabling autoscaling, etc.
Footnotes
-
Thomas, D., & Hunt, A. (2019). The pragmatic programmer, 20th anniversary edition: Journey to mastery (Second edition). Addison-Wesley. ↩
-
https://kaleidawave.github.io/posts/introducing-ezno/#making-all-function-parameters-generic%2Fdependent ↩