As your startup begins to gain traction and grow, the software that once served you well during your early stages now needs to evolve. This case study examines the journey of a startup facing the realities of scaling up. To respect confidentiality, we won’t name the company, but we’ll dive into their challenges and explore how they can transition their Ruby on Rails application to handle increased demand and complexity.
Think of your application like a car. When you were a student, you could get by with a small car that barely started but managed to take you where you needed to go. It was simple, functional, and got the job done. But as you grow up, start a family, and need to travel longer distances, you require something more secure and reliable, spacious enough to accommodate family members and luggage, and capable of taking you safely on longer trips. Similarly, the startup’s product, initially built using Ruby on Rails, was perfect for quickly developing an MVP (Minimum Viable Product) and gaining early market traction.
However, now that the startup is growing up, the application needs to grow up as well. It must handle more traffic, be more reliable to reduce support calls, and be easier to enhance with new features while keeping complexity and technical debt manageable. The initial simplicity that served the company well must evolve into a more sophisticated and scalable architecture.
Here’s a more complex example of what their initial code might look like, focusing on a PaymentsController that handles payment processing, database updates, success notifications, and subscription status updates:
class PaymentsController < ApplicationController
def create
@user = User.find(params[:user_id])
@subscription = Subscription.find(params[:subscription_id])
if process_payment
@subscription.update(status: 'active', paid_at: Time.now)
@user.notify_success("Your payment was successful and your subscription is now active.")
render json: { message: 'Payment successful' }, status: :ok
else
render json: { error: 'Payment failed' }, status: :unprocessable_entity
end
end
private
def process_payment
# Simulated payment processing logic
payment_gateway.charge(@user, @subscription.amount)
end
def payment_gateway
PaymentGateway.new(api_key: 'secret')
end
end
In this example, the PaymentsController handles everything from processing payments to updating the subscription status and notifying the user of success. Unit tests for this controller would have been very complex due to the intertwined logic and dependencies, but (ironically) luckily, this problem hadn’t been tackled much throughout the codebase.
As the startup’s user base expanded, several issues began to surface:
The company realized that to continue growing and meet their users’ demands, their software needed to evolve. They desired a system that was:
However, moving straight to a modular monolith wasn’t a likely approach. The company knew they wanted to eventually get there, but they also realized they had to reach this point in incremental steps to prevent service disruption. The first step would involve moving the logic to a service-like structure, where they had to think a lot about proper naming and namespacing of concepts. As a joke often shared among developers goes: “In computing, there are only two hard problems: invalidating cache and naming things.”
To begin the transition, the company started by refactoring their tightly coupled controllers into service objects. This step required careful consideration of naming and boundaries, akin to laying the foundation bricks before building a house.
Here’s an example of how the PaymentsController can be refactored using service objects:
Initial Setup:
# app/controllers/payments_controller.rb
class PaymentsController < ApplicationController
def create
result = Payments::ProcessPayment.new(user_id: params[:user_id], subscription_id: params[:subscription_id]).call
if result.success?
render json: { message: 'Payment successful' }, status: :ok
else
render json: { error: 'Payment failed' }, status: :unprocessable_entity
end
end
end
Service Objects:
# app/services/payments/process_payment.rb
module Payments
class ProcessPayment
def initialize(user_id:, subscription_id:)
@user = User.find(user_id)
@subscription = Subscription.find(subscription_id)
end
def call
if process_payment
update_subscription
notify_user
OpenStruct.new(success?: true)
else
OpenStruct.new(success?: false)
end
end
private
def process_payment
payment_gateway.charge(@user, @subscription.amount)
end
def payment_gateway
PaymentGateway.new(api_key: 'secret')
end
def update_subscription
@subscription.update(status: 'active', paid_at: Time.now)
end
def notify_user
@user.notify_success("Your payment was successful and your subscription is now active.")
end
end
end
Looking ahead, the startup aims to transition further into a microservices architecture. This approach will allow each module to evolve independently and scale according to its specific needs.
Benefits of Microservices:
Steps to Transition:
The journey from a monolithic MVP to a scalable microservices architecture is challenging but essential for growing startups. By first transitioning to a service-oriented structure, the company can improve maintainability and performance, setting a solid foundation for future growth. As the user base continues to expand, moving to microservices will provide the scalability, flexibility, and resilience needed to support long-term success.
This case study highlights the importance of evolving software architecture in tandem with business growth, ensuring that your startup is not only ready to handle current demands but is also prepared for future challenges. By taking incremental steps and focusing on proper design principles, you can build a robust, scalable system that supports your startup’s growth and success.