Evaluate a SaaS Architecture Like a Staff Engineer
Five checks for what a SaaS codebase will cost to change, not whether checkout still works on a Tuesday.
As a junior engineer fresh out of school I've always liked starting new projects: greenfield, everything's possible, which also meant new tech I actually got to choose (often untested and shiny) and concepts I wasn't inheriting from some other team's mess. It felt like the whole point of having left school.
As a junior, you also don't want to spend money because you don't really have any and you feel that you can do everything, so you'll build everything from scratch, often very poorly: auth provider, infrastructure, mailers, those invoices feel insane when the product has twelve users. It does not look so bad at first, it feels very right at the moment, and then you have to live with it, for a while.
The other way you live with it is the framework. CoffeeScript felt like a gift when Rails handed it to you as the default, implicit returns and a compiler that wrote the JavaScript you then stepped through in a debugger at 1am, and you didn't write those bugs, you just owned them. Then ES6 ate the good parts, Rails 6 stopped generating .coffee files, and the files were still there, so frontend people you wanted to hire saw the repo and the conversation got shorter. That's the glass ceiling. The tech isn't untrusted because it's new anymore, it's untrusted because the industry left and you didn't.
The current rhyme is Next on Vercel, same shape, louder. App Router cache that behaves on a long-lived local process and then doesn't, because use cache sits in memory and a serverless instance dies, so you ship a cached page that is not cached and spend a week in GitHub discussions for a bug you didn't write either. Middleware was the blessed place to put auth, until a request header could skip it if you weren't sitting behind their edge, and serious customers don't care that it was a framework bug, they care that your door was a convention. I mean, that's the bit that rearranges you. Not the syntax. Who you can still hire, and who will still sign the questionnaire after they google the stack.
Then you grow, and you start evaluating things differently, whether you meant to or not. You stop asking "is this new" and "can we avoid the bill," and you start asking whether this is really trustworthy because you'll have to live with it for a while, and then what happens when we have to move it. Checkout still works, kind of. The next change may not though, and that is the part that rearranges how you look, even if you don't have a name for the new look yet.
A staff engineer doesn't evaluate whether a SaaS codebase works today, they evaluate what it costs to change tomorrow: a pricing rewrite, a payment provider swap, a team that triples, a framework they cannot trust.
That's the whole job, honestly, once you've seen a few of these.
I keep a short list. Five things, same five every time, because if you improvise the criteria you end up grading the stack you already like.
- Where billing logic lives and how many files a pricing change touches
- Whether RBAC is a first-class layer or a flag scattered through components
- Whether any external dependency can be swapped without touching product code
- Whether the test suite catches a regression before a customer does
- Whether the technology has been supported for a while, and how trusted it is by the community
The rest of this is what those look like when they're true, and when they're not. You can run them in an afternoon on a repo you didn't write, and you will not get a score, you'll get a file count, a permissions hole, a vendor import in a place it shouldn't be, a test that would stay green, a framework you cannot hire for. That's usually enough.
Where billing logic lives and how many files a pricing change touches
The test is ugly and it works. You pick a real change, not a hypothetical, annual vs monthly or grandfather the people on $29 while new signups pay $39, and then you count the files you'd actually open.
If the number stays small, billing probably lives in one place, a catalog of products and prices, entitlements hanging off that catalog, with checkout, invoices, emails, and the billing UI reading from it instead of keeping a private copy of the amount. A price change is a new row, and Stripe doesn't let you edit an amount in place, fair enough, so "update the price" was never going to be a one-line mutate anyway. Old subscriptions keep the old rate because they're pinned, not because someone remembered to special-case them in the email template.
If the number gets large, you've found it: amounts in a config file, a second copy in the checkout component, a trial length hardcoded in the signup mutation, a plan name in a receipt template that will still say Pro after you renamed the package. I've done this. The $29 lived in four places and we only found the fourth after a customer forwarded the receipt.
If I get past about eight files I stop counting and write down "many." Eight is not a law, it's just past the point where I still believe the catalog is in charge.
Why this one sits first: pricing is the change you cannot park behind a rewrite. Someone will ask for a coupon or an annual plan, and they will ask while you are in the middle of something else. You can live with a messy billing layer for a stretch. Living with it and also moving packaging every other week is a different shape of pain.
Whether RBAC is a first-class layer or a flag scattered through components
Permissions are a layer or they are a rumor.
What's a layer you may ask? A closed set of scopes, a role maps to scopes, then the API checks the scope before the resolver does any work, and the UI hides the button using the same scope, not a different guess at who is allowed. A new hire who joins a team that just tripled cannot invent access by putting if (user.role === 'admin') on a settings page, because the page never gets the data if the scope is missing. They can still try, and the API says no.
A flag looks like isAdmin in the sidebar, and a different check on the billing screen, and a GraphQL field that forgot is the same mess, so the real model is whatever the last person copied. When the team triples they will copy the flag, that's the whole failure mode, not malice, just the nearest example. Read a settings page and the matching mutation side by side, because the UI can look locked down and the API can be wide open, or the other way around.
Tenancy rides along here whether you asked it to or not, org-owned billing vs user-owned billing, or some hybrid you haven't named, and if that choice is implicit, the first enterprise customer will name it for you, probably in a security questionnaire you fill out at 11pm.
This is how you lose a customer without a bug report, they see an invoice that isn't theirs, or a member who shouldn't have billing access does, and they don't file a ticket.
Whether any external dependency can be swapped without touching product code
Open a resolver and see what it imports.
If you see sendEmail or createSubscription, you're probably looking at a contract, with the Stripe SDK and the mailer client living behind it. Swapping is still work, you write an adapter and you wire a config value, then you learn the new provider's webhooks the hard way, but invoice preview doesn't come along for the ride.
If you see the vendor's SDK in the resolver, or the vendor's types in an invoice helper, you don't have a dependency. You have a marriage. I mean that in the boring way: the product and the vendor had a child and the child's name is Customer, and a webhook handler that is also your subscription state machine is the same marriage, just louder.
Vendor lock-in as a business conversation is another topic, I'm digressing, contracts, pricing, data export, lawyers. The architecture question is smaller and meaner. Can the rest of the app not notice.
I like a discriminated config that names the client, more than a pile of env vars that imply the vendor if you squint. Not that it matters much which discriminator you pick, honestly, as long as product code never imports the SDK. Env vars feel faster on day one, then they feel the same as a config object until the second implementation exists, and then they feel like archaeology.
A payment provider swap is one of the tomorrow-costs in the opening sentence, and if you cannot find the boundary, you cannot estimate the swap. People collapse "write the adapter" and "rewrite billing" into one number, then the project is called rewrite billing, so how long the adapter actually takes is a separate estimate, worth keeping separate even when both numbers are ugly.
Whether the test suite catches a regression before a customer does
Having tests is not the check. The check is: if you make the pricing change from the first item, does CI go red before a customer sees the old amount on a receipt.
A suite that helps looks a bit boring. There is one default price per product, or there isn't, this mutation requires this scope and if you strip the check the test dies, because the test is stuck to the invariant, not to a screenshot of the old page.
A suite that doesn't help is a wall of happy paths from the last time packaging was simple, so it seeds $29, snapshots $29, never instantiates the annual price in EUR, stays green, and production sends the old amount. We do this when we're tired, it feels like coverage because the file count is high.
I have shipped that green CI.
Oh, and flaky tests you skip don't count. A test that flaps until someone adds .skip is not catching a regression, it's catching a calendar.
I don't have a great answer for how much of this is enough. Honestly I still don't know which missing case would have caught the last one, maybe it was the fixture, maybe the assertion was just pinned to the old shape. We'll get there eventually, I'm sure, but we haven't found the right formula yet.
Whether the technology has been supported for a while, and how trusted it is by the community
This is the check the intro was already making, just named.
Look at the GitHub issues and who still answers them, then look at a job post that lists the stack and see whether the people you want still apply, which is more information than the landing page.
I like Express for a SaaS API, not because it is nice, but because it has been the Node HTTP default long enough that the bugs are known bugs, the next hire has already used it, and a questionnaire does not turn into a seminar on why we picked it. Passport is the same shape for auth, strategies, callbacks, a session sitting on a cookie, and it is not pretty, but people still know how it fails, so I will take that over an auth library that shipped last year with a delightful onboarding and one maintainer.
Next.js can feel like the default in 2026 and still leave you owning cache that works on a long-lived local process and evaporates when the serverless instance dies, which is the CoffeeScript move with a bigger marketing site. New is not trusted, trusted means the community still shows up after the blog posts stop.
In short
Run these five on a repo you didn't write and you won't come away with a score, you'll come away with a shape. A file count for pricing, a settings page that either checks or doesn't, an import you can or can't name, a test that would or wouldn't have caught the last one, and a job post nobody from the community you want would apply to. None of that is a grade. It's just what tomorrow already costs, whether anyone's written it down or not.
I still don't run all five every time, honestly. Some weeks it's the vendor import because that's the fire, some weeks it's the RBAC read because someone just asked about SOC2. The list is there so you don't end up grading the stack you already like, not so you check every box before lunch.
Greenfield still feels the same as it did out of school, mostly. You get to choose. The cost just doesn't show up until later, and by the time it does, you're the senior person someone else is asking to explain why the sidebar checks one thing and the mutation checks another.
FAQ
- How do you evaluate a SaaS architecture like a staff engineer?
A staff engineer doesn't evaluate whether a SaaS codebase works today, they evaluate what it costs to change tomorrow: a pricing rewrite, a payment provider swap, a team that triples, a framework they cannot trust. Run five checks. Where billing logic lives and how many files a pricing change touches. Whether RBAC is a first-class layer or a flag scattered through components. Whether any external dependency can be swapped without touching product code. Whether the test suite catches a regression before a customer does. Whether the technology has been supported for a while, and how trusted it is by the community.
- Where should billing logic live in a SaaS codebase?
In one catalog the rest of the product reads from. Products, prices, entitlements, trial flags. Checkout, invoices, emails, and the billing UI shouldn't keep their own copy of the amount. A useful test: pick a real pricing change (annual vs monthly, or grandfather $29 while new signups pay $39) and count the files you would open. Small number means the catalog is doing its job. Large number means the amount has leaked.
- How do you know if RBAC is a first-class layer?
There is a closed set of scopes, roles map to those scopes, the API refuses the mutation before the resolver does work, and the UI hides the control using the same scope.
isAdminin a sidebar is not a layer. If a new hire can ship a permissions hole by copying a flag from the nearest component, it is scattered. Read a settings page and the matching mutation side by side. If only one of them actually checks, you found it.- What does it mean to swap an external dependency without touching product code?
Product code talks to a contract (
sendEmail,createSubscription). Vendor SDKs live behind that contract, not in resolvers and not in invoice helpers. Swapping still means writing an adapter, that part is real work. It shouldn't mean rewriting invoice preview or every call site that imported the vendor's types. If you can't find the boundary, you can't estimate the swap, you can only estimate a rewrite.- How do you know if the test suite will catch a regression before a customer does?
Ask whether the change from the other checks would go red in CI. A new price that leaves an old amount on a receipt. A missing scope on a mutation. Suites that only replay the old happy path, or seed $29 and snapshot $29, stay green while production is wrong. Flaky tests you skip don't count.
- Why pick Frameworks like Express or Passport over newer frameworks?
Because you have to live with the choice. Express has been the Node HTTP default for a long time, so the bugs are known bugs and the next hire has already used it, and Passport is not pretty, but the strategies are documented, the failure modes are old, and people still answer questions about it. Next.js on Vercel can feel like the default in 2026 and still leave you debugging cache that works on your laptop and evaporates on a serverless instance. New is not the same as trusted, trusted means the community still shows up, and a security questionnaire does not become a history lesson.