Summary
What building a full-stack microservice from scratch taught me about Clean Architecture, teaching, and the discipline of knowing why every line of code exists.
---
The Guide I Wish Someone Handed Me
I engineer a financial blockchain protocol. A system where a single bug in a transfer function can mean real money disappearing.
That kind of pressure teaches you things no tutorial ever will.
But it didn't start there.
It started with spaghetti. Auth checks tangled with database queries. Business logic buried inside Express route handlers. console.log as a debugging strategy.
Code that worked on my machine and broke everywhere else.
I got better. Not because I read a textbook. I got better because production punished every shortcut. Slowly, painfully, I started understanding why code should be organized a certain way. Not because a pattern said so.
Because 2 AM taught me.
At some point I wanted to write it down. Not a textbook. Not documentation. A conversation. The kind where someone sits next to you, opens a terminal, and just… builds something with you.
So I did. From an empty directory to a running Kubernetes cluster. Sixteen chapters. Every decision explained.
This is what I know so far. Not everything. I'm still figuring things out. But enough to sit next to you and be useful.
---
Why a Grocery List
I needed a project anyone could understand in five minutes but complex enough to teach the patterns that actually matter in production.
A Neighborhood Grocery List. Families add items they need. A household manager approves or rejects them. When someone buys an approved item, the cost comes off a monthly budget. Everyone sees what's been spent and what's remaining.
That's it. But look closer:
0.1 + 0.2 in JavaScript gives you 0.30000000000000004. In a financial system, a rounding error isn't a bug. It's a lawsuit.A grocery list. Simple on the outside. Sharp on the inside.
The system I build at work is far more complex. But the patterns? Same ones. If you can architect a grocery list correctly, you can architect anything. The discipline transfers.
---
The Twenty-Five Line Trap
Before I show you the architecture, I want you to see what the alternative looks like:
app.post('/api/items', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.verify(token, 'secret');
if (!req.body.name || !req.body.quantity) {
return res.status(400).json({ error: 'Missing fields' });
}
const list = await prisma.groceryList.findUnique({
where: { id: req.body.listId }
});
if (list.items.length >= 50)
return res.status(400).json({ error: 'List full' });
const item = await prisma.groceryItem.create({
data: {
name: req.body.name,
quantity: req.body.quantity,
estimatedPrice: req.body.price * 100,
status: 'PENDING',
listId: list.id,
},
});
res.json({ success: true, data: item });
});Twenty-five lines. Auth, validation, business logic, database... all in one function. It works. Ship it.
Now imagine thirty endpoints like this. Need to change how authentication works? Edit thirty files. Need to test the business logic without a database? You can't. The query sits on the same line as the rule. Switch from PostgreSQL to MongoDB? Rewrite everything.
When everything depends on everything, changing one thing changes everything.
I've lived inside that problem. I know what it feels like to open a codebase at 2 AM and not be able to tell where the business logic ends and the database begins. That specific kind of disorientation where you're not even sure what you're looking at anymore.
The twenty-five lines aren't wrong. They're a trap. They work today and punish you tomorrow.
---
Four Walls and a Door
I didn't discover Clean Architecture by reading about it first. I discovered it by building systems where every layer bled into every other, and then spending weeks untangling them.
The idea is separation. Four layers. Each with one job. Dependencies only point inward.
| Layer | What It Knows | What It Doesn't Know |
|---|---|---|
| Domain | Business rules, errors, value objects | HTTP, databases, frameworks |
| Application | Use cases, orchestration, DTOs | How data is stored or served |
| Infrastructure | Database access, external APIs | How or why it's being called |
| Interface | HTTP, controllers, routes, middleware | Business rule implementation |
The domain is the heart. It knows a PENDING item can become APPROVED or REJECTED. It knows a budget can't go negative. It doesn't know what PostgreSQL is. It has never heard of Express.
The application layer orchestrates. "Find the item. Check if the transition is valid. Update the status. Return the result." It calls interfaces, ports, not concrete implementations. It doesn't care if the data lives in Prisma, MongoDB, or an in-memory array.
The infrastructure layer implements what the application asked for. Swap Prisma for something else, and only this layer changes. Nothing above it notices.
The interface layer is the thinnest. A controller extracts data from an HTTP request, calls a use case, formats a response. Three lines inside a try-catch. If your controller has business logic in it, something belongs deeper.
I'm not saying this is the only way to organize code. I'm saying it's the one that stopped punishing me.
---
The Life of a Request
The single most important thing I can show you is what happens when a user clicks "Add Milk":
Nine steps. Each step has one job. Each step knows nothing about the steps above or below it.
The BFF (Backend For Frontend) is worth pausing on. The browser never talks directly to the backend microservice. It talks to API routes inside the same Next.js application. Think of the BFF as a receptionist. The visitor says what they need. The receptionist checks their ID, picks up the phone, and calls the right department. The visitor never needs to know which department to call or what their extension number is.
Your backend might be on port 3060 today, 3070 tomorrow, and behind a load balancer next week. If the frontend knows the backend's address, you've welded two things together that need to move independently.
The first time I saw all nine steps fire in sequence, request flowing down, response flowing back up, every layer doing exactly one thing… I didn't celebrate.
I just sat there... staring at the logs.
---
Errors as Conversations
An error is not a failure. It's a message.
I learned this the hard way. A swallowed error in a transfer function cost me an entire night once. No stack trace. No context. Just a generic 500 and silence. I promised myself I'd never write an error like that again.
A swallowed error tells you nothing. Until production breaks at 2 AM and nobody knows why. A well-thrown error tells the next developer exactly what went wrong, where it went wrong, and what they should have done instead.
When a manager tries to approve an item that's already been bought:
{
"success": false,
"error": {
"code": "INVALID_ITEM_STATE",
"message": "Cannot approve item: current status is BOUGHT, cannot transition to APPROVED",
"details": {
"currentStatus": "BOUGHT",
"targetStatus": "APPROVED",
"operation": "approve"
}
}
}That's not a generic "Bad Request." That's a conversation. The error is saying: here's where you are, here's where you tried to go, and here's why you can't.
Every domain error in this system has a machine-readable code, an HTTP status, a timestamp, and structured details. ItemNotFoundError. BudgetExceededError. InvalidItemStateError. InsufficientPermissionsError. Each one specific. Each one self-documenting. The frontend can switch on the code. The developer can read the message. The logs tell the full story.
I stopped writing throw new Error('something went wrong') a long time ago. Generic errors are lazy errors. Lazy errors make debugging painful.
Errors deserve the same care as features.
---
Making Wrong Things Impossible
The best validation isn't catching mistakes. It's making mistakes impossible.
Money is the clearest example. I work in a financial system. Floating-point money would be catastrophic. So we store cents as integers. $3.50 becomes 350. All math stays in integers.
But here's the thing... that decision lives in your head, not in your code. A new developer joins, writes const price = 3.50, stores it directly. Three cents instead of three-fifty. No error. No warning. Silent corruption.
The fix is a value object:
export class Money {
private constructor(private readonly cents: number) {}
static fromCents(cents: number): Money {
if (!Number.isInteger(cents))
throw new Error(`Money must be integer (cents), got: ${cents}`);
return new Money(cents);
}
static fromDollars(dollars: number): Money {
return Money.fromCents(Math.round(dollars * 100));
}
get inCents(): number { return this.cents; }
get formatted(): string { return `$${(this.cents / 100).toFixed(2)}`; }
}Money.fromCents(350), explicit. Money.fromDollars(3.50), also explicit. You cannot accidentally pass dollars where cents are expected. The private constructor won't let you. The type system won't let you.
Same principle for state transitions. A PENDING item can become APPROVED or REJECTED. An APPROVED item can become BOUGHT. Nothing else. I encode valid transitions as a map and enforce them with a single guard function. Try an invalid transition, and the system doesn't return a vague error. It names the current state, the attempted state, and the operation that triggered it.
Constraints aren't limitations. They're guardrails. The kind you don't appreciate until something slips past them on a Friday... and nobody remembers why the code was written that way.
---
The Pitfalls I Walked Into
| Pitfall | Why It Hurts | What I Do Now |
|---|---|---|
| Floating-point money | Rounding errors compound silently | Store cents as integers, always |
| Auto-incrementing IDs | /item/47 tells attackers there are 46 more | UUIDs reveal nothing |
| Business logic in controllers | Can't test rules without spinning up HTTP | Use cases own the logic, controllers stay thin |
| Frontend calling backend directly | Tight coupling breaks on topology changes | BFF layer hides backend addresses |
No createdAt/updatedAt | "When did this change?" Unanswerable at 2 AM | Every table, every time |
| Connection pool exhaustion | Hot-reload creates a new Prisma client on every save | Singleton pattern on globalThis |
| Generic error messages | "Bad Request", a debugger's nightmare | Structured domain errors with codes and details |
| Validating inside controllers | Validation logic duplicated across endpoints | Zod middleware validates before the controller runs |
Every row in that table is a scar. Not one of them came from a book first. They came from the system telling me, patiently, firmly, that I was wrong.
---
Three Principles
These aren't rules I invented. They were imposed on me. By broken builds. By silent failures. By production.
"Every line has a reason." If you can't explain why a line of code exists, delete it. No copy-paste without understanding. The monorepo structure exists because shared packages prevent duplication. TypeScript strict mode exists because it catches bugs at compile time. The Prisma singleton exists because connection pool exhaustion is a real production problem. Nothing is decoration.
"Read the error, trust the error." The error message is telling you exactly what's wrong. Read it. Read it fully. Don't guess. Don't open a browser before you've read every word the error gave you. Nine times out of ten, the answer is right there. Our structured domain errors are designed for this. They don't just say something went wrong. They say what, where, and why.
"Ship it, then perfect it." A running ugly prototype teaches you more than a perfect plan sitting in a document. I built this entire application without authentication first. Used db push instead of migrations. Hardcoded the list ID. None of that was "correct." But it got me to a running system fast. Then I layered on auth, Docker, and Kubernetes incrementally. The worst code is code that never runs.
---
What I Carry Forward
And the books that shaped how I think about all of this: Clean Architecture, Clean Code, Designing Data-Intensive Applications, Domain-Driven Design, The Pragmatic Programmer. Read them. Not to memorize patterns, but to understand the thinking behind the patterns.
Books give you patterns. Production gives you wisdom.
---
Two Ways to Learn This
Everything in this post comes from a guide I wrote. Sixteen chapters. Every line of code explained, every architectural decision justified, every pitfall documented. I wrote it as a conversation, not a textbook. You read it, you build alongside it, and by the end you have a working application you understand completely.
There are two ways to use it:
Build from scratch. Download the guide, open your terminal, and start from an empty directory. Every chapter builds on the last. By the end, you'll have a full-stack app running in Kubernetes. You'll understand every layer because you wrote every line yourself. This is the path I recommend. It's slower. You'll learn more.
Clone and explore. The completed application is in the repo. Clone it, read the guide alongside the code, and trace every pattern through the implementation. Domain layer, application layer, infrastructure, interface, frontend, Docker, K8s manifests. It's all there. If something in this post made you curious, you can find the exact file and line.
Either way... star the repo if it helped. It tells me someone found this useful.
Download the Guide, Open Your Terminal
The full 16-chapter guide as a PDF. Read it, build every chapter from an empty directory, and understand every decision along the way. By the end you'll have a running app in Kubernetes that you built with your own hands.
Read the Code, Trace the Patterns
The completed application is in the repo. Clone it, read the guide alongside the code, and trace every pattern through the actual implementation. Every layer, every file, every decision is there.
git clone https://github.com/mnzralee/fullstack-grocery.git
cd fullstack-grocery && npm install---
I'm still learning. CQRS. Event sourcing. Distributed tracing. Saga patterns. The list keeps growing. The more I build, the more I realize how much I don't know yet.
But I know enough to be dangerous. Enough to sit next to someone, open a terminal, and say: "Here. Let me show you what I've figured out so far."
That's all this ever was. Not a lecture. A conversation. Between someone who's accumulated a few useful scars, and someone who's about to earn their own.
