Validate business ideas with a viability

Validate business ideas with a viability

1 3
calendar_today agoschedule7 min read

Shipping a SaaS Solo: The Boring Architecture Behind Market Verdict

How I built a 10-language business viability analyzer on a Go monolith, a six-step ship script, and a refusal to do anything manually twice.

Market Verdict answers one question: is your business idea viable in a specific location? Type in "coffee roastery in Hamburg" and it pulls market data, demographics, competition density, and local purchasing power, then renders a verdict — with a paid tier that generates a tailored go-to-market plan and a multilingual PDF report.

It's built and operated by one person. This article is about the engineering decisions that make that possible: the stack, the deployment pipeline, the testing philosophy, and the workflow automation that turns a solo project into something that ships to production several times a day without fear.

The Stack: Aggressively Boring

The architecture is a single Go binary talking to PostgreSQL, deployed on Render. No microservices, no Kubernetes, no message broker cluster. The frontend is server-rendered HTML with HTMX for interactivity — no React, no build step, no node_modules folder the size of a small moon.

This isn't a retro statement. It's a headcount calculation. Every moving part in a system is something one person has to monitor, patch, and debug at 2 a.m. A monolith means one deploy artifact, one log stream, one place where bugs live.

The supporting cast:

River for background jobs. River is a Postgres-native job queue for Go — jobs live in the same database as everything else, which means job enqueueing participates in the same transaction as the business logic that triggered it. No "we charged the customer but the email job got lost" failure mode. Scheduled work (a daily SEO audit at 05:00, an auto-blog pipeline at 08:00) runs as River PeriodicJobs. There is no cron package in the codebase, by rule. One scheduler, one set of retry semantics, one dashboard.

Stripe for payments, hardened the hard way. The webhook handler uses a three-phase atomic pattern: record the event, process it, mark it complete — all transactional, with duplicate-event detection so Stripe's at-least-once delivery can't double-apply anything. Customer resolution metadata round-trips through checkout so a webhook can always find its user, even on the unhappy paths.

Honeybadger for error monitoring and Pushover for the alerts that genuinely need to wake someone up. Fatal-level errors send synchronously before the process dies — an error reporter that loses the most important errors is worse than useless.

i18n across ten languages, served on language subdomains (de.marketverdict.app, ja.marketverdict.app, and so on). The interesting part isn't the translation files — it's the test: a reflection-based completeness check walks every translation struct and fails the build if any language is missing any key. Translation drift is a compile-time-adjacent error, not a user-reported one.

The Ship Sequence

Every change to production goes through the same six numbered scripts:

1.dev-go.sh # build + full test suite against a real DB
2.git-push-dev.sh "feat: ..." # stage, commit (conventional), push dev
3.user-dev-test.sh # Playwright E2E against the dev deploy
4.git-merge-dev2main.sh # merge dev → main, triggers prod deploy
5.git-rev-parse.sh # confirm the deployed SHA
6.prod-smoke.sh # 96 checks against live production

The numbers are in the filenames on purpose. There is no ambiguity about order, no tribal knowledge, no "wait, do I run the smoke test before or after the merge?" The sequence is the documentation.

A few details that took iteration to get right:

The pipeline waits for the deploy. An early version would merge to main and immediately smoke-test production — against the old deploy, because Render hadn't finished rolling out. The fix was a wait_for_deploy step that polls until the live SHA matches the merged one. Obvious in hindsight; every pipeline bug is.

The smoke test is wide, not deep. Ninety-six checks: every static page returns 200 in every language, hreflang tags are sane, the sitemap round-trips, JSON-LD parses, security headers are present, the demo flow responds. It's a tripwire, not a test suite — its job is to catch "the deploy is broken in a way the unit tests structurally can't see" within sixty seconds of going live.

Shell scripts have failure modes too. A PIPESTATUS bug once let a failing test run report success because the exit code of go test was swallowed by a pipe into a formatter. The pipeline now treats its own scripts as production code: reviewed, version-controlled, and fixed with the same urgency as the application.

Testing: Real Databases or Nothing

The testing philosophy has two non-negotiables.

First: 100% coverage on every new function, shipped in the same patch as the function. Not as a vanity metric — as a forcing function. If a function is hard to get to 100%, it's usually doing too much, and the coverage requirement surfaces that at write time instead of refactor time. Coverage is also audited: at one point the merged coverage number looked healthy until a check of the package list revealed five packages silently excluded from the test scope. True coverage was lower than reported, with one package at 57%. The lesson generalizes — a metric you don't verify the denominator of is a metric you don't have.

Second: database-backed tests run against a real Postgres, never mocks. The store layer is tested through the actual driver against an actual database (a local Docker container in dev). Mocking the database tests your mocks. The real thing catches the bugs that matter: transaction isolation surprises, constraint violations, the time Docker's bridge networking was silently dropping bytes mid-connection — TCP handshake fine, driver hanging forever. No mock would ever have found that. (The fix, for the curious: --network host.)

End-to-end coverage is Playwright against the deployed dev environment, organized as numbered phases through the full user journey: land, analyze, sign up via magic link, pay, receive the PDF. Email verification in E2E runs through testmail.app with a unique address per test action, so flows that depend on receiving an email — magic-link login, blog approval — are tested for real, not stubbed. One workflow improvement halved total E2E runtime: the magic-link confirmation page used to require a click, and making it auto-submit (with a CSP-nonced inline script and a

2 Comments

2 votes
1
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelskiverified - Apr 23

Why Prompt Engineering Is Just an Expensive Way to Be Incompetent

Karol Modelskiverified - May 21

Your Tech Stack Isn’t Your Ceiling. Your Story Is

Karol Modelskiverified - Apr 9

Tuesday Coding Tip 02 - Template with type-specific API

Jakub Neruda - Mar 10
chevron_left
139 Points4 Badges
Venice, Camarketverdict.app
1Posts
1Comments
Senior software developer building a SaaS product with a Go backend and an AI-assisted development p... Show more

Related Jobs

View all jobs →

Commenters (This Week)

7 comments
3 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!