What Building Software for Small Businesses Taught Me About Engineering
TL;DR
I showed up to a car detailing shop with a 12-page microservices architecture doc and got absolutely humbled. Building for small businesses forces you to ship fast, choose boring technology, and learn that the best engineering isn't about complexity — it's about solving real problems for real people who will text you at 2 AM if something breaks. These lessons made me a better engineer at every scale.
I showed up to my first small business client meeting with a 12-page technical architecture document. Microservices. Kubernetes. Event-driven message queues. The works. This guy runs a car detailing shop out of his truck. He looked at me, looked at the document, looked back at me, and said "I just need people to book online, man."
That was the most important product lesson of my career.
I've spent a meaningful part of my career building software for small businesses since then. Not venture-backed startups with engineering teams and ping pong tables. Real small businesses — a car detailing shop, an image restoration service, a cleaning company. The kind of businesses where the owner answers the phone, does the work, invoices the client, and also somehow needs to manage a website and booking system in between.
And I'm going to be honest with you: this work taught me more about engineering than any enterprise project ever did. It wasn't even close.
The Gap Between Enterprise and "Can My 50 Customers Book?"
In enterprise engineering, we obsess over things that small business owners have literally never heard of. And I mean that — I once tried to explain "horizontal scalability" to a cleaning service owner and she said, "Honey, I have 40 clients. I don't need horizontal anything. I need vertical — as in, I need to stand up straight after scrubbing floors all day."
Fair point.
┌─────────────────────────────────────────────────────────────────┐
│ Enterprise vs Small Business Priorities │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Enterprise Engineering Small Business Reality │
│ ───────────────────── ───────────────────── │
│ Horizontal scalability "Can my 50 customers book?" │
│ Microservices architecture "Does the website load?" │
│ CI/CD pipelines "Can I update the prices?" │
│ 99.99% uptime SLAs "Is my site down right now?" │
│ A/B testing frameworks "Do people actually call?" │
│ Distributed tracing "Why didn't I get the email?" │
│ Kubernetes orchestration "How much does hosting cost?" │
│ │
└─────────────────────────────────────────────────────────────────┘
When I first started building for small businesses, I brought my entire enterprise mindset like a kid bringing a scientific calculator to a lemonade stand. I proposed architectures with separate services, message queues, and container orchestration. The car detailing shop owner looked at me like I'd suggested we launch his business to Mars.
He wanted three things: a website that showed up on Google, a way for customers to book appointments, and a way to get paid.
That's it. Three things. And honestly? Serving those three needs well is harder than it sounds. Way harder. Because there's nowhere to hide when the scope is this small — every rough edge is visible, every slow page load costs a real person a real customer.
Shipping Fast vs Shipping Right (They're Not Opposites, I Promise)
Here's the tension every freelance engineer feels in their bones: you want to build things properly, but the client needed it yesterday, and the budget is... let's say "optimistic." I once quoted a small business owner my rate and he laughed so hard I thought he was going to need medical attention.
I used to think "shipping fast" and "shipping right" were opposites. Like you had to pick one. Turns out they're different axes entirely, and the sweet spot is absolutely reachable — you just have to swallow your pride about a few things.
┌─────────────────────────────────────────────────────────────────┐
│ The Shipping Matrix │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Fast Slow │
│ Right ┌──────────────────┬──────────────────┐ │
│ │ Sweet spot: │ Enterprise: │ │
│ │ Boring tech, │ Thorough but │ │
│ │ clear scope, │ often over- │ │
│ │ proven patterns │ engineered │ │
│ ├──────────────────┼──────────────────┤ │
│ Wrong │ Prototype: │ Worst case: │ │
│ │ Quick hack that │ Slow AND broken │ │
│ │ becomes prod │ (analysis │ │
│ │ (tech debt bomb) │ paralysis) │ │
│ └──────────────────┴──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
The sweet spot — fast AND right — comes from choosing boring technology, reusing proven patterns, and ruthlessly cutting scope. Emphasis on ruthlessly. I'm talking "this feature seems important but we're cutting it anyway" levels of ruthless.
For Shining Image, a photo restoration and digitization service, I needed a site with a service catalog, before/after gallery, and a contact form. My first instinct? Build a custom CMS. Because of course it was. I'm an engineer — we see a nail and reach for a nail factory. Instead, I forced myself to use Next.js with static pages, Tailwind for styling, and a simple contact form that sent emails via Resend. Deployed on Vercel. Done in under a week.
Was it the most elegant architecture? No. Did it make me feel clever? Also no. Did it work perfectly for the business and still work today with zero maintenance? Yes. And that's the point.
The 80/20 of Small Business Software
80% of small business software needs are solved by: a fast website, a way to collect customer information, a way to accept payments, and automated email notifications. Build those four things well and you've covered most cases. I know it sounds boring. That's because it is. Embrace the boring.
Choosing Boring Technology (A Love Letter)
I'm a strong advocate for boring technology in general, but for small business projects it's not just a preference — it's a responsibility. And I learned this the hard way.
When you hand off a project to a small business owner, there's no engineering team to maintain it. There's no on-call rotation. There's no Slack channel where someone will triage the issue. If something breaks at 2 AM, the business owner is going to text you personally. (Ask me how I know. Ask me about the texts I've gotten at 2 AM. Actually, don't — my therapist says I need to stop reliving those.)
Here's my small business tech stack and why each choice exists:
// The "Boring Stack" for Small Business Projects
const boringStack = {
framework: "Next.js", // Large community, great docs, Vercel hosting
styling: "Tailwind CSS", // Utility-first, no CSS-in-JS runtime issues
database: "PostgreSQL", // Rock solid, free tier on Supabase/Neon
orm: "Prisma", // Type-safe, readable queries, easy migrations
payments: "Stripe", // Industry standard, handles PCI compliance
email: "Resend", // Simple API, reliable delivery
hosting: "Vercel", // Zero-config deploys, generous free tier
auth: "NextAuth.js", // When needed — many small biz apps don't need auth
monitoring: "Vercel Analytics", // Built-in, no extra setup
};
// What I explicitly AVOID for small business projects:
const notBoring = {
avoid: [
"Microservices", // One service is enough
"Kubernetes", // Way too much operational overhead
"Custom auth", // Security liability
"NoSQL databases", // PostgreSQL handles everything
"GraphQL", // REST is simpler to debug
"Monorepos with Turborepo", // It's one app
"Redis", // PostgreSQL can handle the caching
"Message queues", // Direct function calls work fine
],
};Every single item in the "avoid" list is a technology I have personally proposed for a small business project at some point. Every single one was a mistake, or would have been if someone hadn't talked me out of it. I once almost set up a Redis cache for an app that had 12 concurrent users. Twelve. My PostgreSQL instance was doing literally nothing. It was bored. It was begging for queries.
Technology Is a Liability, Not an Asset
Every technology choice is a maintenance burden. For enterprise, the burden is distributed across a team. For small business, the burden falls on one person — you — potentially for years. Choose accordingly. Future you is going to either thank present you or curse present you. There is no in between.
Building a Real Booking System (A.K.A. "Sounds Simple, It's Not")
For AutoSpa, a mobile car detailing service, the core need was a booking system. Customers needed to pick a service, choose a date and time, and pay. Sounds simple, right?
Ha. Haha. No.
I thought it would take a week. It took three. And I've built booking systems before! (It was not, in fact, a simple feature.) Here's what a booking system actually involves when you sit down and think about it:
// The booking data model — deceptively complex
interface BookingSystem {
// Service catalog
services: {
id: string;
name: string; // "Full Detail", "Interior Only", etc.
duration: number; // in minutes
price: number;
description: string;
addOns: AddOn[]; // "Ceramic Coating", "Pet Hair Removal"
}[];
// Availability management
availability: {
businessHours: DaySchedule[]; // Mon-Sat 8am-6pm
bufferTime: number; // 30 min between appointments
maxBookingsPerDay: number; // Capacity limit
blockedDates: Date[]; // Holidays, vacation
travelTime: number; // Mobile service — need transit time
};
// The booking itself
booking: {
customer: CustomerInfo;
service: string;
addOns: string[];
datetime: Date;
location: Address; // Mobile service goes to the customer
vehicle: VehicleInfo; // Make, model, size affects pricing
status: "pending" | "confirmed" | "completed" | "cancelled";
payment: PaymentInfo;
remindersSent: Date[];
};
}See that travelTime field in the availability config? That one line represents approximately four hours of me staring at a whiteboard going, "Wait, if he's in Miami Beach at 2 PM and the next appointment is in Coral Gables at 3 PM, and it's a Friday..." Mobile businesses add a layer of scheduling complexity that makes your head spin.
The tricky part isn't the data model, though. It's the edge cases — the stuff nobody mentions in the tutorial:
- What happens when two people try to book the same slot? (This happened. Day one. Of course it did.)
- How do you handle cancellations and refunds? (The policy changed three times in the first month.)
- What about travel time between mobile appointments?
- How do you send reminders without annoying people?
- What if the customer no-shows? (Spoiler: they will.)
// Slot availability — must account for existing bookings + buffer + travel
async function getAvailableSlots(date: Date, serviceId: string): Promise<TimeSlot[]> {
const service = await getService(serviceId);
const daySchedule = getDaySchedule(date);
const existingBookings = await getBookingsForDate(date);
const slots: TimeSlot[] = [];
let currentTime = daySchedule.start;
while (currentTime + service.duration <= daySchedule.end) {
const slotEnd = currentTime + service.duration;
const hasConflict = existingBookings.some((booking) => {
const bookingStart = booking.startTime;
const bookingEnd = booking.endTime + BUFFER_MINUTES + TRAVEL_MINUTES;
return currentTime < bookingEnd && slotEnd > bookingStart;
});
if (!hasConflict) {
slots.push({
start: currentTime,
end: slotEnd,
available: true,
});
}
currentTime += SLOT_INCREMENT; // 30-minute increments
}
return slots;
}I learned that race conditions in booking systems are very real. Not in a theoretical computer science way — in a "two actual humans both want the 10 AM slot on Saturday and they're both clicking Book Now right now" way. The solution is honestly simpler than you'd think: database-level locking. Don't try to be clever about it. Let PostgreSQL do what PostgreSQL does best.
// Optimistic locking with Prisma — prevent double bookings
async function createBooking(data: BookingInput) {
return await prisma.$transaction(async (tx) => {
// Check if slot is still available inside the transaction
const conflicting = await tx.booking.findFirst({
where: {
date: data.date,
startTime: { lte: data.endTime },
endTime: { gte: data.startTime },
status: { not: "cancelled" },
},
});
if (conflicting) {
throw new Error("This time slot is no longer available");
}
// Create the booking
const booking = await tx.booking.create({ data });
return booking;
});
}I shipped the first version without the transaction wrapper. "What are the odds two people book the same time?" I said to myself, like a fool. The odds were apparently 100%, because it happened on literally the first Saturday the system was live. A customer showed up to find someone else already getting their car detailed. The owner called me. It was not a pleasant conversation.
Transactions. Always transactions. Lesson learned. (It was, in fact, not fine.)
Handling Payments with Stripe (The One Thing I Got Right From Day One)
Every small business app I've built involves payments. Stripe is the answer, every time. I will die on this hill.
Not because it's the cheapest (it's not — that 2.9% + 30 cents adds up and your client WILL ask about it). Not because the API is perfect (it's close, though — whoever designed their API docs deserves a raise and a vacation). It's because Stripe handles PCI compliance, fraud detection, disputes, and tax reporting — problems you absolutely, positively do not want to solve yourself. I have a friend who tried to build a custom payment system once. We don't talk about it. He doesn't talk about it either. He just stares into the middle distance sometimes.
// Creating a Stripe Checkout Session for a booking
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckoutSession(booking: Booking) {
const session = await stripe.checkout.sessions.create({
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency: "usd",
product_data: {
name: booking.service.name,
description: `${booking.service.name} - ${format(booking.date, "MMM d, yyyy")} at ${booking.time}`,
},
unit_amount: booking.totalPrice * 100, // Stripe uses cents
},
quantity: 1,
},
],
mode: "payment",
success_url: `${process.env.NEXT_PUBLIC_URL}/booking/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/booking/cancel`,
metadata: {
bookingId: booking.id,
customerEmail: booking.customer.email,
},
});
return session;
}The webhook handler is where things get spicy. Stripe sends events asynchronously, and you need to handle them idempotently. "Idempotently" is a word I use to sound smart, but it basically means "if Stripe sends you the same event twice because the internet hiccuped, don't charge the customer twice." You'd be amazed how many people get this wrong. (I got this wrong.)
// Stripe webhook handler — must be idempotent
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const bookingId = session.metadata?.bookingId;
if (!bookingId) break;
// Idempotency: check if already processed
const booking = await prisma.booking.findUnique({ where: { id: bookingId } });
if (booking?.status === "confirmed") break; // Already processed
await prisma.booking.update({
where: { id: bookingId },
data: { status: "confirmed", stripeSessionId: session.id },
});
// Send confirmation email
await sendBookingConfirmation(booking);
// Send SMS reminder (scheduled for 24h before appointment)
await scheduleReminder(booking);
break;
}
case "charge.refunded": {
// Handle refund — update booking status
const charge = event.data.object as Stripe.Charge;
await handleRefund(charge);
break;
}
}
return new Response("OK", { status: 200 });
}See that idempotency check — if (booking?.status === "confirmed") break? That little if statement saved me from a bug that would have sent duplicate confirmation emails. I found this out during testing when I got seven confirmation emails for one booking. Seven. The Stripe CLI webhook replay feature is both a blessing and a curse.
Stripe Webhook Testing — Save Yourself Hours
Use the Stripe CLI to forward webhooks to your local dev server: stripe listen --forward-to localhost:3000/api/webhooks/stripe. This saves hours of debugging compared to deploying to test webhook flows. I spent an entire day once deploying to staging to debug a webhook issue. An entire day. The Stripe CLI would have found it in five minutes. Learn from my pain.
Reliability Over Features (Or: Nobody Cares About Your Animations)
The most important lesson from small business work, and I cannot stress this enough: reliability trumps features every single time. Every. Single. Time.
A small business owner does not care about your clever animation. They do not care about your innovative UI pattern. They do not care that you implemented a really elegant state machine. (I know. I'm sad about it too.) They care about:
- Does the website load fast? — Slow sites lose customers. Real money. Not "engagement metrics" — actual dollars walking out the door.
- Can customers actually book/buy? — If the payment flow breaks, they lose money. And they will call you. At dinner. On a Saturday.
- Do I get notified? — They need to know when bookings come in. Miss a notification and they miss a customer.
- Does it work on phones? — Most of their customers are on mobile. If you only test on your 27-inch monitor, you're going to have a bad time.
For Clear Choice, a cleaning service company, I built a simple quote calculator. The owner wanted a dozen fields to capture every detail about the customer's home. Pet type. Carpet color. Number of windows. Whether they had a "complicated kitchen" (her words). I pushed back hard. We launched with four fields: square footage, number of bedrooms, number of bathrooms, and frequency. That was enough to generate an accurate quote 90% of the time. The other 10% got a phone call.
The owner hated me for about a week. "What about the pet question?!" she kept asking.
// Simple quote calculator — covers 90% of cases
function calculateQuote(input: QuoteInput): Quote {
const baseRates: Record<string, number> = {
studio: 90,
"1bed": 120,
"2bed": 150,
"3bed": 190,
"4bed": 240,
};
const frequencyDiscount: Record<string, number> = {
once: 1.0,
biweekly: 0.9,
weekly: 0.8,
monthly: 0.95,
};
const base = baseRates[input.bedrooms] ?? 150;
const sqftAdjustment = Math.max(0, (input.sqft - 1000) / 500) * 25;
const bathroomAdjustment = Math.max(0, input.bathrooms - 1) * 20;
const discount = frequencyDiscount[input.frequency] ?? 1.0;
const total = Math.round((base + sqftAdjustment + bathroomAdjustment) * discount);
return {
estimate: total,
isEstimate: true,
message: `Estimated price: $${total} per visit`,
disclaimer: "Final price may vary based on home condition and specific needs.",
};
}Three months later, she told me the simple quote form was the best decision we made. Customers actually filled it out (because it took 30 seconds instead of 5 minutes), and she converted more leads because she could follow up before they closed the browser tab and forgot they wanted a cleaning service. Sometimes the best feature is the one you didn't build.
The Business Side Makes You a Better Engineer (Whether You Like It or Not)
Working with small business clients forces you to think about things enterprise engineers often ignore — or worse, actively dismiss as "not my job":
Cost matters. Like, really matters. When the client's entire software budget is $200/month for hosting and tools, you learn to be resourceful real quick. You discover that Vercel's free tier handles 90% of small business traffic. You learn that Supabase's free PostgreSQL instance is more than enough for a local business. You stop spinning up infrastructure "just in case" because "just in case" costs $47/month and the client's profit margin is thinner than you think.
Simplicity is a feature. The admin panel for AutoSpa has six buttons. Not because I couldn't build more — trust me, I had designs. Beautiful designs. A dashboard with charts and analytics and a customer segmentation tool. But the owner manages his business from his phone between appointments while his hands are still wet from washing cars. Every extra button is cognitive load he literally cannot afford. Six buttons. It's perfect. I've never been more proud of something so simple.
You are the entire team. There's no frontend team, backend team, DevOps team, and design team. You are all of them. You are also QA, project management, and customer support. This sounds terrible (and some days it is), but it forces you to be pragmatic about every single decision because you're the one who has to live with all of them. You stop over-engineering real fast when you realize you'll be debugging your own "clever" abstractions at midnight.
┌─────────────────────────────────────────────────────────────────┐
│ Skills Developed by Small Biz Work │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Technical Non-Technical │
│ ───────── ───────────── │
│ Full-stack ownership Requirements gathering │
│ Database design Client communication │
│ Payment integration Scope management │
│ DevOps / deployment Budget estimation │
│ Performance optimization Expectation setting │
│ SEO fundamentals Teaching non-technical users │
│ Email deliverability Long-term maintenance planning │
│ Mobile responsiveness Business model understanding │
│ │
└─────────────────────────────────────────────────────────────────┘
Seriously, Do This
If you're a junior or mid-level engineer looking to accelerate your growth, go build something real for a small business. I don't care if you charge them $500 or do it for free. The constraint of serving a real customer with a real budget teaches you things no tutorial, bootcamp, or side project ever will. You'll learn more in one month of "the booking page is broken and I'm losing customers" than in a year of building todo apps.
What I'd Tell My Past Self (Who Would Definitely Not Listen)
If I could go back to when I started building for small businesses, here's what I'd say. (Past me would ignore most of this, because past me thought he was very smart. He was not.)
Don't over-engineer. Please. I'm begging you. The car detailing shop doesn't need a microservices architecture. A single Next.js app with a PostgreSQL database will outlast most startups' infrastructure. It will also outlast the Kubernetes cluster you're fantasizing about, because Kubernetes clusters require people to run them and this business has one person and it's a guy named Carlos who is very good at making cars shiny but does not know what a pod is.
Charge for maintenance. Building the app is half the work. The other half is keeping it running, updating dependencies, fixing the random edge case that appears six months later when someone tries to book an appointment on February 29th (ask me how I know). Build maintenance into the contract from day one. Future you will either get paid for this work or do it for free. There is no third option.
Document everything for non-technical people. Write a simple guide that says "here's how to update your prices" with screenshots. Not a technical README. Not API documentation. Screenshots. With red circles around the buttons. Your future self (and theirs) will thank you. I once got a call from a client who said "the website is broken" and it turned out he was trying to edit the site by right-clicking the browser and selecting "Inspect Element." Documentation matters.
Build relationships, not just software. The car detailing shop owner referred me to three other businesses. The cleaning service owner told her entire BNI group about me. Small business communities are tight-knit. Good work compounds. A single happy client is worth more than a hundred LinkedIn posts about your tech stack.
Measure what matters. For a small business, the metrics that matter are: Did the phone ring more? Did online bookings increase? Did the owner spend less time on admin work? If yes, you succeeded. If no, your architecture doesn't matter. Nobody cares about your Lighthouse score if the phone isn't ringing. (Okay, the Lighthouse score helps the phone ring, but you get my point.)
The Paradox (Or: Why "Simple" Software Is the Hardest Kind)
Here's the irony, and it took me years to fully internalize this: building "simple" software for small businesses is some of the hardest engineering I've done. Genuinely. Not because the technical problems are complex — a booking system is not distributed systems research. But because the constraints are unforgiving.
You can't hide behind infrastructure. You can't blame another team. You can't ask for more time or budget. You can't say "well, it works in staging." You have to ship something that works, that a non-technical person can use without calling you, that runs reliably with minimal oversight, and that directly impacts someone's livelihood. Their mortgage payment is connected to your code working. That's a different kind of pressure than "the sprint velocity is down."
That pressure produces better engineers. I genuinely believe that. I've carried every lesson from this work into larger projects — the bias toward simplicity, the obsession with reliability, the willingness to cut scope ruthlessly, and the understanding that software exists to serve people, not the other way around.
The best engineering isn't about building the most sophisticated system. It's about building the right system for the people who will use it. Sometimes that system has six buttons and a PostgreSQL database and it's deployed on a free tier. And sometimes that's exactly perfect.
Carlos's booking system is still running, by the way. Two years and counting. Zero downtime. He texts me sometimes — not because something is broken, but to tell me business is good. Those are the best texts I've ever gotten at 2 AM.
Frequently Asked Questions
Don't miss a post
Articles on AI, engineering, and lessons I learn building things. No spam, I promise.
Osvaldo Restrepo
Senior Full Stack AI & Software Engineer. Building production AI systems that solve real problems.