5 Lessons Learned Building our SaaS with Rust 🦀

5 Lessons Learned Building our SaaS with Rust 🦀

In this post we will not answer the question everybody asks when starting a new project: Should I do it in Rust ?

Instead, we’ll explore the pitfalls and insights we encountered after confidently answering “absolutely!” and embarking on our journey to build a business using mostly Rust.

This post aims to provide a high-level overview of our experiences, we will delve deeper into the details in an incoming series.

(vote in the comments for our next post 🗳️)

Why Rust

Choosing the right language for a project is never a one-size-fits-all decision. Our SaaS, Meteroid, is a modular Billing and Analytics platform (think Stripe Billing meets Profitwell, with a dash of Posthog).

We’re open source !
You can find our repo here : https://github.com/meteroid-oss/meteroid
We would love your support ⭐ and contribution

We therefore have some non-negotiable requirements that happen to fit Rust pretty well: performance, safety, and concurrency.
Rust virtually eliminate entire classes of bugs and CVEs related to memory management, while its concurrency primitives are pretty appealing (and didn’t disappoint).

In a SaaS, all these features are particularly valuable when dealing with sensitive or critical tasks, like in our case metering, invoice computation and delivery.

Its significant memory usage reduction is also a major bonus to build a scalable and sustainable platform, as many large players including Microsoft have recently acknowledged.

Coming from the drama-heavy and sometimes toxic Scala community, the welcoming and inclusive Rust ecosystem was also a significant draw, providing motivation to explore this new territory.

With these high hopes, let’s start our journey !

Lesson 1: The Learning Curve is real

Learning Rust isn’t like picking up just another language. Concepts like ownership, borrowing, and lifetimes can be daunting initially, making otherwise trivial code extremely time consuming.

As pleasant as the ecosystem is (more on that later), you WILL inevitably need to write lower-level code at times.

For instance, consider a rather basic middleware for our API (Tonic/Tower) that simply reports the compute duration :

impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for MetricService<S>
where
S: Service<Request<ReqBody>, Response = Response<ResBody>, Error = BoxError>
+ Clone + Send + ‘static,
S::Future: Send + ‘static,
ReqBody: Send,
{
type Response = S::Response;
type Error = BoxError;
type Future = ResponseFuture<S::Future>;

fn poll_ready(&mut self, cx: &mut Context<‘_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}

fn call(&mut self, request: Request<ReqBody>) -> Self::Future {
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
let started_at = std::time::Instant::now();
let sm = GrpcServiceMethod::extract(request.uri());

let future = inner.call(request);

ResponseFuture {
future,
started_at,
sm,
}
}
}

#[pin_project]
pub struct ResponseFuture<F> {
#[pin]
future: F,
started_at: Instant,
sm: GrpcServiceMethod,
}

impl<F, ResBody> Future for ResponseFuture<F>
where
F: Future<Output = Result<Response<ResBody>, BoxError>>,
{
type Output = Result<Response<ResBody>, BoxError>;

fn poll(self: Pin<&mut Self>, cx: &mut Context<‘_>) -> Poll<Self::Output> {
let this = self.project();
let res = ready!(this.future.poll(cx));
let finished_at = Instant::now();
let delta = finished_at.duration_since(*this.started_at).as_millis();

let (res, grpc_status_code) = ()

crate::metric::record_call(
GrpcKind::SERVER,
this.sm.clone(),
grpc_status_code,
delta as u64,
);

Poll::Ready(res)
}
}

Yes, you end up writing a custom Future implementation for a simple service middleware.
Keep in mind that this is a somewhat extreme example, to showcase the rough edges existing in the ecosystem. In many cases, Rust can end up being as compact as any other modern language.

The learning curve can vary depending on your background. If you’re used to the JVM handling the heavy lifting and working with a more mature, extensive ecosystem like we were, it might take a bit more effort to understand Rust’s unique concepts and paradigms.

However, once you grasp these concepts and primitives, they become incredibly powerful tools in your arsenal, boosting your productivity even if you occasionally need to write some boilerplate or macros.
It’s worth mentioning that Google has successfully transitioned teams from Go and C++ to Rust in a rather short timeframe and with positive outcomes.

To smooth out the learning curve, consider the following:

Read the official Rust Book cover to cover. Don’t skip chapters. Understanding these complex concepts will become much easier.

Practice, practice, practice! Work through Rustlings exercises to build muscle memory and adopt the Rust mindset.

Engage with the Rust community. They’re an incredible bunch, always willing to lend a helping hand.

Leverage GitHub’s search capabilities to find and learn from other projects. The ecosystem is still evolving, and collaborating with others is essential (just be mindful of licenses and always contribute back).
We’ll explore some of the projects we’ve been inspired by in the next post.

Lesson 2: The ecosystem is still maturing

The low-level ecosystem in Rust is truly incredible, with exceptionally well-designed and maintained libraries that are widely adopted by the community. These libraries form a solid foundation for building high-performance and reliable systems.

However, as you move higher up the stack, things can get slightly more complex.

For example, in the database ecosystem, while excellent libraries like sqlx and diesel exist for relational databases, the story is more complicated with many asynchronous or non-relational database clients. High-quality libraries in these areas, even if used by large companies, often have single maintainers, leading to slower development and potential maintenance risks.
The challenge is more pronounced for distributed systems primitives, where you may need to implement your own solutions.

This is not unique to Rust, but we found ourselves in this situation quite often compared to older/more mature languages.

On the bright side, Rust’s ecosystem is impressively responsive to security issues, with swift patches promptly propagated, ensuring the stability and security of your applications.

The tooling around Rust development has been pretty amazing so far as well.

We’ll take a deep dive into the libraries we chose and the decisions we made in a future post.

The ecosystem is constantly evolving, with the community actively working to fill gaps and provide robust solutions. Be prepared to navigate uncharted waters, allocate resources accordingly to help with maintenance, and contribute back to the community.

…did I mention we are open source ?

Meteroid is a modern, open-source billing platform that focuses on business intelligence and actionable insights.

We need your help ! If you have a minute,

Your support means a lot to us ❤️
⭐️ Star us on Github ⭐️

Lesson 3: Documentation Lies in the Code

When diving into Rust’s ecosystem, you’ll quickly realize that documentation sites can be a bit… well, sparse, at times.
But fear not! The real treasure often lies within the source code.

Many libraries have exceptionally well-documented methods with comprehensive examples nestled within the code comments. When in doubt, dive into the source code and explore. You’ll often discover the answers you seek and gain a deeper understanding of the library’s inner workings.

While external documentation with usage guides is still important and can save developers time and frustration, in the Rust ecosystem, it’s crucial to be prepared to dig into the code when necessary.

Sites like docs.rs provide easy access to code-based documentation for public Rust crates. Alternatively, you can generate documentation for all your dependencies locally using cargo doc. This approach might be confusing at first, but spending some time learning how to navigate this system can be quite powerful in the long run.

Needless to say, another helpful technique is to look for examples (most libraries have an /examples folder in their repository) and other projects that use the library you’re interested in, and engage with these communities. These always provide valuable guidance into how the library is meant to be used and can serve as a starting point for your own implementation.

Lesson 4: Don’t aim for perfection

When starting with Rust, it’s tempting to strive for the most idiomatic and performant code possible.
However, most of the time, it’s okay to make trade-offs in the name of simplicity and productivity.

For instance, using clone() or Arc to share data between threads might not be the most memory-efficient approach, but it can greatly simplify your code and improve readability. As long as you’re conscious of the performance implications and make informed decisions, prioritizing simplicity is perfectly acceptable.

Remember, premature optimization is the root of all evil. Focus on writing clean, maintainable code first, and optimize later when necessary. Don’t try to micro-optimize ¹ (until you really need to). Rust’s strong type system and ownership model already provide a solid foundation for writing efficient and safe code.

When optimizing performance becomes necessary, focus on the critical path and use profiling tools like perf and flamegraph to identify the real performance hotspots in your code. For a comprehensive overview of the tools and techniques, I can recommend The Rust Performance Book.


¹ this applies throughout your startup journey

Lesson 5: Errors can be nice after all

Rust’s error handling is quite elegant, with the Result type and the ? operator encouraging explicit error handling and propagation. However, it’s not just about handling errors; it’s also about providing clean and informative error messages with traceable stack traces.
Without tons of boilerplate to convert between error types.

Libraries like thiserror, anyhow or snafu are invaluable for this purpose. We decided to go with thiserror, which simplifies the creation of custom error types with informative error messages.

In most Rust use cases, you don’t care that much about the underlying error type stack trace, and prefer to map it directly to an informative typed error within your domain.

#[derive(Debug, Error)]
pub enum WebhookError {
#[error(“error comparing signatures”)]
SignatureComparisonFailed,
#[error(“error parsing timestamp”)]
BadHeader(#[from] ParseIntError),
#[error(“error comparing timestamps – over tolerance.”)]
BadTimestamp(i64),
#[error(“error parsing event object”)]
ParseFailed(#[from] serde_json::Error),
#[error(“error communicating with client : {0}”)]
ClientError(String),
}

Investing time in crafting clean and informative error messages greatly enhances the developer experience and simplifies debugging. It’s a small effort that yields significant long-term benefits.

However sometimes, even more in SaaS use cases where logs stays outside of the user scope, it makes a lot of sense to keep the full error chain, with possibly additional context along the way.

We’re currently experimenting with error-stack, a library maintained by hash.dev that allows exactly that, attaching additional context and keep it throughout your error tree. It works great as a layer on top of thiserror.

It provides an idiomatic API, actualling wrapping the error type in a Report datastructure that keeps a stack of all the errors, causes and any additional context you may have added, providing a lot of informations in case of failure.

We’ve encountered a couple of hiccups, but this post is far too long already, more on that in a subsequent post !

Wrapping up

Building our SaaS with Rust has been (and still is) a journey. A long, challenging journey at start, but also a pretty fun and rewarding one.

Would we have built our product faster with Scala ?
Certainly.

Would it be as effective ?
Maybe.

Would we still be as passionate and excited as we are today?
Probably not.

Rust has pushed us to think differently about our code, to embrace new paradigms, and to constantly strive for improvement.
Sure, Rust has its rough edges. The learning curve can be steep, and the ecosystem is still evolving. But that’s part of the excitement.
Beyond the technical aspects, the Rust community has been an absolute delight. The welcoming atmosphere, the willingness to help, and the shared enthusiasm for the language have made this journey all the more enjoyable.

So, if you have the time and the inclination to explore a new and thriving ecosystem, if you’re willing to embrace the challenges and learn from them, and if you have a need for performance, safety, and concurrency, then Rust might just be the language for you.

As for us, we’re excited to continue building our SaaS with Rust, to keep learning and growing, and to see where this journey takes us. Stay tuned for more in-depth posts, or vote for which one we should do next in the first comment.

And if you enjoyed this post and found it helpful, don’t forget to give our repo a star! Your support means the world to us.

⭐️ Star Meteroid ⭐️

Until next time, happy coding !

Leave a Reply

Your email address will not be published. Required fields are marked *