How I Build Software
Six years of full-stack work, the stack I reach for, and the process behind it — discovery before design, planning before prompting, and why the review bar doesn't move just because a machine wrote the diff.
August 6, 2026 — 16 min read — workflow · process · ai · engineering
I'm Yvan Sabay, a full-stack developer based in the Philippines. I've spent roughly six years building and shipping production web applications — internal systems, SaaS platforms and client products — and I work both ends of every feature, from the data model and the APIs through to the typed interfaces sitting on top of them.
It started in college, and I fell for it fast. What got me was that building something and learning something turned out to be the same activity — you'd set out to make a thing work, and by the time it did you understood something you hadn't that morning. Nothing else I'd tried gave me both at once. You finish the day with a thing that runs and a head full of stuff you didn't know at breakfast, and the reward for finishing is another problem you don't know how to solve yet.
Six years in, that hasn't worn off. The problems got considerably larger and the consequences got real, but the loop is the same one I found in college.
I learned this craft writing every line by hand. These days a significant share of the typing is delegated to AI, and I want to be precise about what that did and didn't change. It did not change who owns the architecture, the trade-offs, or whatever ends up in production. What it changed is the ratio — how much of the day goes into mechanical work versus the decisions that are genuinely hard.
This post is about the process that ratio depends on. Because the honest version of "AI made me faster" is this: it made me faster at building the thing I'd already thought through, and considerably faster at building the wrong thing when I hadn't.
The stack
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
TypeScript everywhere it's allowed. React and Next.js, Vue and Nuxt, Inertia where the app is better served by one codebase than by two. On the backend, Laravel most often, with MySQL behind it, and Ruby on Rails where the project already lived there.
Firebase when the product is genuinely realtime. I've used it extensively — Firestore and the Realtime Database, Firebase Auth, Cloud Functions, Storage and messaging — and the reason isn't that it's less work. It's that presence, live sync and push are the kind of thing you either get for free or spend a month building badly. The trade is that your data model and your security rules become the same conversation: with no server tier between the client and the database, the rules are the authorisation layer, and anything a rule can't express has to move into a function. Knowing which half a given feature belongs in is most of what using Firebase well actually is.
Around that: Docker for local parity, GitHub Actions for CI/CD, Sentry for the errors that only exist in production, Cypress and Jest for coverage, Figma for prototypes that go in front of people before anything is built.
The list is long because six years of shipping means inheriting other people's decisions as often as making your own. What I actually care about is narrower: typed end to end, a data model chosen deliberately, and boundaries between systems that were designed rather than accumulated.
Discovery comes before design
Most wasted engineering is a correct build of the wrong thing. Not buggy — wrong. It works exactly as specified and nobody uses it.
So before I design a system, I want to know what the business is actually losing today, and that only comes from talking to the people living with it.
- Talk to the stakeholder. Start with how the work happens now — the spreadsheet, the workaround, the thing someone does manually every Friday. What people ask for is a solution they've already guessed at. I want the problem underneath it.
- Find the real problem. The person paying for the software and the person using it daily almost never describe the same pain. That gap needs finding early, because building for only one of them is how a project gets delivered and then quietly abandoned.
- Design the system. Data model, the flows between screens, where the boundaries sit, what talks to what. Decided deliberately and written down, so the architecture is a choice rather than whatever the first sprint happened to produce.
- Prototype and show it. Low fidelity through to high, in front of people before it's built. A stakeholder cannot review a paragraph. They will tell you precisely what's wrong with a screen.
- Iterate. Change it, show it again. Redrawing a screen costs an afternoon. Rebuilding a shipped feature costs a sprint and a measure of the client's confidence.
- Agree the scope. What's in, what's explicitly out, what we're assuming — settled before implementation starts, so what ships is what everyone agreed to and scope creep becomes a conversation rather than a surprise.
None of that involves AI, and the order is the argument. The thinking happens before the tooling.
Planning is the work
The single biggest determinant of output quality isn't the model. It's how precisely the problem was specified before the model saw it.
I lean on AI heavily — scaffolding, refactors, test suites, migrations, the work that used to consume entire days. What I haven't handed over is the thinking. Every artefact below exists before the first prompt, and that is the difference between output that ships and output that merely compiles.
The problem statement. In plain language: who is stuck, what they're trying to do, what it costs them today, and what success looks like measurably. Hand a model a vague problem and it will confidently solve a different one.
Aggressive planning. Every unclear requirement gets resolved before it gets built. I push the model to surface edge cases and argue design decisions through rather than accept the first approach that compiles. An assumption made silently at this stage is a rewrite later.
User stories. The most important artefact of the set. Stories keep the work anchored to a person rather than a spec, and they're what acceptance criteria hang off. Without them, AI optimises for something plausible. With them, it optimises for something someone actually needs.
The UI flow. Screens and the transitions between them, designed before implementation — including the empty, loading and error states everyone forgets. A model writes markedly better code against a flow it can see than against a paragraph it has to imagine.
An explicit out-of-scope list. Naming what we are not building is the cheapest way to stop a model generating three features nobody asked for.
Tests, written first. Against the acceptance criteria, before the implementation exists. This one matters more than it sounds: a failing test is an unambiguous specification — it either passes or it doesn't. Given one, a model writes toward a target. Given a vague paragraph, it will build the wrong thing and the code will compile perfectly while doing so.
Most of what gets called "AI slop" is a planning failure that arrived at speed.
The tools
- Claude — the primary driver. Planning, implementation, refactors and test suites. It does the typing; the architecture decisions and the review stay with me.
- Cursor — an AI-first code editor. A fork of VS Code with the model built into the buffer rather than bolted on beside it, so completions, inline edits and multi-file changes happen where you're already typing.
- VS Code — my editor when I'm running Claude. Noticeably lighter than Cursor, and if the AI already lives in the terminal there's little reason to pay for a heavier shell around it.
- Herdr — an agent-aware terminal multiplexer. tmux-style persistent panes and sessions, but it also surfaces each agent's state — idle, working, blocked, done — in a sidebar, so I can see which one needs me without cycling through panes.
- FluidVoice — dictation, free and fully on-device. Prompts are prose, and I speak prose faster than I type it. Nothing leaves the machine.
- Tailscale — a private mesh network across my devices, so my development machine is reachable from anywhere. A laptop, a tablet or someone else's desk is just another terminal into the same session.
From one sentence to a shipped feature
The shape of it is simple enough to list:
- Specify. Problem statement, stories, acceptance criteria, scope boundary.
- Design the flow. Screens and states, so there's something concrete to build against rather than a description to imagine.
- Decide the architecture. Where this lives, what it touches, what it stores, what it does when the thing it depends on is down.
- Write the tests. Red first, straight from the acceptance criteria.
- Delegate the implementation. Claude in a Herdr pane, dictated where dictating is faster. Cursor when the change is easier to point at than to describe.
- Review properly. Every diff read as though a contractor wrote it — because in effect one did.
- Ship, then watch it. Monitoring and error tracking, because the failure modes that matter only ever surface in production.
Written down like that it looks tidy, and it badly undersells where the time goes. Steps one through four are most of the work, and the reason why is easiest to see with a real request.
A worked example
Say the ask is: "let users connect their email account so the app can send on their behalf."
One sentence. Hand it to a model and you will get code back immediately, and the code will look like OAuth. It will have a consent URL, a callback route, a token stored somewhere. It will probably even work — once, on your machine, with your own Google account, with the consent screen still in testing mode.
Then it reaches real users and you find out what the sentence didn't say.
On experience, none of this is decided yet. Where does connecting live — in settings, or inline the first time someone tries to send? What's on screen while the provider's popup is open, and what happens if they close it halfway through? Can they connect two accounts, and if so which one is the default sender? How do they disconnect, and what happens to anything already queued when they do? What do we say when Google shows the "this app isn't verified" screen, because a user who sees that with no warning does not continue.
On correctness, it's worse. Which providers — Google, Microsoft, both? They
differ in scopes, consent behaviour and token lifetime, so "both" is two features
wearing a trenchcoat. Which scope exactly: the narrow send-only one, or the full
mailbox read that's easier to find in the docs and asks the user for far more
than we need? Where does the refresh token live, encrypted with what, rotated
how — because that value is a long-lived credential to someone's inbox and
belongs nowhere near an ordinary column. What happens when an access token
expires mid-send: refresh and retry once, or fail? What happens when the user
revokes access from their Google account settings and we don't find out until the
next send returns a 400 — do we mark the connection dead, and how do we tell
them, given the channel we'd normally use is the one that just broke? What about
provider rate limits, bounces, threading, From versus Reply-To? Is the
callback checking a state parameter, is the redirect URI allowlisted, and is
there any path where a token ends up in a log line or an error report?
And on verification: how is any of this tested without calling Google in CI, and are the failure paths covered or only the happy one?
None of that is exotic. Every item there is something a real user hits in the first month, and not one of them is in the original sentence. A model won't ask about any of it either. It will pick an answer — silently, plausibly, usually the most common one on the internet — and hand you a diff that compiles.
What I actually hand over
For that feature, before any implementation:
- The provider decision and the reason for it, with the second provider named as out of scope for v1 rather than left ambiguous.
- The exact scopes, chosen to be the narrowest that does the job.
- The token storage design — what's stored, how it's encrypted, how it rotates, and who can read it.
- A connection state machine —
disconnected → pending → active → needs_reauth → revoked— and what the interface shows in each of those states. - The send path: one refresh attempt, then a specific failure, with the retry and backoff behaviour written down rather than improvised.
- The actual copy for every error state, including the unverified-app screen.
- An out-of-scope list: no shared mailboxes, no scheduling, no second provider.
- Tests against those states, written first.
That's perhaps ninety minutes of thinking, and it is most of the feature. What follows it — the provider client, the migration, the callback handler, the test doubles, the retry wrapper, the settings UI — is work AI does quickly and does well, precisely because every decision it would otherwise have guessed at has already been made.
The part that doesn't delegate
The useful way to think about the model is as an exceptionally fast implementer who has never met your users, hasn't read your privacy policy, doesn't know which compliance regime you're under, and will never push back on a bad decision because it has no way of telling that it's bad. Give it a decided problem and it is genuinely excellent. Give it an undecided one and it will decide for you, and you won't notice, because the output looks the same either way.
It is, however, a very good adversary. "Here's my design for OAuth token storage — tell me how it fails" gets a sharper list than most code reviews. So does asking it to enumerate the edge cases in a flow, or to argue the case for an approach I didn't choose. That's a different job from asking it to build, and it's the one I lean on hardest during planning.
What stays mine is knowing which of those fifty questions actually matter for this product, and which we're consciously choosing to skip. Skipping something deliberately is a decision. Not knowing it existed is an accident, and the two are indistinguishable in the diff.
Which is why the review step is the one I'd defend hardest. Generated code has a particular failure mode: it is fluent. It reads well, it follows the conventions of the file it's in, and it can be subtly wrong in a way that hand-written code, with its hesitations and its awkward bits, usually isn't. Reviewing it demands more attention than reviewing a colleague's work, not less — and the moment a diff is merged, the fact that a model wrote it stops being relevant to anybody. It's my name on the commit and my problem at two in the morning.
What this is actually for
None of this is about writing less code. It's about spending a larger share of the day on the decisions that are genuinely hard — the data model, the boundary between systems, what happens when the third-party API times out — and a smaller share on the mechanical work surrounding them.
The tools will keep changing; most of what's on this page will look dated inside two years. The parts that won't are the ones that were never really about tooling: understanding the problem before designing the system, showing people something they can react to before it's expensive to change, naming what you're not building, and holding the same bar at review no matter what produced the diff.
The engineering judgement is still mine. The typing no longer has to be.
If you're building something and any of this sounds like the way you'd want it approached, my inbox is open.