DEV Community

Cover image for What we learned building our SaaS with Rust ๐Ÿฆ€
Gaspard Boursin for Meteroid

Posted on • Edited on

What we 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 upcoming 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.

A couple words about our team and use case :

  • we're a team of 6, with almost no prior Rust experience but an extensive Scala/Java background building data-intensive applications
  • our SaaS is a Billing platform with a strong focus on analytics, realtime data and actionable insights (think Stripe Billing meets Profitwell, with a dash of Posthog).
  • our backend is fully in Rust (divided in 2 modules and a couple of workers), and talks to our React frontend using gRPC-web

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();
        // this is the actual logic
        let (res, grpc_status_code) = (...) 

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

        Poll::Ready(res)
    }
}


Enter fullscreen mode Exit fullscreen mode

Yes, in addition to generic types, generic lifetimes, and trait constraints, 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.

Done is better than perfect

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.

Image description
ยน this applies throughout your startup journey, including fundraising


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),
}


Enter fullscreen mode Exit fullscreen mode

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 !

Top comments (38)

Collapse
 
gaspardb profile image
Gaspard Boursin • Edited

Thanks for reading ! This was my first post ever, I'd love to have your feedback !

What should we write about next ?

  1. ๐Ÿ”ฅTop libraries that boosted our productivity
  2. ๐Ÿคฏ Clean error propagation in Rust
  3. ๐Ÿ’– Top Rust open source projects we take inspiration from
  4. ๐Ÿš€ Building a SaaS with Rust + GRPC + React
  5. ๐Ÿฆ„ How we leveraged Clickhouse to build our dynamic Metering module

Or if you have suggestions, don't hesitate to let us know in comments !

Collapse
 
eli_glanz profile image
Eli

Choosing #5!

Collapse
 
robinbastien profile image
Robin Bastien • Edited

*#4 please! *
Mid-read I veered off and researched how one would approach this. Thank you for the article!

Collapse
 
best_codes profile image
Best Codes

Hmmโ€ฆ I'd say it's up to you (of course), and I can't really decideโ€ฆ
I guess I would choose 2 or 5.

Awesome article!

Collapse
 
jar profile image
Phil R.

I'd vote for 1 & 2

Collapse
 
bhirmbani profile image
Bhirawa Mbani

please do number 4 sir

Collapse
 
benbpyle profile image
Benjamen Pyle

Great article. I've been working to transition teams into Rust in the world of AWS Serverless with Lambda Functions and Containers. The learning curve is real but the productivity and performance is amazing.

Collapse
 
gaspardb profile image
Gaspard Boursin

Thanks ! Serverless is a great use case for Rust yes, cold start diff can be huge ! What language are they coming from ?

Collapse
 
benbpyle profile image
Benjamen Pyle

So I work with a lot of different teams. Most come from Go but I've moved teams from TypeScript too.

Collapse
 
tombohub profile image
tombohub

do you mean productivity once you get over the learning curve? Where does this productivity come from? is it type safe code? easier error handling?

Collapse
 
benbpyle profile image
Benjamen Pyle

Fewer defects. Better project/library management. Cleaner code paradigms like errors and enums.

Collapse
 
gaspardb profile image
Gaspard Boursin • Edited

The support following this post has been amazing, thanks everyone โค๏ธ

We're now working on our deep dive series โœจ

In the meantime if you want to start looking at some webdev projects, we did a tiny "listicle" with a couple of opensource saas/fullstack tools that have been invaluable to us while learning, I hope it can be useful to you as well ๐Ÿ™Œ
The projects that helped us learn Rust ๐Ÿฆ€
On a different theme, we published yesterday: What's coming to Postgres ? ๐Ÿ˜, a dive into the recent innovations in that space.
Cheers !

Collapse
 
curry44424 profile image
Curry44424

Great to hear Iโ€™m using rust in my backend as well itโ€™s just crazy cause when I started like most Rust was foreign language people were knee deep in react and Ruby so to see rust take off is nuts future definitely bright in Rust

Collapse
 
gaspardb profile image
Gaspard Boursin

Adoption from larger players and efforts from the community have made it a lot easier to onboard for a variety of use cases, and we're still early ๐Ÿ‘

Collapse
 
timibolu profile image
Oluwatimilehin Adesina

Thanks for this article. Amazingly, you're open-source. I will surely learn from the repo.
I am also learning rust. I built a basic backend with Axum and MongoDB. Unfortunately, I can't share -- client work. It was a bittersweet experience. I enjoyed some things but found other things stressful coming from a TypeScript/Golang and Python background. I am still learning though.

Collapse
 
gaspardb profile image
Gaspard Boursin

Thanks ! I can relate, until you've truly integrated the concepts lifecycle and ownership can drive you crazy. It'll get easier with practice !

Collapse
 
december1981 profile image
Stephen Brown

I can't speak for the relational db drivers, but the mongodb rust driver is excellent, and quite mature.

Collapse
 
ricardogesteves profile image
Ricardo Esteves

Cool, thanks for sharing it.
4 would be nice!

Collapse
 
vingitonga profile image
Vincent Gitonga

Wow.
I loved the article.
It's really encouraging and I think it's time I head back and learn rust ๐Ÿ˜‚.

Collapse
 
gaspardb profile image
Gaspard Boursin

Thanks Vincent :) Join us ๐Ÿฆ€ ๐Ÿฆ€

Collapse
 
ddbb1977 profile image
Danny Boyd

Why not Scala? Since you were Scala developers what is the reason not to choose Scala?

I'm myself wondering if Scala is a dead end as moving to Scala 3, licensing problems with Akka/Pekko,, reduced community, ABI stability, and so on and I've been wondering if I should move to Rust.

I love coding in Scala, it's type system, hybrid OOP functional paradigm, JVM based and lot of other merits, but it feels like investing more time in it's ecosystem is not worth it.

I would love to hear why you've made the move over to Rust more than just the "drama-heavy and sometimes toxic Scala community". Do you see Scala as a technical dead end too?

Collapse
 
gaspardb profile image
Gaspard Boursin

Hey Danny,
We had the same feeling as you. Although we really enjoyed the language, we didn't feel like committing to a long-term project based primarily on Scala, even if it meant starting from scratch in a new ecosystem. I wouldn't say that Scala is a dead end, but it has definitely lost its flavor for us, and starting this project was a good opportunity to change and have fun learning something new.

Collapse
 
ashkanmohammadi profile image
Amohammadi2

2

Some comments may only be visible to logged-in visitors. Sign in to view all comments.