Maybe Finance: A Modern Rails Monolith That Chose Hotwire Over the SPA Hype
Hook
A personal finance app built with Ruby on Rails just attracted 54,000+ GitHub stars in a landscape dominated by React dashboards and Electron apps—then its creators shut it down at the peak of its popularity.
Context
Personal finance apps occupy a peculiar space in software: users desperately want them, but almost nobody trusts third parties with their financial data. Services like Mint and Personal Capital require linking your bank accounts to their servers. Privacy-conscious users either resort to spreadsheets or avoid tracking altogether. The gap between "feature-rich cloud service" and "total data ownership" has been wide enough to drive a truck through.
Maybe Finance emerged as an answer to this trust problem: a fully-featured personal finance app you can self-host on your own infrastructure. But what makes it technically interesting isn't just the self-hosting capability—it's that the team chose to build a traditional Rails monolith using Hotwire in an era when every financial dashboard seems to be a React SPA with a Node.js backend. The architecture decision was deliberate: server-rendered HTML with progressive enhancement, PostgreSQL for persistence, and Turbo + Stimulus for interactivity. No GraphQL. No microservices. No separate frontend repository. Just Rails doing what Rails does best, updated for 2024.
Technical Insight
Maybe's architecture centers on Hotwire's premise that most web applications don't need heavy JavaScript frameworks. Instead of shipping a JSON API and rebuilding the UI in React, Maybe sends HTML fragments over the wire and swaps them into the page. This isn't the Rails of 2010—it's a sophisticated approach that delivers app-like responsiveness while keeping business logic on the server.
The core pattern revolves around Turbo Frames for surgical page updates. When you categorize a transaction, that action doesn't trigger a full page reload or a complex Redux state update. Instead, a Turbo Frame intercepts the form submission, sends it to the server, and replaces just that portion of the DOM with the server's response. Here's how a transaction row might be structured:
<%= turbo_frame_tag "transaction_#{transaction.id}" do %>
<tr class="transaction-row">
<td><%= transaction.date %></td>
<td><%= transaction.description %></td>
<td>
<%= form_with model: transaction,
data: { turbo_frame: "_top" } do |f| %>
<%= f.select :category_id,
Category.all.map { |c| [c.name, c.id] },
{},
{ data: {
action: "change->transactions#update",
turbo_stream: true
}} %>
<% end %>
</td>
<td class="amount"><%= number_to_currency(transaction.amount) %></td>
</tr>
<% end %>
This pattern keeps controllers clean. The TransactionsController#update action doesn't need separate JSON and HTML responses—it simply updates the model and renders the frame:
class TransactionsController < ApplicationController
def update
@transaction = Transaction.find(params[:id])
if @transaction.update(transaction_params)
# Turbo automatically replaces the frame with this render
respond_to do |format|
format.turbo_stream
format.html { redirect_to transactions_path }
end
else
render :edit, status: :unprocessable_entity
end
end
private
def transaction_params
params.require(:transaction).permit(:category_id, :notes, :date)
end
end
For more complex interactions, Maybe uses Stimulus controllers to add client-side behavior without building a full component system. A chart that updates based on date range selections might use a Stimulus controller to manage state and trigger updates:
import { Controller } from "@hotwired/stimulus"
import { Chart } from "chart.js"
export default class extends Controller {
static targets = ["canvas", "startDate", "endDate"]
static values = { url: String }
connect() {
this.chart = new Chart(this.canvasTarget, this.chartConfig())
this.loadData()
}
async updateRange() {
const params = new URLSearchParams({
start_date: this.startDateTarget.value,
end_date: this.endDateTarget.value
})
const response = await fetch(`${this.urlValue}?${params}`)
const data = await response.json()
this.chart.data.datasets[0].data = data.values
this.chart.update()
}
chartConfig() {
return {
type: 'line',
data: { datasets: [] },
options: { responsive: true }
}
}
}
The PostgreSQL schema design is straightforward but thoughtful. Accounts, transactions, and valuations are modeled with clear foreign keys and constraints. The team uses Rails migrations to manage schema evolution, and queries stay readable by leveraging Active Record's query interface rather than dropping to raw SQL prematurely.
What's particularly elegant is how Maybe handles real-time data syncing from financial institutions. Rather than building a complex job queue with Redis and Sidekiq, the app uses Rails' built-in ActiveJob with the solid_queue adapter—a database-backed job system that requires no additional infrastructure. Bank account syncing jobs run in the background, update transaction records, and broadcast updates to connected clients via Turbo Streams. The entire data flow stays within the Rails ecosystem.
The Docker deployment strategy reveals intentional architectural choices. The Dockerfile is a multi-stage build that compiles assets, installs dependencies, and produces a single container that runs both the web server and background jobs. For self-hosters, this means one docker-compose up command gets you a working finance app. No Kubernetes manifests, no service mesh, no infrastructure complexity—just PostgreSQL and the Rails app.
Gotcha
The elephant in the room: Maybe is no longer actively maintained. As of version 0.6.0, the creators announced they're discontinuing development. For a financial application where security updates matter, this is a critical limitation. You're not getting bug fixes, Rails security patches won't be applied, and new banking API integrations won't arrive. If you deploy this today, you're accepting full responsibility for maintenance.
The trademark restrictions add another wrinkle. You cannot fork Maybe and keep using its name or branding. This isn't just a license technicality—it affects community cohesion. Forks will fragment under different names, making it harder to find the "canonical" maintained version if the community rallies around this codebase. The open-source license gives you the code, but the brand recognition that attracted 54,000 stars stays with the original creators.
There's also the reality that self-hosted financial apps require discipline most users lack. You need to maintain backups, handle database migrations when updating, monitor for security issues, and ensure your Docker host stays patched. The app makes this easier than most, but it's still far more responsibility than clicking "Sign Up" on a managed service. For developers, this is fine. For the general public Maybe was ostensibly built for, it's a significant barrier.
Verdict
Use if: You're a Rails developer looking for a production-quality reference implementation of Hotwire patterns, need a self-hosted finance app for personal use and are comfortable maintaining Rails applications yourself, or want to fork and build your own finance product on a solid foundation (respecting trademark restrictions). This is genuinely excellent code to learn from—the architecture is clean, the patterns are modern, and it proves Rails monoliths can compete with SPA complexity. Skip if: You need active maintenance and security updates for production use, want something you can recommend to non-technical users without ongoing support obligations, or are looking for a commercial foundation without trademark complications. The discontinuation is a deal-breaker for anything beyond personal tinkering or educational purposes. Consider Actual Budget or Firefly III for actively maintained alternatives.