If you are a solo developer carrying the entire system on your shoulders, two paths appear before you every morning: following the flawless, pristine "clean code" principles written in books, or shipping that feature that needs to go live today at all costs. I have been living right in the middle of this dilemma since 2006. For years, I have swung between these two poles while writing code for enterprise projects, my own side products, and production ERPs.
Where I have finally landed is pragmatism. The biggest enemy of a solo developer is not their competitors or insufficient server resources; it is the dream of a "perfect architecture" detached from reality that they create in their own head. If you are on your own, the cleanliness of the code you write is only valuable as long as that code goes into production and can generate revenue.
1. The Perfectionism Trap: 10 Hours of Refactoring vs. 10 Minutes to Production
Almost all of the popular "Clean Code" books in the industry are written for teams backed by massive budgets and dozens of engineers. When you are working alone, hiding everything behind abstraction layers and building a three-tier architecture for every tiny function only slows you down. Last year, I needed to write a simple email delivery module on the backend of a side product I was developing. I had two options: I could either write an abstract IEmailServiceProvider interface and its factory classes, fully compliant with SOLID principles, so that I wouldn't have to change a single line if I changed the email provider tomorrow—or I could just call the library directly and move on.
If I had chosen the first option, designing the architecture, writing its tests, and managing dependencies would have taken me about 4 hours. I chose the second path; I imported the library directly, wrote a 10-line function that reads the API key from the environment variable, and pushed it to production. Total time: 12 minutes.
# Over-engineered approach - A waste of time for a solo developer
class IEmailProvider(ABC):
@abstractmethod
def send(self, to_email: str, subject: str, body: str): pass
class ComplexEmailFactory:
@staticmethod
def get_provider(provider_type: str) -> IEmailProvider: ...
# The pragmatic approach I preferred (FastAPI / Python)
import httpx
async def send_simple_email(to_email: str, subject: str, content: str):
# If you are on your own, go straight to the target. If the provider changes, you can change these 10 lines.
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.mailgun.net/v3/mydomain.com/messages",
auth=("api", "key-xxxxxxxxxxxx"),
data={"from": "Mustafa <mail@mydomain.com>", "to": to_email, "subject": subject, "text": content}
)
response.raise_for_status()
Thanks to this simple approach, I spent the 3 hours and 48 minutes I saved on fixing a bug in the checkout steps that users were experiencing directly. In solo operations, time is the most valuable capital, and spending this capital on imaginary flexibility is an invitation to bankruptcy.
⚠️ The Illusion of Flexibility
The biggest misconception among developers is the thought of "Let's make it easy to change this library in the future." In my 20-year career, I have seen that 90% of the abstraction layers written just for this possibility end up in the trash without ever being changed.
2. How Over-Engineering Loses Money (Annual Server and Time Cost)
When the microservices craze started, I saw many colleagues split even their solo projects into 15 different microservices, put message queues in between, and try to run them on Kubernetes. What was the result? A project that could easily run on a single $15/month VPS (Virtual Private Server) turned into a $250/month cloud bill and unmanageable complexity.
The goal of a solo developer should always be "the fewest moving parts." Every new service, every new database, every new queue mechanism is a burden that must be monitored, updated, backed up, and troubleshot during a crash.
| Architectural Approach | Monthly Server Cost | Update/Maintenance Time (Weekly) | Mean Time to Resolution (MTTR) |
|---|---|---|---|
| Unnecessary Microservices | $280 (Cloud + Managed DB + K8s) | 8 Hours | ~3 Hours (Log tracing mess) |
| Pragmatic Monolith | $15 (1 Core, 2GB RAM VPS) | 30 Minutes | 5 Minutes (A single journald log) |
If your system does not receive 10,000 requests per second (and 99% of projects don't even come close to this level in their first years), all you need to do is write a clean monolith. In my own side products, I spin up the PostgreSQL, FastAPI, and Vue/React trio with a single docker-compose file. Deployment is just a matter of running git pull && docker compose up --build -d.
In my previous [related: Docker disk fires] post, I detailed the disk space issues I encountered in such minimalist infrastructures and how I resolved them. Keeping the infrastructure simple ensures you know exactly where to look when an error occurs.
3. "Working Code" Does Not Mean Garbage Code: My Pragmatic Standards
I don't want to be misunderstood here; by "working code," I don't mean a pile of garbage code full of security vulnerabilities, variable names like a, b, c, and zero logs. On the contrary, when you are on your own, the "readability" of the code is vital so you don't borrow from your future self. However, my clean code standards differ from the rules in academic books.
For me, pragmatic clean code has three basic rules:
- Readability: When I look at the code 6 months later, I should be able to understand what it does without needing any documentation. I prefer clear and descriptive variable names over complex one-liner magic.
- Error Handling and Logging: Code can break, that's natural. But when it breaks, it must produce a meaningful error log that tells me exactly on which line and with what data it failed.
- Security: Fundamental issues like SQL injection, authorization gaps, and rate limiting can never be postponed under the pretext of "working code."
# READABLE AND SECURE: A simple but robust endpoint example
@app.post("/api/v1/invoice/generate")
@limiter.limit("5/minute") # Rate limiting is vital
async def generate_invoice(request: InvoiceRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
# Authorization check is pragmatic and clear
if not current_user.is_active:
logger.warning(f"Inactive user tried to generate invoice: {current_user.id}")
raise HTTPException(status_code=403, detail="Account is suspended")
try:
invoice = create_invoice_record(db, request, current_user.id)
return {"status": "success", "invoice_id": invoice.id}
except Exception as e:
# Working code without logging is a ticking time bomb
logger.error(f"Failed to generate invoice. User: {current_user.id}, Error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal billing system error")
There are no complex design patterns or unnecessary abstractions in this code. But it is secure, has a logging mechanism set up, and is readable. This is exactly what the standard of a solo developer should be.
4. A Real Case Study: What Did I Sacrifice to Reduce a 12-Second Query to 150 Milliseconds?
In one of my production ERP projects, the shipment reporting screen locked up the entire system every time it ran. The query took about 12.4 seconds, and database CPU usage hit 98%. If I had adopted an academic "clean code" approach, I would have had to fully normalize the database schema, write a microservice to archive old data, and perhaps apply the CQRS (Command Query Responsibility Segregation) pattern to separate the read and write databases. This approach would have cost me at least 2 weeks of sleepless nights and potential new bugs.
What did I do? When I analyzed the problem, I saw that the query was constantly scanning the past 30 days of transactions and there was no index on the status field. I defined a single partial index on PostgreSQL 15 and converted it into a raw SQL query by removing three unnecessary JOIN operations inside the query.
-- An academically "unclean" but life-saving partial index
CREATE INDEX idx_delivery_pending_last_30_days
ON deliveries (created_at DESC, customer_id)
WHERE status = 'PENDING' AND created_at > NOW() - INTERVAL '30 days';
The query time instantly dropped from 12.4 seconds to 142 milliseconds. Yes, I embedded a raw SQL query inside the code instead of using the ORM (Object-Relational Mapping). Some software gurus might find this contrary to "clean code" standards because it creates a database dependency. However, in the real world, the probability of us migrating that ERP's database from PostgreSQL to another system was close to zero. This pragmatic decision saved me 2 weeks and kept the system running smoothly. I shared similar performance solutions in my [related: PostgreSQL index strategies] post; sometimes the best code is the code that is written the least.
5. Being "Clean Enough" in Solo DevOps and Infrastructure Management
If you are writing code and managing the server where that code runs all by yourself, you have to be pragmatic on the system administration side as well. Instead of spending weeks automating CI/CD processes and building complex pipelines, you should choose the simplest and most reliable method that gets the job done.
In my own projects, instead of heavy tools like Jenkins or GitLab CI, I use GitHub Actions or a simple self-hosted runner. On the server side, I prefer systemd and docker-compose over Kubernetes to manage Docker containers. A simple systemd unit file that ensures a service spins back up automatically if it crashes is enough to give me a peaceful night's sleep.
# /etc/systemd/system/app-watcher.service
[Unit]
Description=My Pragmatic App Container Watcher
After=docker.service
Requires=docker.service
[Service]
TimeoutStartSec=0
Restart=always
RestartSec=10
# Simple health check and auto-restart mechanism
ExecStart=/usr/local/bin/docker-compose -f /opt/myapp/docker-compose.yml up
ExecStop=/usr/local/bin/docker-compose -f /opt/myapp/docker-compose.yml down
[Install]
WantedBy=multi-user.target
Thanks to this service file, even if the server reboots or the Docker daemon crashes, the system recovers itself. Without dealing with complex Kubernetes pod replication settings, I get a zero-cost and highly reliable infrastructure by using the operating system's native capabilities (systemd).
6. Technical Debt Management: When to Pay, When to Declare Bankruptcy?
When you move forward by writing "working code," technical debt inevitably accumulates in your system. This debt is just like credit card debt; it is a sensible tool to grow your business, but if it grows out of control, it will bankrupt you. The secret to managing this debt as a solo developer is knowing exactly when to pay it off.
I always tag the "workaround" solutions I write in the code with a specific format. These tags are not ordinary TODO comments. They contain a date, the reason for the debt, and a trigger for when this debt should be paid.
# FIXME: [2026-05-30] - Mustafa Erbay
# REASON: Reading quickly from a JSON file for the initial launch.
# TRIGGER: Must be migrated to a PostgreSQL table when active user count exceeds 500.
# IMPACT: Read speed is O(N), will cause performance issues after 500 users.
def get_user_preferences_legacy(user_id: int):
with open("temp_preferences.json", "r") as f:
data = json.load(f)
return data.get(str(user_id), {})
I search for these tags in the codebase on a weekly or monthly basis. If the trigger I defined (for example, the user limit) has not been reached, I don't touch that code. If it works and gets the job done, let it be. However, as soon as that debt starts to slow down business processes or cause real errors, that's when I start a planned refactoring process.
If you want to succeed on your own, you must put aside the obsession with writing perfect code and focus on writing "working code that delivers value." Because at the end of the day, your users don't care about how clean your code is; they care about whether your application solves their problem.
In my next post, I will talk about how I position AI agents as a workforce in solo projects and how I reduced my operational load by 40%.
Top comments (0)