DEV Community

Mustafa ERBAY
Mustafa ERBAY

Posted on • Originally published at mustafaerbay.com.tr

Perfect Architecture vs. Working Code: 3 Lessons for the Solo

There's an age-old battle in the software world: Perfect Architecture vs. Working Code. If you're a solo developer working on projects alone, this battle happens within your own mind, before every commit. We talk about design patterns, clean code principles, microservices, and by the end of the day, we're left with a "perfect" but dead pile of code that hasn't even had a single line pushed to production. In my 20 years of field experience, I've fallen into this trap countless times; each time, I've seen that the pragmatic approach, i.e., working code, wins.

For someone developing a product alone, the most valuable resource is time. You need to solve in a single night what large corporate teams take weeks to resolve with architectural meetings. In this post, drawing from my experience developing my own side projects and in the field, I'll examine 3 fundamental lessons that help solo developers survive and the trade-offs in architectural decisions.

[related: PostgreSQL Index Strategies]


1. The Infrastructure Trap: Setting Up Kubernetes When Docker Compose Suffices

One of the most common mistakes solo developers make is trying to set up a massive Kubernetes (K8s) cluster for a project that isn't even visited by 100 people a day. Perfecting the infrastructure is the most comfortable way to avoid doing the "real work." You set up K8s, configure ingress, and struggle with certificate renewal automation; but there's no product that can actually bill customers yet.

I made this mistake myself with one of my side projects initially. Thinking, "What if millions of requests come tomorrow?" I set up a 3-node cluster. The result? I paid an extra $40 bill every month just for the RAM consumption of the control plane components. Moreover, one day when etcd broke, it took me a full 6 hours to get the system back up. Meanwhile, I could have solved the same task with zero overhead using a simple Docker Compose file running on a single VPS.

# docker-compose.yml - The solo developer's lifesaver
version: '3.8'

services:
  web:
    image: python:3.11-slim
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
    volumes:
      - .:/app
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/dbname
    restart: always
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 512M

  db:
    image: postgres:16-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: dbname
    restart: always

volumes:
  postgres_data:
Enter fullscreen mode Exit fullscreen mode

The simple configuration above can easily handle hundreds of requests per second. Furthermore, thanks to cgroup limits, you prevent the web service from causing a memory leak and locking down the entire server. For a solo developer, the perfect infrastructure is the one that takes the least time to maintain and won't wake you up at night.


2. Over-Normalization in Database Design and ORM Traps

Database normalization rules like 3NF, 4NF, as taught in academic books, look great on paper. However, in practice, an over-normalized database schema for a solo developer means endless JOIN queries and performance bottlenecks. Especially if you're using an ORM (Object-Relational Mapping) library, it can take time to notice the N+1 query disasters happening in the background.

While working on a production ERP system, we had divided everything into so many tables that showing a simple order status on the operator screen required joining 12 different tables. The SQL queries bloated, and database CPU usage hit 95%. We later solved this problem by denormalizing some data, meaning we kept frequently read fields as JSONB in a single table.

⚠️ Be Careful When Using ORMs

ORM tools are great for rapid prototyping, but they can cause you to overlook query optimization. Especially to prevent queries going to the database within loops (the N+1 problem), don't hesitate to use eager loading or raw SQL.

In the SQL example below, you can see how we can solve complex relationships with PostgreSQL's JSON capabilities in a single query. This approach saves you from writing dozens of lines of ORM code on the client side and avoids burdening the database:

-- Fetching related data as JSON in a single query (N+1 solution)
SELECT 
    orders.id,
    orders.status,
    json_build_object(
        'id', customers.id,
        'name', customers.name,
        'email', customers.email
    ) AS customer,
    (
        SELECT json_agg(json_build_object('item_id', order_items.product_id, 'qty', order_items.quantity))
        FROM order_items 
        WHERE order_items.order_id = orders.id
    ) AS items
FROM orders
LEFT JOIN customers ON orders.customer_id = customers.id
WHERE orders.status = 'pending'
LIMIT 50;
Enter fullscreen mode Exit fullscreen mode

This query retrieves all the data you need hierarchically with a single database round-trip. Your goal as a solo developer is to leverage the power of the database to its fullest, rather than writing hundreds of lines of data transformation logic in your code.


3. Premature Optimization and "You Aren't Gonna Need It" (YAGNI) Architectures

The "YAGNI" (You Aren't Gonna Need It) principle is the most important rule a solo developer should hang on their wall. Writing code today for hypothetical future scenarios is the biggest obstacle to finishing a project. For instance, trying to set up message queue systems like RabbitMQ or Kafka for a simple email sending process that runs in the background is a complete waste of time.

In a side project I developed last year, I considered setting up a separate worker service for the welcome email to be sent after a user registers. Then I stopped and asked myself: "How many people are registering per day right now?" The answer: 5. Why should I manage an extra service, a queue mechanism, and a log monitoring layer for this operation? Instead, I used the built-in power of Linux, solving the task with a simple systemd timer or an asynchronous task within the application.

Feature Heavy Queue Architecture (Kafka/RabbitMQ) Simple Systemd Timer / Async Task
Setup Time 4-8 Hours 10 Minutes
Memory Consumption ~512MB - 1GB RAM ~0MB (No extra overhead)
Maintenance Cost High (Version updates, disk full) Negligible
Solo Developer Compatibility Low Very High

If your system truly grows, then changing the architecture will be a welcome problem. But taking on this burden from day one will cause you to get tired before you even start.


4. Microservice Dreams vs. Monolith Reality

Microservices architecture is a solution found by large organizations to allow teams to work independently. For a solo developer to choose a microservices architecture, however, is technical suicide. You cannot single-handedly deal with problems like inter-service communication, distributed tracing, network latency, and eventual consistency.

After witnessing the complexity of managing the deployment and monitoring of 40 different microservices on a bank's internal platform, I better understood why I always choose the "modular monolith" approach for my own projects. A single codebase (monorepo), a single database, and a single deployment process give you incredible speed.

# /etc/systemd/system/my-monolith-app.service
# Simple systemd definition to keep the solo developer's monolith service live
[Unit]
Description=My Monolith Web Application
After=network.target postgresql.service

[Service]
User=www-data
WorkingDirectory=/var/www/my-app
ExecStart=/var/www/my-app/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 main:app
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

With the systemd service definition above, if your application crashes for any reason (e.g., exceeds memory limits or encounters an unexpected error), it will automatically restart within 5 seconds. You don't need Kubernetes or complex pod management tools. Just systemd 252+ and clean configuration.


5. Error Handling and Logging: Settling for Journald When Sentry Isn't Available

Observability is important, but you don't have to spend your budget and time on it. Instead of paid APM tools or complex ELK (Elasticsearch, Logstash, Kibana) stacks, learning to use the built-in tools provided by the Linux kernel is a great power for a solo developer.

One of the biggest mistakes I've seen in system administration is logs growing uncontrollably and filling up the disk. The "Docker disk fire" incident, where the /var/lib/docker/containers directory swells and locks down the entire server, happens to almost every solo developer. To prevent this, you can use journald's rate limiting and disk size restriction features.

# /etc/systemd/journald.conf.d/size-limit.conf
# Configuration to prevent logs from filling up the disk
[Journal]
SystemMaxUse=2G
SystemMaxFileSize=200M
MaxRetentionSec=1month
RateLimitIntervalSec=30s
RateLimitBurst=10000
Enter fullscreen mode Exit fullscreen mode

With this simple configuration, your logs will never occupy more than 2GB in total, and services attempting to lock down the system by generating more than 10,000 logs per second will be automatically throttled. When there's an error, you can filter critical errors from the last hour with a single command in the terminal:

# Shows only logs of 'error' level and higher from the last hour
journalctl -u my-monolith-app.service --since "1 hour ago" -p err..emerg --no-pager
Enter fullscreen mode Exit fullscreen mode

[related: Docker Disk Full Solutions]


6. Decision Matrix for the Solo Developer: When to Say "Perfect"?

So, at what point should we stop and invest in code quality or architecture? The answer is very simple: When the value produced by the code exceeds its maintenance cost. If the code you've written can bill customers tomorrow morning, it's "working" code, and it doesn't need to be perfect at that stage.

The decision matrix below can provide you with a practical guide on how to proceed when developing a feature:

Scenario / Need Initial Approach (Working Code) When to Change? (Perfect Architecture)
User Registration / Auth Simple JWT / Session-based single function When user count exceeds 50,000+ or OAuth2/SSO is required
File Uploads Direct save to server disk (/uploads) When disk starts filling up or CDN integration is needed (S3 etc.)
Reporting / Analytics COUNT and GROUP BY on PostgreSQL When reports take longer than 5 seconds to run (Read Replica or ClickHouse)
Payment Integration Direct call via a single API endpoint When daily transaction volume exceeds $10,000+ and Idempotency is required

My firm stance on this is: First run, then validate, then optimize. No matter how perfect the architecture of non-working code is, its value is zero. Your real strength as a solo developer is to use that "agility" and "speed" that large companies sacrifice to bureaucracy to your advantage. Let your code be a little dirty, as long as it's in production and delivering value to customers.

In the next post, I will explain the PostgreSQL optimization techniques I use in the backend architecture of my own side projects.

Top comments (0)