Many of the candidates we interview for a position at PassFort are intrigued by the fact that we use Rust, a language which is only three years old (since its 1.0 release).

Despite its relatively young age, Rust has been voted the “most loved” language in the StackOverflow developer survey every one of those three years - an impressive feat!

However, it’s not enough for a language to be well liked: the programming ecosystem changes rapidly, and many of these developers are rightly afraid to jump blindly onto the latest bandwagon. We chose Rust not because it is popular, but because we believe it is the best tool for the job we have to do, and I hope to explain that reasoning now.

Productivity

As I’m sure is common to most companies, engineering resource is limited, and we have to carefully prioritise what is most important and delivers the most value to our customers. The productivity of our engineering team is therefore hugely important, as it acts as a multiplicative factor in our ability to deliver product.

Rust has a reputation for being difficult to learn, so why do we think it’s a good idea for this task? If productivity is important, doesn’t it make more sense to use a language which is as easy to learn as possible?

Developers are not unchanging

When using a language like Python, it is very easy to write new code quickly. The most experienced Python developers can do that and also write code that is well engineered and maintainable. However, the process of becoming an experienced Python developer is long-winded: mistakes and bad decisions may only cause problems days or even months down the line, and so it’s much harder to take those lessons onboard and avoid making the same mistakes in the future.

Rust has many features, from its static typing and ownership model, to its comprehensive dependency management and minimalist runtime, which shorten that cycle of learning as much as possible.

A simple example of this is how the type system interacts with error handling:

fn get_data() -> String {
    fs::read_to_string("data.txt")
}

Trying to compile this will result in an error:

error[E0308]: mismatched types
    note: expected type `std::string::String`
             found type `std::result::Result<std::string::String, std::io::Error>`

The compiler is pointing out that the fs::read_to_string method may fail with an IO error: for example, the file may not exist, but the program is not handling this error case.

In other languages, the programmer is not required to consider error handling up-front, and so it often ends up being tacked on as issues arise. This leads to messy code and more fragile systems. The lesson (to give as much thought to the failure path as to the happy path) is not learned until the programmer reflects on why their code is becoming increasingly hard to maintain.

graph

Writing good Rust code may have a steep learning curve, but write bad Rust code and you’ll know about it within seconds. The continued learning process after reaching a basic level of competency easily makes up for the initially steeper learning curve, and over any significant timescale, overall productivity increases.

Maintenance costs

It’s not just about the initial cost to build a feature: the time that is invested keeping existing services running, fixing bugs, and generally ensuring customers continue to have a good experience is equally important.

Even for a fast-moving startup, this ongoing cost can be a huge burden on the engineering team, because while the cost of building a new feature is paid once, the maintenance cost is paid over and over again during the lifetime of the service.

Those same features of Rust I listed above help to move some of those maintenance costs to being initial development costs. As well as saving us time overall, this allows us to better prioritise new features: there are times in the past where we’ve made the mistake of building a new feature because we (correctly) estimated that it was cheap to build, but did not fully appreciate the maintenance cost we’d be subjecting ourselves to, and that maintenance cost meant we were unable to deliver as much value to the customer as we could have done.

Correctness

One of the areas in which Rust is undersold is in its ability to help you write more correct code. Memory safety, data-race freedom, etc. are all frequently mentioned, but those features do not make Rust the right tool for the job, at least for us: we can afford to pay the cost of garbage collection, and the request/response model can be easily mapped onto threads/fibers in most languages without error-prone concurrency logic.

The primary value that we get is around correctness: Rust borrows the functional programming mantra of “making illegal states unrepresentable”. This eliminates a whole class of bugs that would show up as “unchecked exceptions” in Java: null pointer exceptions, illegal argument exceptions, concurrent modification exceptions, etc. These are all “logic errors” - ie. the programmer made a mistake while writing the code, rather than e.g. a network failure, and Rust will catch them at compile time.

The net result is that our tests can be far more focused: with fewer “exceptional” cases there are fewer paths that execution can take through the program, and so fewer tests are required to exercise those paths. We save time writing, maintaining and even running tests, and can dedicate more thought to gracefully handling those runtime errors which really are unavoidable.

For us, this was one of the biggest downsides we found with a language like Go: while Go retains many of the same benefits of Rust, it is difficult to write Go code in this style: error handling is easy to omit, and the lack of generics result in over-use of the empty interface type. Illegal states such as null references will tend to proliferate without extensive test coverage.

Dependency management

Quite simply, Rust (with Cargo) has the best dependency management story I’ve ever encountered. Other languages are playing catch-up in this regard, with “Yarn” being one of the closest. There is a project for Python (Pipenv) which is under very active development, but it has suffered from severe performance problems and bugs due to the way Python’s package management has previously operated, and as yet only supports a fraction of the features offered by Cargo.

Extolling all the virtues of cargo would be another entire blog post, but I’ll attempt to summarize here: it makes managing dependencies a treat, and uses the human-friendly TOML format which plays nicely with version control. It ensures repeatable builds via the use of lock files, and does so without requiring any additional steps. When updating a dependency, the resolution algorithm is smart enough to minimise the changes to other crate versions.

All crates in the ecosystem are expected to follow semver, and while you can have two different major versions of the same crate in your dependency graph, cargo will ensure that for each major version, a single major.minor.patch version of the crate is used. This avoids the kind of package duplication that has historically been a problem with NPM, and which can lead to bigger problems when those differing versions of the same package try to interoperate.

Explicitness and simplicity

There are other languages that offer comparable correctness guarantees, and a few of those also have a reasonable dependency management solution. However, there are almost none which do so whilst also being as explicit, and in some sense simple as Rust is at its core.

Rust uses type inference to allow for code to be both statically typed and relatively concise. However, type inference is intentionally limited: it operates locally to a function. There is no global or module-level type inference.

When you know the types involved in an expression, there is no “magic”. For example, Python has descriptors, double-underscore methods and metaclasses, each of which can dramatically change the semantics of a simple field access. JVM/CLR based languages have remote/dynamic proxies, and many languages have “property” methods which can run arbitrary code.

However convenient these kinds of features may be some of the time, they impose a huge cost on a developer reading or refactoring a piece of code, and this cost is exacerbated by the fact that many of these features are only rarely used, and so bugs involving them can be unexpected and hard to track down.

The ability to look at a piece of code with very little context, and to truly know what it does is vastly underrated, and this is part of the reason I believe Go has achieved such popularity, and why C and Java are still so widely used.

Rust in production

We’ve been running Rust in production for over a year now, and our oldest Rust service (a batch job which runs twice a day, and consumes data from one of our providers) has needed precisely zero maintenance since it was built. An architecture built from these kinds of low maintenance services allows a small team to build something really impressive.

In my next post I’ll jump into a real example of a Rust micro-service we’ve built and deployed.

We're hiring!

Now you understand why we use Rust - think of what you could help us accomplish with it. We're always on the lookout for great engineers, and you can see more about our vacancies, team and culture here