Introduction
Software development is undergoing a fundamental transformation.
As I discuss in my book (shameless plug 😊), the barrier to entry is consistently trending toward simpler and more expressive ways to convey executional intent.
I genuinely appreciate many aspects of low-code development. Over the next sections, we’ll explore the key pillars that define low-code platforms. At its core, low-code is about optimization: enabling faster delivery of high-quality software.
However, I have concerns about where current trends are heading. Many of the leading low-code platforms are becoming increasingly closed off. They often restrict developers to using only what’s available in their proprietary stores. While external extensions are technically possible, integrating them is far from straightforward. Moreover, the underlying structure of these systems is typically hidden from the user.
While the goal of low-code is to abstract complexity, I believe a better balance is needed. Rather than making the low-code tool the centerpiece, it should complement the application’s existing codebase. Instead of hiding everything, the platform should adopt a clear, configurable convention—one that offers transparency into its structure and decisions.
For instance, take a data model definition. Low-code platforms can (and should) auto-generate code from that model. This is a core principle: “generate all you can.” But it should follow a convention—like storing all models in a data_model folder by default. If a developer prefers a different folder name, that’s fine—just maintain the understanding that a dedicated model folder is part of the convention.
Common Low / No Code Pillars
1. Generate What You Can
Principle: Automatically generate code, UI, or logic where patterns are predictable or repetitive.
Features Supporting This:
- Drag-and-drop UI builders that auto-generate HTML/CSS/JS.
- Visual workflow editors that create backend logic without manual scripting.
- Form generators from data schemas or database structures.
2. Abstract the Complexity
Principle: Simplify complex logic, architecture, or plumbing from the end-user.
Features Supporting This:
- Visual data modeling tools instead of raw SQL or JSON.
- Reusable components and templates for logic blocks, integrations, and UI widgets.
- Auto-managed infrastructure (e.g., authentication, API gateways).
3. Configure Over Code
Principle: Prefer configuration through UI or metadata over manual code writing.
Features Supporting This:
- Property panels and settings menus for behaviors, styles, and rules.
- Declarative UI rules like visibility conditions, validations, or triggers.
- Environment variables and global config UIs.
4. Empower Domain Experts
Principle: Let business users (non-developers) build and deploy apps with minimal engineering help.
Features Supporting This:
- Business rule engines with natural language or decision tables.
- Easy data binding between forms and data models.
- Access control systems that can be configured without code.
5. Composable Building Blocks
Principle: Provide modular units of functionality that can be assembled like LEGO bricks.
Features Supporting This:
- Component libraries (buttons, tables, charts).
- Logic blocks (if/then, loops, integrations).
- Plug-and-play connectors for databases, APIs, and services.
6. Visualize Everything
Principle: Provide a visual representation of data, logic, and flow to aid understanding and debugging. Follow the design principle of “Information at a glance”
Features Supporting This:
- Flow diagrams for automation logic.
- Data flow visualization for inputs/outputs.
- State and execution trace visualizers for debugging.
7. Standardize with Templates
Principle: Encourage reusability and best practices by using templates and patterns.
Features Supporting This:
- App or module templates.
- Cloneable workflows or pipelines.
- Design system integrations.
8. Support Escape Hatches
Principle: Allow custom code when needed to bypass limitations.
Features Supporting This:
- Custom script blocks (JavaScript, Python, etc.).
- Plugin or extension mechanisms.
- Code injection for advanced styling or behavior.
9. Secure and Governable by Default
Principle: Enforce good security and governance automatically.
Features Supporting This:
- Role-based access control.
- Audit logs and change history.
- Prebuilt compliance and security rules.
10. Deploy Instantly
Principle: Make it possible to test, preview, and deploy with one click.
Features Supporting This:
- Real-time preview.
- Versioning and rollback.
- CI/CD integration out of the box.
Making it work
The principles outlined above are solid, but it’s worth noting that many of them can be achieved without a dedicated low-code tool. That said, I believe there’s room to improve on the current models and how they approach core functionality.
After evaluating several low-code platforms—especially those featured in the magic quadrant—my biggest takeaway is this: generate as much as possible. Automating scaffolding and repetitive patterns can save a significant amount of time. However, I’ve also consistently found these systems to be lacking in depth and flexibility where it really counts.
Take validation as a concrete example.
Most platforms treat form validation as a default behavior tied to UI elements. But this conflates concerns. In a robust system, validation should focus on the data itself, not just the form. Data can be updated via side effects—outside the context of a form—so tying validation solely to UI interactions is a brittle strategy.
Worse still, the validation logic they generate is often superficial. Deeper and more contextual validation should be possible. I expect a low-code platform to validate individual fields, provide precise error messages, and drive interaction to correct those errors. For instance, if a field is invalid but hidden in a collapsed group or on an inactive tab, the system should:
- Switch to the appropriate tab,
- Automatically expand the group,
- Scroll the input into view,
- And focus the element.
These are the kinds of UX details that platforms should handle out of the box, but currently don’t.
What’s missing is a bridge between configuration and code generation—a formalized intermediate language. This is where a well-defined DSL (domain-specific language) comes into play.
The DSL doesn’t have to be complex. In our case, we’re opting for a text-based language. It’s designed to be:
- Easy to author by hand,
- Friendly for UI-based editing,
- And AI-generatable.
With this, we can express high-level intent, let the system generate the code, and still have the flexibility to go deeper when needed.
DSL – data model example
ENTITY: Person, 1.0.1, "Person entity"
REST:
collection: "/people"
item : "/people/{id}"
create : "/people"
delete : "/people/{id}"
update : "/people/{id}"
COLLECTIONS:
roles: ENUM
0=user
1=admin
2=superadmin
options: ARRAY = [1, 2, 3]
values: ARRAY = [
{id: 1, name: "One"},
{id: 2, name: "Two"},
{id: 3, name: "Three"}
]
PROPERTIES:
id : INT REQUIRED AUTO_INCREMENT PRIMARY_KEY READONLY
name : STR REQUIRED MIN_LENGTH=1 MAX_LENGTH=100
age : INT REQUIRED MIN=18 MAX=65
email : EMAIL
notes : STR MAX_LENGTH=1000
role : ENUM roles DEFAULT=user
active : BOOLEAN DEFAULT=true
option : INT IN options
value : INT IN values
VALIDATIONS:
inactive_role_is_null:
IF active IS FALSE
THEN role MUST BE NULL
TRIGGERS:
on_active_changed:
IF active IS FALSE
THEN SET role TO NULL
AND SET notes TO "User is inactive"The above is a work in progress definition but it highlights some key points.
One of the foundational pillars of building robust low-code systems is having a well-defined, declarative layer that describes the data model — independent of how it’s presented in the UI. The DSL (domain-specific language) I’ve developed follows that exact philosophy.
Here’s a quick look at how it works, using an example entity:
ENTITY: Person, 1.0.1, "Person entity"
...
The Purpose
This DSL enables full-stack code generation — from frontend validation logic and form inputs to backend routes and data schemas. It’s UI-agnostic, focusing on modeling data, validation, and behavior at a declarative level. This separation of concerns means the same data model can power:
- Generated backend REST APIs
- Rich frontend forms with deep validation
- Dynamic UI rendering with context-aware behaviors
Entity Definition
Each ENTITY block begins with a name, version, and description. This acts as a root definition, under which all properties, behaviors, and relationships are declared.
ENTITY: Person, 1.0.1, "Person entity"
When the version changes, the system regenerates the codebase accordingly.
REST Endpoints
You can define standard REST endpoints to support API generation. These can be mapped directly into routes in a backend framework or used by frontend clients for data binding.
REST:
collection: "/people"
item : "/people/{id}"
Collections and Lookups
Collections support both validation and UI generation. You can define enums, arrays, and complex object sets that can be used to:
- Validate values (e.g., ensuring a field is one of a predefined set)
- Populate dropdowns or dynamic selection inputs
- Apply conditional logic based on field values
COLLECTIONS:
roles: ENUM
0=user
1=admin
2=superadmin
options: ARRAY = [1, 2, 3]
values: ARRAY = [
{id: 1, name: "One"},
{id: 2, name: "Two"},
{id: 3, name: "Three"}
]
Property Declarations
The PROPERTIES section defines the data schema. Each field supports:
- Types (e.g.,
INT,STR,BOOLEAN,EMAIL) - Constraints (e.g.,
REQUIRED,MAX_LENGTH,AUTO_INCREMENT) - References to collections for validation (
IN options,ENUM roles) - Defaults and access control (
READONLY,PRIMARY_KEY)
PROPERTIES:
name : STR REQUIRED MIN_LENGTH=1 MAX_LENGTH=100
role : ENUM roles DEFAULT=user
active : BOOLEAN DEFAULT=true
These declarations can be used to generate:
- Database schemas
- Form fields
- Type-safe models
- Validation logic for client and server
Validations and Business Rules
The VALIDATIONS block lets you define cross-field rules declaratively. These are not tied to the UI and enforce logic at the data level.
VALIDATIONS:
inactive_role_is_null:
IF active IS FALSE
THEN role MUST BE NULL
This ensures data consistency even when side effects (like API updates) modify fields out of form context.
Triggers and Automation
The TRIGGERS section handles auto-adjustments to the data when conditions are met. Think of these as soft business rules or side-effect logic that executes when certain fields change.
TRIGGERS:
on_active_changed:
IF active IS FALSE
THEN SET role TO NULL
AND SET notes TO "User is inactive"
This supports UI interactions (like dynamically adjusting a form) and backend logic (like sanitizing incoming data).
The trigger begins with a name, followed by a simplified expression that describes its intent. This name will also serve as the function name in the generated code. While snake_case is used here, the code generator will adapt the naming convention to match best practices for the target language.
Why This Matters
Most low-code platforms treat validation and UI logic as bolted-on features. This DSL flips that. It treats the data layer as the source of truth. Everything else — UI rendering, forms, validations, API scaffolding — is generated from this base.
Because the DSL is text-based, it can be:
- Written by hand
- Generated by an AI
- Enhanced with a visual editor later
This makes it both human-readable and machine-operable, striking the right balance for automation without sacrificing clarity.
JavaScript Result
The generated JavaScript result could look like this.
// Person.js
const roles = {
0: 'user',
1: 'admin',
2: 'superadmin',
};
const options = [1, 2, 3];
const values = [
{ id: 1, name: 'One' },
{ id: 2, name: 'Two' },
{ id: 3, name: 'Three' },
];
// Utility for value validation
const isInCollection = (value, collection) => {
if (Array.isArray(collection)) {
return collection.includes(value);
}
return Object.keys(collection).includes(String(value));
};
export class Person {
constructor(data = {}) {
this.id = data.id ?? null; // AUTO_INCREMENT, READONLY
this.name = data.name ?? '';
this.age = data.age ?? null;
this.email = data.email ?? '';
this.notes = data.notes ?? '';
this.role = data.role ?? 'user';
this.active = data.active ?? true;
this.option = data.option ?? null;
this.value = data.value ?? null;
}
validate() {
const errors = {};
if (!this.name || this.name.length < 1 || this.name.length > 100) {
errors.name = 'Name is required and must be between 1 and 100 characters.';
}
if (typeof this.age !== 'number' || this.age < 18 || this.age > 65) {
errors.age = 'Age must be between 18 and 65.';
}
if (this.email && !/^\S+@\S+\.\S+$/.test(this.email)) {
errors.email = 'Email format is invalid.';
}
if (this.notes && this.notes.length > 1000) {
errors.notes = 'Notes must be at most 1000 characters.';
}
if (!isInCollection(this.role, roles)) {
errors.role = 'Invalid role selected.';
}
if (!isInCollection(this.option, options)) {
errors.option = 'Invalid option selected.';
}
if (!values.some(v => v.id === this.value)) {
errors.value = 'Invalid value selected.';
}
// DSL-defined custom validation
if (this.active === false && this.role !== null) {
errors.role = 'Role must be null when the person is inactive.';
}
return errors;
}
// DSL-defined trigger logic
onActiveChanged() {
if (this.active === false) {
this.role = null;
this.notes = 'User is inactive';
}
}
// Example of how REST endpoints could be structured
static endpoints = {
collection: '/people',
item: '/people/{id}',
create: '/people',
delete: '/people/{id}',
update: '/people/{id}',
};
}
Validation is comprehensive and reflects all DSL constraints, including data types, ranges, and custom rules.
Trigger onActiveChanged() executes logic based on a condition, directly derived from the DSL’s TRIGGERS section.
REST endpoints are included as static properties to allow integration with fetch/axios clients or express routers.
Uses clean code practices: separation of validation logic, no business logic in the constructor, use of constants.
Note: While reviewing the generated JavaScript gives a clear idea of the model’s structure, the DSL offers a more concise and immediate understanding. It’s easier to pinpoint specific logic—like which validations apply to the role property—just by scanning the DSL definition.
Additionally, a well-structured DSL like the one above is ideal for AI-assisted code generation. Its clear, unambiguous format makes it easy to interpret. In fact, the JavaScript example shown earlier was generated by ChatGPT without any instructions beyond specifying that the input was a DSL and the desired output should be JavaScript. As AI improves the code quality will also keep improving.
Since the DLS is a expression of intent it can be used to generate any language required.
Form
In keeping with the principle of separation of concerns, the form is defined using its own DSL, which has a slightly different structure. While the data model DSL focuses on structure and validation, the form DSL is concerned with layout, interactions, and user experience. It complements the data model by describing how the UI should present and interact with that data.
ENTITY: Person, 1.0.1, "Person entity"
LAYOUT:
TABSHEET:
TAB "Personal Info":
GROUPBOX "Identity":
name
age
GROUPBOX "Contact":
email
TAB "Status":
active
role
notes
CONDITIONS:
admin_updates:
WHEN role IS "admin"
AND active IS TRUE
THEN email IS HIDDEN
AND notes IS READONLYTo keep the DSL clean, reusable, and framework-agnostic, it will be accompanied by a widget dictionary. This dictionary provides the generator with the context it needs to choose the appropriate UI elements (e.g., text inputs, dropdowns, checkboxes) based on the data model and layout definitions. The DSL itself focuses purely on describing structure and behavior, without dictating how the UI should be rendered.
This deliberate separation allows generators to translate the DSL into UI code for any frontend framework—or even render it without a framework at all. Whether the target is React, Vue, Svelte, plain HTML, or a native mobile UI, the DSL remains consistent and generic. The complexity of implementation is left entirely to the generator, which interprets the definitions in context.
Key Concepts of the Form DSL
- Layout-Oriented Design
TheLAYOUTsection organizes fields visually intoTABSHEET,TAB, andGROUPBOXstructures, which can be interpreted as tabs and fieldsets or sections in a UI.
For example, the “Identity” group tells the generator to place thenameandagefields together under a labeled section titled “Identity.” - Behavior Through Conditions
TheCONDITIONSsection allows dynamic UI behavior to be described declaratively. This enables conditional behavior without writing imperative UI logic. The generator interprets this to hide or disable fields at runtime based on the current form state.
Extensibility via Widget Dictionary
A widget dictionary is a supporting structure that maps field types or names to actual UI components. For instance:
{
"STR": "TextInput",
"INT": "NumberInput",
"BOOLEAN": "Checkbox",
"ENUM": "Select",
"email": "EmailInput"
}The generator will use this mapping to decide what kind of input to render. You could also override this per-field using metadata in the DSL, if needed, allowing even more control without bloating the DSL itself.
Why This Approach Works
- Framework-independent: You’re not locked into any rendering tech.
- Maintainable: You can update field layouts or UI logic without touching UI code.
- AI/automation friendly: The structure is deterministic and highly predictable, ideal for code generation or AI tooling.
- Consistent UX: Common behaviors (like conditionally disabling fields) are centralized and reusable.
Summary
Low-code development platforms aim to expedite application creation by reducing manual coding. However, many become proprietary, limiting developers to predefined components and obscuring system structures. Striking a balance between abstraction and transparency is essential; low-code tools should enhance, not replace, an application’s codebase. By adhering to clear conventions and offering modular, configurable components, these platforms can provide both efficiency and customization. Key principles include automating predictable code generation, simplifying complexity, prioritizing configuration over coding, empowering domain experts, ensuring security by default, and enabling instant deployment. Embracing these pillars allows low-code platforms to balance user-friendly abstraction with the transparency developers need for customization.