Software Architecture for Solo Developers
When I greenfielded 3ni8ma — leetspeak for "enigma" — I had a microservices architecture in mind before I had a feature list. I sketched an auth service, a puzzle service, a scoring service, and an event bus to glue them together. Then I spent two months building plumbing and zero puzzles. The repo was a showcase of infrastructure with nothing to show for it. I was the entire engineering team, and my architecture was designed for a team of fifteen.
That was the first lesson: as a solo developer, every architectural choice is a bet against your own future time. The cost of each service, abstraction, or queue is paid in the hours you spend re-reading old code and re-tracing data paths six months later. I rebuilt 3ni8ma's backend twice before it stuck. It is now a deliberately boring monolith, and this article is the reasoning behind that design: the concrete decisions, the code, and the tradeoffs I measured instead of guessed.
The Ownership Revolution
"Ownership" in a solo project is not a badge, it is a sentence. You own every line forever. There is no junior dev to hand the confusing stuff to, no reviewer to catch the half-baked abstraction, no on-call rotation to share the 2am page. When the team is one, the bus factor is one, and the only thing that saves you from your own absence is how little context you need to rebuild.
My original 3ni8ma backend had three services, a message broker, and a shared schema package. I counted once: roughly 70% of the code was HTTP glue, serialization, and event schemas. The actual puzzle logic — the reason the project exists — was about 1,200 lines out of 6,000. Every bug fix required tracing a request through three containers and mentally replaying two JSON transforms. The services weren't an architecture, they were a tax on my attention.
The hard-won lesson: the solo developer's goal is not raw performance or scalability. It is survivability — the ability to wake up in six months, read the code cold, and ship a fix before coffee wears off. Every abstraction you add is debt you pay in context-switching. The measure of a solo architecture is not how clean the diagram looks, but how many decisions your future self has to hold in their head at once.
Why a Monolith Is the Solo Developer's Best Friend
A monolith is one process that does all the things. Not a single file — that is a different failure mode — but a single deployable unit with a single entry point, a single log stream, and a single database. For 3ni8ma, the numbers were decisive.
Before the rewrite, deploying meant building three Docker images, pushing them, and running a multi-container compose restart: four to six minutes of my afternoon, plus watching orchestration logs to make sure the order was right. After the rewrite, deploying means building one image and restarting one container: about forty seconds of build time and three seconds of downtime. When I fix a bug, the path to production is one command and one tail of a log.
| Approach | Deploy time (avg) | Ops burden | Time to first bug fix | Scaling ceiling | |---|---|---|---|---| | Single script | ~1s | Near zero | Minutes | Hits a wall fast | | Modular monolith | ~40s | Low (one container) | 5–10 min | Thousands of users on one box | | Microservices | 4–6 min | High (many containers) | 30+ min | Very high, but you pay daily |
People hear "monolith" and imagine a decade-old PHP application with 40,000 lines of mixed HTML and SQL. That is a distributed monolith's opposite: it is a big ball of mud, and the cure for mud is modules, not microservices.
The Modular Monolith Pattern
A monolith only stays sane if its internals are disciplined. The pattern I arrived at is the modular monolith: every domain owns a directory, a public interface, and its own tables. Modules never import each other's internals; they are wired together in exactly one place — the application factory.
3ni8ma's domains are small: accounts, puzzles, submissions, leaderboard. Each one is a FastAPI router plus a service layer plus its own set of database tables. The whole application is assembled in one file:
from fastapi import FastAPI
from app.modules.accounts import router as accounts_router
from app.modules.leaderboard import router as leaderboard_router
from app.modules.puzzles import router as puzzles_router
from app.modules.submissions import router as submissions_router
def create_app() -> FastAPI:
"""Assemble the monolith from its domain modules."""
app = FastAPI(title="3ni8ma", version="1.0.0")
app.include_router(accounts_router, prefix="/api/v1")
app.include_router(puzzles_router, prefix="/api/v1")
app.include_router(submissions_router, prefix="/api/v1")
app.include_router(leaderboard_router, prefix="/api/v1")
@app.get("/healthz")
def health() -> dict[str, str]:
return {"status": "ok"}
return appThe one rule that keeps it honest
The single rule: a module may only be wired into the rest of the app at the factory. No importing app.modules.submissions.models from app.modules.leaderboard.services. If leaderboard needs submission data, it calls the submissions module's public service function. This is the same discipline that makes microservices decoupled, minus the network.
The "big ball of mud" happens not because you lack services, but because you lack boundaries. The modular monolith gives you boundaries without the operational overhead of a second process.
Data First: The Database as Contract
In a monolith with one database, the schema is the architecture diagram. When the puzzles table changes shape, every downstream consumer sees it through migrations and foreign keys before they see it through code. I write the schema first, then the code that speaks to it.
CREATE TABLE puzzles (
id TEXT PRIMARY KEY,
puzzle_type TEXT NOT NULL CHECK (puzzle_type IN ('cipher', 'crossword', 'logic')),
puzzle_data JSONB NOT NULL,
answer_hash TEXT NOT NULL, -- never store plaintext answers
difficulty INTEGER NOT NULL CHECK (difficulty BETWEEN 1 AND 5),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE submissions (
id TEXT PRIMARY KEY,
puzzle_id TEXT NOT NULL REFERENCES puzzles(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
is_correct BOOLEAN NOT NULL,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_submissions_puzzle ON submissions(puzzle_id, is_correct);Every constraint here caught a real bug at least once. The difficulty CHECK rejected a puzzle generator that once emitted difficulty 6. The puzzle_type CHECK kept the same generator from persisting an unhandled variant. The foreign key on submissions.puzzle_id prevents orphaned rows when I delete test puzzles. Constraints catch what tests miss because they run on every write, including the ones you forget to cover.
I also chose answer_hash over a plaintext answer column deliberately: the client sends an answer, the server hashes it with a per-puzzle salt, and only the hash is stored. If the database leaks, the puzzles are still hard to cheat — and the worst-case is nobody. The schema is the contract between the puzzle generator and the submission checker, and it cannot drift out of sync because it is one database.
During development I run SQLite with the same migrations; in production it is Postgres. The app never touches database-specific features, so the same code and same migrations run on both. That means my laptop is a fully functional replica of production, and "works on my machine" is actually true.
API Boundaries and the Single Entry Point
One process means one API boundary. Everything sits behind a single FastAPI app, and the only thing a client talks to is /api/v1. Authentication middleware is written once and applies to every route. Rate limiting is one dependency, not three configurations. Request logging is one middleware, not per-service decorators sprinkled across a codebase.
This is the quiet superpower of the monolith: there is no service-to-service authentication to design, no internal API tokens to rotate, no team arguing about which service is allowed to call which other service. A solo project with three services has three auth boundaries to get wrong. My monolith has one.
Versioning is trivial because it is just a URL prefix. v1 stays frozen while I break things in v2 behind the scenes. The day I need to change a response shape, I add v2 routes in the same router and let the old ones rot until no client uses them. There is no microservice version matrix to track.
The one cost: every route is in one codebase, so a noisy module can distract you. The mitigation is the module boundary from the previous section — puzzles and accounts have separate routers, separate services, separate directories. They share a process, not a brain.
Background Jobs and the Queue
Generating a cipher puzzle is instant. Generating a custom crossword is not — it is 4 to 6 seconds of CPU-bound constraint solving. I refuse to burn that in a request path, so 3ni8ma has exactly one background worker and one Redis queue. The HTTP API enqueues a generation job and returns 202 Accepted; the worker does the heavy math and writes the puzzle to the database.
The queue is not a scalability play. There is one worker process. The queue exists purely to keep request latency under 300ms and to absorb the spiky cost of crossword generation. If I ever need to generate ten puzzles at once, the worker does them serially and that is fine.
Because RQ (like most queues) is at-least-once, a job can run twice if the worker crashes mid-task. Every job must be idempotent. The generation job starts by checking: does a puzzle with this seed already exist? If yes, it returns the existing row instead of generating a duplicate. This one check has saved me from duplicate-puzzle bugs at least three times, every one caused by manual re-queueing during debugging.
The rule I apply: a task goes in the queue only if it takes longer than I am willing to wait, or if it needs to run on a schedule (like the daily puzzle). Everything else stays synchronous. Every queue you add is another moving part, and a solo developer should count moving parts like they cost money — because they cost time.
Testing Strategy for a One-Person Team
I gave up on extensive unit tests for internal service methods. They tested an implementation detail I would rewrite anyway, and they gave me false confidence. What I kept is integration tests that boot the entire app against a disposable database and hit it over HTTP.
import pytest
from fastapi.testclient import TestClient
from app.main import create_app
@pytest.fixture()
def client() -> TestClient:
"""Each test exercises the real API stack against a fresh database."""
app = create_app()
with TestClient(app) as c:
yield c
def test_submit_correct_answer(client: TestClient) -> None:
puzzle = client.post("/api/v1/puzzles", json={"puzzle_type": "cipher"}).json()
answer = "ENIGMA"
response = client.post(
f"/api/v1/puzzles/{puzzle['id']}/submissions",
json={"answer": answer},
)
assert response.status_code == 200
assert response.json()["is_correct"] is TrueWhy this works: the test goes through the router, the middleware, the auth layer, the service, and the database — the same path a real request takes. A bug in any of those layers fails the test. Unit tests mock away the layers where bugs actually live.
For puzzle answers, I use snapshot testing. The answer_hash column makes it trivial: the test generates a puzzle with a fixed seed and compares the stored hash against a golden snapshot. If the generator changes, the hash changes, and the test tells me exactly that one puzzle whose answer shifted. The table I use for deciding what to write:
| Approach | Catches | Cost per test | Worth it solo? | |---|---|---|---| | Unit tests on internals | Logic errors in isolated functions | Low | Rarely — you'll rewrite the internals | | Integration tests on API | Wiring, auth, validation, DB | Medium | Yes — this is the safety net | | End-to-end browser tests | UI regressions | High | Only for the 3 pages users actually touch |
The integration suite runs in under 30 seconds. I run it before every push. That is the entire CI story.
Deployment Without the DevOps Burden
The deployment is a $5 VPS, Docker Compose, and one shell script. There is no Kubernetes, no Terraform, no observability stack, no CI pipeline that builds artifacts I then deploy somewhere else. The entire process:
git pull
docker compose up -d --buildThat is it. One container image, one compose file, one server. The logs go to stdout, and docker compose logs -f --tail=100 tells me everything about the health of the system.
I deliberately skipped infrastructure-as-code. Terraform is another system to learn, another state file to babysit, another set of version constraints to watch. For five containers on one box, the abstraction costs more than the problem it solves. A backup script does more for my peace of mind than any provisioning framework:
#!/usr/bin/env bash
set -euo pipefail
TIMESTAMP="$(date +%Y-%m-%d_%H%M%S)"
BACKUP_DIR="/var/backups/3ni8ma"
mkdir -p "$BACKUP_DIR"
docker exec 3ni8ma-db pg_dump -U enigma enigma \
| gzip > "$BACKUP_DIR/enigma_$TIMESTAMP.sql.gz"
# Keep 30 days of backups
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +30 -delete
echo "Backup written: $BACKUP_DIR/enigma_$TIMESTAMP.sql.gz"A cron job runs that script nightly, and I test the restore path quarterly by spinning up a throwaway container and loading the dump. A backup you have never restored is a rumor, not a backup.
The Instrumentation Trap
For the first year, the only observability was structured JSON logs to stdout. No dashboards, no metrics server, no tracing. That was the right call, and it remains the right call. The trap I keep avoiding is installing Grafana and Prometheus because they are fun, not because I have a question they answer.
My rule: add an instrument only when I can name the exact question it answers. The day a friend texted "is 3ni8ma down?", the question was "is the process alive?" — so I added a /healthz endpoint and an uptime check. The day a puzzle request crawled for five seconds, the question was "which query is slow?" — so I enabled slow-query logging, found a missing index on the leaderboard, added it, and moved on. No dashboard was built.
The moment I feel the urge to draw a sparkline, I stop and ask what decision the sparkline would change. So far, the honest answer has always been "none." When that answer becomes "yes, I need to know if the worker queue is backing up," I will add a single metric for that, and not the other ninety Prometheus defaults.
When to Reconsider
The monolith is not a religious position. There are real signals that it is straining, and I keep them written down so I notice them before the code forces me to:
- Build time over three minutes. When the image build eats that much time, the problem is usually the build cache, not the architecture — but if splitting the build does not fix it, splitting the app might.
- Two modules with genuinely different scaling needs. The puzzle generator is CPU-bound; the leaderboard is storage-bound. If the worker queue ever grows to thousands of jobs per minute, the worker deserves its own process. Not before.
- More than two people in the codebase. Merge conflicts are a feature of parallel work, and a monolith serializes merges. The moment the team grows, the module boundaries I built become the seams for extraction.
- A compliance or security boundary. If one module processes payments and another stores only guesses, they get their own deployment because the audit logs demand it.
For each signal, the remedy is the strangler pattern: extract exactly that one module into its own process, leave the monolith intact, and stop there. A big-bang re-architecture is how solo projects die.
- Architecture for a solo developer is a personal discipline, not a diagram: optimize for the ability to re-read the code cold and ship a fix.
- Start with a modular monolith: one process, one database, one deployable unit, with strict boundaries between domain modules.
- Let the database be the contract between modules, with constraints that catch real bugs on every write.
- Add background jobs and instrumentation only when you can name the exact problem they solve; every moving part is a tax on your future attention.
- Reconsider the monolith only when you hit measurable signals: slow builds, divergent scaling needs, a growing team, or compliance boundaries.
01Should I start with microservices "to be safe"?
02SQLite or Postgres for a solo project?
03How do I keep module boundaries from eroding over time?
04When should I add a queue?
Conclusion
The architecture of a solo project is not a diagram you present in a meeting. It is a set of rules you negotiate with your future self, and the negotiation is explicit in the code. When I look at 3ni8ma now, I see a monolith that is boring on purpose: one process, one database, one queue, one log stream. Each of those ones is a decision I made after losing weeks to the alternatives.
The deeper lesson is about trust. A solo developer has no one to blame and no one to catch mistakes, so the architecture has to be the guardrail. Module boundaries that are enforced by a script, constraints that are enforced by the database, tests that exercise the real HTTP stack — these are the silent reviewers of a one-person team. They do not get tired, and they do not miss the PR that never happened.
If you take one thing from this article, let it be this: design for the size of the team you actually have, which is one. Start with a modular monolith, keep every moving part justified, and let the day you genuinely need a second service be the day you pay for that complexity — not the day you anticipate it.
The full source of 3ni8ma, including the monolith, the migrations, and the deployment scripts, is public: View the project on GitHub.