Mobile System Design

By the author of the Mobile System Design and Swift in Depth books
Tjeerd in 't Veen

Written by

Tjeerd in 't Veen

X

Mastodon

LinkedIn

Youtube

Instagram

Newsletter

Reader

Designing a Declarative API

May 31, 2024, reading time: 8 minutes

Declarative programming is an interesting approach. You basically define “This is what I want” and then let some other type figure out how it should work.

Specifically, with declarative programming, we focus more on expressing logic and structure. We then worry less about control flow, such as figuring out the order of method calls, and keeping track of local state.

As mobile devs, we may associate declarative programming with a fancy syntax to build UI, such as follows in Jetpack Compose where we configure a Modifier:

modifier = Modifier
  .padding(24.dp)
  .fillMaxWidth()

Or a similar notation, in SwiftUI, where we modify a Text element:

Text("I am some text")
  .padding([.bottom, .trailing], 20)
  .bold()
  .font(.title)

But there are two ideas to unpack here:

  • Just because we may associate declarative programming with UI doesn’t mean that we can solely use it for UI.
  • Often in declarative UI, we are chaining methods, but chaining is not a prerequisite for declarative programming.

We’ll explore both these ideas while designing our own custom declarative type in this article.

Let’s see how we can harness about this power by designing some API’s for a type.

Designing from the call-site

Let’s imagine that we are asked to design a field validator that we’ll creatively call Validator. A type that takes user input (text) and gives back whether it’s valid, used for forms and fields.

For forms we could use good old regular expressions. Initially, a validator might not be that different from regular expressions.

However, we could treat a validator as a higher abstraction that can better focus on forms and fields, and can ideally give specific errors, such as "Letters are not allowed in a phone number" or "Your password must contain at least 20 different egyptian hieroglyphs".

We’ll design the API from the call-site, so that we can worry about the implementation afterwards. Because how we use it is most important, then how it’s implemented becomes an “implementation detail”. This makes it easier to design an API.

At the end of this exercise, we also make sure this type will support chaining via declarative programming.

Designing from the call-site

We can imagine that a Validator accepts certain rules. Each rule accepts an an anonymous function, that takes an input (a string) and tells us whether it’s valid, by returning either true or false.

Following convention, let’s call this anonymous function a predicate.

Before we build a Validator type with fancy predicates, let’s start simple. The simplest version we can design is a Validator type that always says a string is valid.

Designing from the call-site, we see how we create a new Validator, and we can set its rules property, which can hold an array of rules. We’ll start with a Rule that always returns true, so that any string is always valid.

Finally, we pass a string to the Validator to validate, using the check() method.

We'll use Swift for the examples. But the programming language isn't always too important. You can apply these concepts to other languages, too.

var validator = Validator()
// We add rules
validator.rules = [
    // There is only one rule. It receives a string.
    // But it always returns true (string is valid)
    Rule(predicate: { string in true })
]

// Then we can pass a string to the validator
// to check whether it's valid.

// No matter what string we pass
// the validator says it's always valid (true).
check("Filled string") // true

// Empty strings are still valid
validator.check("") // true

Since we can match anything with strings, we already have a ton of flexibility. For example, we can have one rule that disallows white space, and another rule that the character limit needs to be less than 4.

var validator = Validator()
validator.rules = [
    // No empty strings allowed.
    Rule(predicate: { string in !string.isEmpty }),
    // Max four chars
    Rule(predicate: { string in string.count < 4 })

]

validator.check("") // false
validator.check("abc") // true
validator.check("abcdef") // false

We built a little structure; We introduced a Validator, which contains some Rule instances. Even though these can be small types, we managed to have some expressivity.

We could already say this is declarative, even without chaining types. Without dealing too much with control flow, we merely state “These are the rules, now make it work”. Which is not that different from “This is what I want in my view, now render it for me”.

For completion’s sake, let’s see what this implementation would look like in Swift.

Looking at the implementation

A Rule can be tiny; A small struct that contains the predicate. This predicate is a function that provides a string (the input to check) and returns a boolean.

struct Rule {
    let predicate: (String) -> Bool
}

Implementing the Validator itself can also start small.

It contains an array of Rule types. When checking, it passes the string to each rule. If one Rule returns false, the check fails. Otherwise, the check passes and check returns true.

struct Validator {

    var rules = [Rule]()

    func check(_ string: String) -> Bool {
        for rule in rules {
            if !rule.predicate(string) {
                return false
            }
        }

        return true
    }

}

This is already enough to make our implementation work. Everything compiles, and with just a few lines of code, we have a very flexible validator already!

Adding chaining support

You’re probably here to see some of that fancy declarative chaining. Let’s see how we could make that happen.

First, we’ll again design it from the call-site.

We introduce a little factory method called makeNameValidator() to prove that we don’t need to return anything anymore explicitly.

To configure a Validator, we need to chain some sort of method. Let’s call this method rule(), which again accepts a predicate.

Notice that we can now keep calling the rule method to pass validation rules.

func makeNameValidator() -> Validator {
  Validator()
    .rule { string in !string.isEmpty }
    .rule { string in string.count > 4 }
    .rule { string in string.count < 20 }
}

That’s looking quite declarative or DSL-like already!

DSL stands for Domain Specific Language. A higher level of expression or abstraction to describe a problem in a programming language.

Notice that, thanks to chaining, we don’t need to return anything in makeNameValidator(). This is because every rule modifier returns a brand new Validator, making this the implicit return type.

Implementing chaining

The secret sauce to chaining is to return a type after every method call. Most commonly, we return the same type as the owner of the method.

By calling rule on Validator, we ensure to return a new Validator so that we can keep chaining. For our use-case, this new Validator will need to support a new rule, and all previous rules before that.

We’ll define the rule method that accepts a predicate and returns a Validator. To make the compiler happy, let’s start by adding a placeholder body that just returns the current validator.

struct Validator {

    // ... snip

     // We now return a Validator
    func rule(predicate: (String) -> Bool) -> Validator {
        // Placeholder: We just return the current validator.
        // We ignore the passed predicate for now.
        return self
    }

This already compiles and runs.

Because rule(predicate:) returns a Validator, we always end up with a Validator. Because of that, we can repeatedly call rule on it.

// We can infinitely call rule
Validator()
  .rule { string in true }
  .rule { string in true }
  .rule { string in true }
  .rule { string in true }
  .rule { string in true }
  .rule { string in true }
  .rule { string in true }
  .rule { string in true }
  .rule { string in true }
  .rule { string in true }

It’s a silly example, and similar to calling bold() ten times on a Text, hoping it turns superduper-bold. But the point is, we unlocked chaining! This looks closer to declarative programming.

Next up, let’s make the implementation work.

Implementing a chaining method

Our implementation only contains a placeholder called self. This doesn’t work yet, because we need to combine all the rules whenever we call the rule method on Validator.

The implementation is different for every type you make. In the case of Validator, that means that whenever we call rule, we need to create a new validator and add the rules from the previous validator. Then, we add the freshly passed predicate to it. This way, the new validator has all old and new rules.

Finally, we return the new Validator containing all rules.

struct Validator {

    func rule(predicate: @escaping (String) -> Bool) -> Validator {
        // Create a new validator
        var combined = Validator()
        // Copy over the current rules
        combined.rules = self.rules
        // We add the new rule
        combined.rules.append(Rule(predicate: predicate))
        // Return the new validator
        return combined
    }

}

The escaping keyword tells Swift that a closure might outlive the method's lifecycle.

That’s all the code we need to create a DSL-like declarative style Validator!

Combining validators

This is just a starting point to build a full-fledged Validator library.

We can get fancy and even combine validators.

Because our implementation is so tiny, we can easily add more small building blocks like these.

For example, let’s say we have a default validator that ensures fields aren’t empty. On top of that, let’s say we have a validator that ensures a user doesn’t exceed the maximum number of characters. We might reuse these validators across the entire application, so we choose to offer these as factory methods.

func makeNotEmptyValidator() -> Validator {
  Validator()
    .rule { string in !string.isEmpty }
}

func makeMaxCharValidator(max: Int) -> Validator {
  Validator()
    .rule { string in string.count < max }
}

With a little work, we can combine these two validators.

First, let’s support the boolean and && operator. In Swift, we achieve this with a static function that we’ll lovably call &&.

struct Validator {

    // .. snip

    // We introduce the && operator.
    // Then we can use it such as:
    // let combinedValidator = validator1 && validator2
    static func && (left: Validator, right: Validator) -> Validator {
        // We make a new validator
        var combined = Validator()
        // We combine all rules from two validators
        combined.rules = left.rules + right.rules
        return combined
    }

}

That’s all it takes. Now we can easily combine validators.

Below, we combine the two validators into one, using the custom && operator!

let combined = makeNotEmptyValidator() && makeMaxCharValidator(max: 4)
combined.check("") //false
combined.check("abc") // true
combined.check("abcdef") // false

It took little effort, but it’s powerful. With a little imagination, we can come up with other boolean operators, such as || or operators or XOR operators.

In the next example below, we combine the or || operator with the and && operator.

Here, the field isn’t allowed to be empty. But, as long as the field is a user ID or email, we can consider it valid.

let notEmpty = makeNotEmptyValidator()
let isUserID = makeUserIDValidator()
let isEmail = makeEmailValidator()

// We combine all three validators using || and &&
let validator = notEmpty && (isUserID || isEmail)
validator.check("") //false
validator.check("@tjeerdintveen") // true
validator.check("[email protected]") // true

Using custom operators allows us to express validators in a more DSL-like way.

Next, let’s take this idea one step further.

A declarative DSL

To continue with our example, in Swift we can use something called Result builders, allowing us to make things more implicit, removing some of the noise in our code. This brings us closer to a DSL with a declarative feel.

Then our code could look something like this:

Validator {
  Rule { string in !string.isEmpty }
  Rule { string in string.contains("@") }
  Rule { string in string.count < 20 }
}

Now it’s even more DSL like.

We can keep going; Such as supporting regular expressions. Or combining rules with And and Or operations. Perhaps we could design this with a RuleGroup that takes various And or Or statements.

Validator {
  // We could support a custom RegExp rule.
  // Such as a phonenumber check.
  RegExp { "/^(\+\d{1,2}\s?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/" }

  // We design a group of rules that work together
  // with boolean operations, such as Or and And
  RuleGroup {
    Or { string in string.count > 10 }
    Or { string in string.count < 20 )
  } And {
    Rule { string in !string.isEmpty }
  }

}

The idea would be to first design the declarative DSL you like, and then try to get as close as possible. With some ideas from this article, I think we can get really close to this design.

This is just a Swift example. But even without Swift’s result builders, this library can go in many directions. Such as making validators collect various errors, or supporting types other than strings, such as dates and numbers.

Maybe we can even offer transformations. Such as a validator that trims the white space, so that other combined validators always get a proper string.

With a little creativity and a combination of small operators, we can grow a mature system that can be expressed in a declarative, DSL-like manner.

Conclusion

We’ve been describing the rules of a validator without describing how it should validate. So that we focus less on control flow.

I hope this article gave you some ideas and inspiration to come up with your own design.

We’ve seen how declaratively defining an API doesn’t always mean we have to chain methods. But, it brings us to a higher-level language of expression, closer to a DSL. These examples show we can chain method calls to define elements declaratively .

That means that chaining is one shape to handle declarative programming. But there are many more.

We’ve also seen that declarative programming isn’t bound to UI. This Validator library could very well work in a model-only context.

If you want to see more declarative examples like these, then check out Chapter 16: Reusing views across flows of the Mobile System Design book.

The Mobile System Design book

If you want to boost your career and become an even better mobile engineer, then check out the Mobile System Design book.

I am the author of this book, but you may also know me from the highly-rated Swift in Depth book.

Even if you're a senior or staff-level engineer, I am confident you'll get a lot out of it.

It covers topics in depth such as:

  • Passing system design interviews
  • Large app architectures
  • Delivering reusable components
  • How to avoid overengineering
  • Dependency injection without fancy frameworks
  • Saving time by delivering features faster

... and much more!

Get the Mobile System Design book

Written by

Tjeerd in 't Veen has a background in product development inside startups, agencies, and enterprises. His roles included being a staff engineer at Twitter 1.0 and iOS Tech Lead at ING Bank.

He is the author of the Mobile System Design and Swift in Depth books.

Stay up to date and subscribe to the newsletter to get the latest updates in your mailbox.