Navigating Complexity at Scale: Qonto’s Monolithic Domain-Driven Design Journey

Paweł Rutkowski
The Qonto Way
Published in
7 min readJan 5, 2024
Illustration on a black background, showing two building block constructions. On the left a 2-storey building made with sark grey cuilding blocks and with additional external features displays a red cross in a circle next to it. On the right there is a single storey dark-grey building block construction with some blocks on the top layer outlined in either pale green, yellow, or purple. This 2nd construciton has a green tick in a circle next to it.

Humble beginnings

Successful ventures often have humble beginnings. At Qonto, in code terms, ours was the Rails framework. Rails gave us the tools we needed to translate innovative ideas into a tangible product quickly and to produce features at an astonishing rate. Our business loved it. We all did.

Rails proposes a classic MVC (Model, View, Controller) architecture. In our case, being heavily API-driven, the View took a backseat, leaving us to focus on Models and Controllers. Following the community standards, we also used Service Objects as a place to extract code from big Models and Controllers. This is where our business logic lived the most, leaving Controllers responsible for communication and Models for persistence. It worked great in the early days of Qonto.

The double-edged sword of rapid growth

Our business grew quickly and, as with any fast-evolving platform, our feature set expanded. With this came multiple challenges: first and foremost, having to manage an increasingly intricate codebase; secondly, code ownership. Working on a monolith with only the structure mentioned above made it extremely hard to establish ownership of certain places in our application. Unfortunately, this was mostly around our Service Objects and sometimes Models. We quickly started using Services from Services, sharing resources and responsibilities between them. This led to an engineer’s favorite problem — spaghetti code.

Over time, as our features multiplied and our user base expanded, we inevitably began to face growing pains. This was no longer just a technical challenge; it started affecting our deployment speed, became a hurdle in onboarding new engineers and, most critically, exposed our platform to bugs and inefficiencies. We were making decisions in the context of what we were: a small team. Trade-offs we made back then made a lot of sense in that context and allowed our teams and products to grow. We are still very proud of that, and our business today shows it was the right call at the time.

But with rapid growth, our architecture stopped reflecting how our teams were working. With multiple teams, each had its own areas of expertise but, technically, we all shared a monolith. Our architecture could not keep up with our own development. It was time to reflect on the trade-offs we were making and the way we write scalable code.

The Eureka Moment: embracing Contexts

We realized that linear feature growth led to an exponential increase in complexity. So, we started looking for alternatives. Most of the content out there falls into two categories: on one hand, the solutions we were already applying but that were no longer sufficient and, on the other hand, Microservices. However, migrating to Microservices posed two problems of its own. One was how to divide our monolith and the second was that we had problems adding features already — this migration will take years. Eventually, this dilemma pushed us towards Domain Driven Design (DDD).

Learning and slowly adapting DDD principles, we found Railway Oriented Programming to be one of the eye-openers. In Ruby, we took a lot of inspiration from Dry Monads (and Dry ecosystem) and how it plays together with Railway Oriented Programming and DDD. Based on this and plenty of internal discussions, we introduced Contexts, Services and Commands.

The main and most important focus of our new standard was the introduction of “Contexts”. This is short for Bounded Contexts.

A Bounded Context is a defined part of software where particular terms, definitions and rules apply in a consistent way.

It’s a pretty abstract concept, but most important for us was that Contexts are different areas of our business. They are mapped to our teams and have very specific ownership. In terms of code, Contexts are just folders and namespaces, but they do create and maintain clear boundaries. Below you can see examples of domains assigned to Onboarding and Bookkeeping teams.

Example of domains assigned to Onboarding and Bookkeeping teams.

Collaboration in separation

Once we had established our areas of expertise and ownership, we needed to figure out how to communicate with each other. It’s not like we live in silos and work on 10 different products; we all work towards the same vision of creating the finance solution that energizes SMEs and freelancers. We might each contribute in our own way to different elements that all support this vision, but it always comes back to the same objective. We need to support each other, but how?

We decided that we needed a “public” interface of our Contexts. An agreement between teams, a contract of sorts. We didn’t have this contract in our Service Objects, which meant we followed a “free for all” principle. And if something can be used in a situation you didn’t design it for, it will be. And it was. Having a public interface would allow us to imagine other scenarios where someone else might adopt our work, but it would also mean we have private code that we can hide from other teams.

Technically speaking, in code, we decided our public interface would be “Services”. They resemble functions, they include a verb in their name and they’re a simple Ruby class with a “call” method. They live in our Contexts. Another Context (potentially owned by a different team) can only access our domain through these Services. Our Services would now have explicit expectations, ensuring there are no surprises when one team’s feature leverages another team’s Context.

The symphony of Services and Commands

Once we had a solution for our teams’ ownership and collaboration needs, we started looking deeper at how we could prevent spaghetti code issues. When we introduced the new standard, the main principle was to have a solid public interface that would leave teams free to decide what to do inside their Context. In the end, each team will know best how to design a solution to the problem they’re exploring. Having said that, most solutions to simple problems will be similar and we don’t want to reinvent the wheel. Then, we decided on a default structure inside a Context.

Services became a kind of orchestrator. They were responsible for a business process (for example, creating an account). Each process is made up of several steps. Each step became a Command. Now, instead of vast monolithic methods, we had a series of compact, easy-to-understand Commands, each representing an atomic business operation. This streamlined our codebase and made it much more maintainable and comprehensible. Commands enabled us to compartmentalize each step. You can think of them as the private parts of a Context.

An example file structure for Contexts with Services and Commands.

Beyond technicalities: a Cultural Renaissance

This architectural evolution wasn’t just a technical pivot. It was a cultural shift that generated several welcome benefits.

  1. Collaboration reimagined: with clear Service contracts, inter-team collaboration became smoother; dependencies were transparent and integration points were predictable.
  2. Faster onboarding: new engineers could now dive into specific domains without being overwhelmed by the complexity of the system as a whole.
  3. Empowered development: clear boundaries and responsibilities meant teams felt a sense of ownership and empowerment; they could innovate and experiment without the fear of inadvertently affecting unrelated parts of the system.
  4. Failure handling: one of the under-appreciated aspects of our transition was the granularity it brought to failure handling. Previously, a failure in our system often resulted in broad, generic error messages. With Commands, we could return highly detailed failure reasons. Forcing developers to return a monad (Success/Failure) puts failure scenarios under the spotlight. As developers, we tend to focus on the green paths, and it’s very easy to forget how everything can fail. Putting errors as first-class citizens vastly improved the quality of our product.

Afterthoughts

Challenges and lessons

Another challenge was overcoming the urge to nest Commands. It was tempting to create meta-Commands, which internally invoked other Commands. We had to resist this to maintain clarity and ensure the purity of each Command.

Ultimately, our biggest challenge is still, to this day, answering the following question: how big should a single bounded Context be? We’re still learning and experimenting but, so far, we’ve noticed that the most important heuristics for a good bounded Context are autonomy, cohesion and the way data is changing or combined.

A peek into the future

As we write this chapter, we can look back with pride and forward with excitement. The Context-centric approach has positioned us well for the future, leaving us ready to scale even more.

We’re very excited about our architecture and our move towards Microservices. The first step of introducing Contexts in the same application allows us to easily extract a single Context to a separate application, as we already have the interfaces and all communication is clear and known.

We’re also keeping a close eye on projects like packwerk, that would allow us to take this idea even further.

Lastly, I’d love to thank all the contributors and the Ruby community at Qonto: Pericles, Maciej, Szymon, and Nick. All of the above has truly been a group effort.

About Qonto

Qonto makes it easy for SMEs and freelancers to manage day-to-day banking, thanks to an online business account that’s stacked with invoicing, bookkeeping and spend management tools.

Created in 2016 by Alexandre Prot and Steve Anavi, Qonto now operates in 4 European markets (France, Germany, Italy, and Spain) serving 400,000 customers, and employs more than 1,300 people.

Since its creation, Qonto has raised €622 million from well-established investors. Qonto is one of France’s most highly valued scale-ups and has been listed in the Next40 index, bringing together future global tech leaders, since 2021.

Interested in joining a challenging and game-changing company? Consult our job offers!

Illustration by Karina Pasechka.

--

--