Middle of last year I was asked to do a proof of concept for an assertion-driven interface for buying and selling stocks.
The idea was simple. The user clicks the Buy button, for example, and the micro-service on the back end responds by creating a blank Buy order for that customer along with a set of assertions about the order.
The assertions indicate whether the requirements for placing the order have been met. Naturally, as it is initially a blank order, they have not.
Assertions are us
The idea of the assertion-driven interface is that it looks for the first unmet assertion and, from that, creates the correct widget to permit the user to make the assertion true.
So, for example, the user might be presented with a search box to type or select a given stock from those available. When the user makes that selection, and event is sent to the micro-service that updates the buy order accordingly and returns a new set of assertions.
The interface then provides a widget—perhaps for the number of shares—to allow the customer to make the next assertion true. And so it goes.
Help and “error” messages are part of the assertion, as is datatype. So the components (widgets) used in the progressively-displayed form are effectively generic. They are chosen and populated as indicated by the failing assertions. They are marked valid when the assertion returns true.
When the last assertion—that the order is ready to be processed—is marked true, then the Buy button is displayed and enabled.
I built this in SolidJS for the fun of it, and it worked beautifully. Would like to try it with a larger form.
Code is data
At the core of the assertion-driven form is the recognition that code is just another kind of data. There is a finite and rather limited set of form widgets. Which one we choose depends on the data we’re collecting, whether we are conscious of this or not.
Hence, the components I built were not radio buttons and checkboxes and selects and inputs. The were Boolean fields and Trilean fields and String fields and Element (of a set) fields, as in choose one, or Set fields, as in choose many.
So what does a “BooleanField” look like? Well, it could be represented as a single checkbox. Or two radio buttons, yes and no. Or a switch. Or a select with two options.
Which one is used depends on the company’s design system. It may also be rule-based and configurable.
For example, for an “Element” field, where the user is choosing one from a set of values (an enumeration), this might be represented by radio buttons. But at some cutoff point—say more than ten options—perhaps it switches to a drop down or an autofill field.
In short, the datatype of the value with which we’re working tells us a great deal about the interface we’ll need. Simple rules can tell us more: configurable app-wide or, potentially, by metadata returned with the data itself.
Data-driven UIs FTW
So how does a data-driven UI (DDUI) work?
Simple. One option—the easiest—is that the query response returns enough data for the front-end to fully configure itself, choose the widget, add validation, provide help and/or error messages, permit conditional display, etc.
More complex widgets, such as an auto-lookup address form, can be handled by algebraic (composite) datatypes.
The key is to stop thinking in terms of presentation and to think instead in terms of domain, schema, data.
For example, the data-driven UI does not have “forms”, per se, although an HTML <form>
element will undoubtedly be used (maybe more than one). Instead, the DDUI has mutations.
When the back end indicates that a datum is mutable, i.e., not read-only, then it also provides information about how to mutate it. This is a bit like HATEOAS (Hypertext As The Engine Of Application State).
What might this consist of? How about a URL, an HTTP method, various headers that need to be included (e.g., Content-Type or Authorization). And of course a very specific datatype. Not just “string” but string matching this RegEx. Min length. Max length. Allowed characters. Etc.
Not just a number, but a positive integer between 7 and 42, inclusive.
Specific help messages can be provided (see Stop scolding your users), or the help can be generated directly from the schema, as can the validation.
Code writes code
Take the positive integer example above. It could be as simple as this:
const constraint = {
constraintType: 'and',
tests: [
{
constraintType: TypeOfConstraint.IS_INTEGER,
},
{
constraintType: TypeOfConstraint.AT_LEAST_N,
operand: 7,
},
{
constraintType: TypeOfConstraint.AT_MOST_N,
operand: 42,
},
],
}
It is easy to see from this that we could generate a validator from composable validators:
const validate = makeAnd([
makeIsInteger(),
makeAtLeastN(7),
makeAtMostN(42),
])
validate({ value: 42 }) // returns { value: 42 }
validate({ value: 43 })
/*
returns {
errors: [
{
constraintType: TypeOfConstraint.AT_MOST_N,
operand: 42,
}
],
value: 43
}
*/
We can also easily create a help message:
Must be an integer between 7 and 42, inclusive.
The front end renderer must understand the schema for that value precisely, so it can obviously create validation and help/error messages. It can even update them on the fly. For example, if you use passwords the stupid way:
Your password must be at least 8 characters and include a digit, an uppercase letter, a lowercase letter, and a punctuation mark.
After the user has entered “Fu”:
Your password needs at least 6 more characters to include a digit and a punctuation mark.
And so on. All with zero work on the front end and zero configuration. Change the datatype in the micro-service, and the front end adjusts instantly.
We can also configure the app to inject data at compile time, at form load, or in real time. That data might come from URL params or the query string, from local or session storage, from a cookie, from a timestamp, from a location, from an API call, or even from another field or fields in the form (mutation).
This same system can also be used for conditional display, obviously. It is infinitely configurable.
We can create composite fields simply by modifying the query response, or we can create bespoke algebraic datatypes and components (widgets) to display them, such as a phone field that includes a separate selector for country calling code and maybe a field for an extension, or selectors for type of service (e.g., landline, mobile), capabilities (voice, fax, message, etc.), and use (home, work, emergency).
Then all we need to do is pass the datatype “PHONE_TYPE” or whatever we decide to call it and the several data. The UI does the rest.
Rendering it all
Once you have an “operations” module that handles validation, formatting, conditional display, and value injection, and a set of generic components, creating the renderer is easy. Here is a simplified example:
export default function getComponent(component) {
const { datatype, ...props } = component
const Component = components[datatype]
return Component
? <Component key={props.id} {...props} />
: null
}
const components = {
ACTION: Action,
CONTENT: Content,
ELEMENT_TYPE: ElementField,
EMAIL_TYPE: StringField,
EXTERNAL_LINK: ExternalLink,
NAME_TYPE: FullNameField,
GROUP: Group,
IMPORTANT_NOTE: ImportantNote,
MUTATION: Mutation,
PHONE_TYPE: PhoneField,
STRING_TYPE: StringField,
}
Of course, this goes way beyond forms. There is no reason we can’t render the entire site from a simple GraphQL query.
One very easy way to do this is to make the JSON returned from that query match the DOM precisely. Here is a simple vanilla JS function that does just that:
function render({ attributes, content, type }) {
if (type) {
const element = document.createElement(type)
if (attributes) {
const { children, ...rest } = attributes
Object.entries(rest).forEach(
([name, value]) => element.setAttribute(name, value)
)
Array.isArray(children) &&
children.forEach(
(child) => element.append(render(child))
)
}
return element
}
return document.createTextNode(content)
}
Now this:
render({
type: "A",
attributes: {
children: [
{ content: "Google" }
],
href: "https://google.com/",
rel: "external"
}
})
Yields this:
<a href="https://google.com/" rel="external">Google</a>
Copy and paste it into DevTools and try it if you don’t believe me.
With a few more utility functions we can handle data-*
attributes and more. We can also strip out constraint configurations and pass them to our operations module to generate JavaScript that can be injected with a <script>
element or into bundled code if we’re using SolidJS, Svelte, or React.
And if you can’t get support…
Unfortunately, when I developed this DDUI for a micro-app that handled a user’s profile data, I could not get the back end dev to fully support the data-driven UI. (In fairness, the back end was a mess and he had his hands full.)
No problem! I added an anti-corruption layer. This is essentially a mapping function that takes the response from the GraphQL query and maps it to the configuration that the rendering function needs. In short, I add in any app-specific metadata missing from the query response.
Now the app is essentially generic. Everything specific to this particular application is neatly contained in that anti-corruption layer folder. Delete that folder, and all the IP and business logic is gone.
And that’s how it should be.
Business logic does not belong in the UI
The business logic of an application belongs in one place: a single source of truth. Moving some of it to, or, worse, duplicating logic in the front end is a recipe for bugs and other unpleasant events.
It shocks me how many devs have a hard time with this.
If the front end is generic and simply does what the back end tells it to do, then we can update our domain model and see the changes in our UI instantly. That saves time, effort, and money, but it also means fewer bugs, less tech debt, etc.
How is that a bad thing?
But to be clear:
This is not “one size fits all”
The DDUI app I created for the client was not a completely generic app meant to fit every application. Down that path madness lies. The first rule of coding, after name well, is do not over-abstract.
For one thing, the way the components were constructed, and of course the CSS stylesheets (using CSS properties), were specific to that company’s design and UX strategy. For another, I only built the components we needed, although more could easily be added as necessary.
It is also easy to think that the components in this DDUI application are highly abstracted. But in truth they are hardly abstracted at all. For example, we don’t have one component that handles every different form field. In fact, the components are decoupled from the form fields they use.
Each component represents a specific type of data, whether that is mutable data, such as in a form field, or immutable data, such as a paragraph of text.
That the BooleanField can display as a checkbox or a switch is not really a matter of abstraction. Both widgets do the same thing. It is simply a matter of visual rendering.
But it is yes or no, true or false, 1 or 0. It is not yes/no/maybe. That’s the TrileanField’s job. Each is specific to its task.
Just as, for example, a component representing an unordered list has little to do with the HTML <ul>
element. It is telling us about the data: First, that it is a list, which should mean similar items related in some way, else why the list? Second, that the ordering of the list is unimportant.
This list of items could be rendered as a <ul>
element, but just as easily as a menu or perhaps read aloud.
And we want the UI to understand not that this is a <ol>
or an <ul>
element, but that for one the ordering matters and for the other, it does not.
It’s not about generic interfaces. It’s about smart interfaces.
There is much more to be said for this approach. I’ll come back to it and will go still deeper soon with a discussion of the Semantic Web and data-centric architectures. Stay tuned.