The Most Expensive Architectural Decisions Are the Ones You Don't Realize You're Making
Most costly architecture choices never show up on a design doc. They feel like getting things done.
The most expensive line of code I have ever written did not look so bad at first, it felt very right at the moment!
A payment SDK call directly at checkout. An inline role check that needed to get things done at that time. A framework folder convention that shipped the landing page before we had a domain model. A webhook handler that "just marked the invoice paid" because the confirm path already did something similar.
None of those moments felt like architecture at the moment, they felt like getting things done. Months later they showed up as a rewrite estimate. Or a Slack thread where half the team wanted to "just ship" and the other half was staring at a three-week untangle for what used to be an afternoon of coupling.
Senior engineers learn this the hard way. The costly decisions are rarely the ones on a whiteboard with boxes and arrows. They are the defaults you accept because they are local, fast, and socially rewarded. Defaults scale. The tax scales with them.
Two lines that keep showing up when the shortcut is sitting there in the ticket:
Optionality is an architectural investment.
The cost of change is the architecture's true benchmark.
Everything below is judgment practice for those two lines, not a tutorial, and not a stack recommendation dressed as philosophy.
Defaults feel free. They're not.
A decision you don't name still gets made. The runtime will enforce it whether you wrote it down or not.
When domain code imports a vendor client directly, you have decided that all the vendor's types, error strings, and ID shapes are part of your product model. Authorization as if (membership.role === 'admin') decides that tomorrow's feature matrix has to fit yesterday's logic. And when the app's identity is the framework's file tree, leaving that framework stops being a hosting change and becomes a product rewrite. That estimate lands in a planning meeting and a quarter of roadmap disappears into "migration work."
People leave Next.js. People leave managed auth and database bundles. The public discourse frames that as fashion. Operationally it is usually the bill for earlier defaults. The framework or BaaS was fine a lot of the time. The absence of a boundary around it was the expensive part.
Taking the shortcut under deadline is still the default reflex. It's scar tissue from watching teams spend a quarter untangling what took an afternoon to couple, and then taking the afternoon path again because the ticket said Friday.
Optionality is a premium you pay on purpose
Real-options thinking in software is older than the current blog cycle. Design decisions that preserve the right to wait, switch, or abandon a path have economic value under uncertainty. That's not motivational language, it's LITERALLY how capital budgeting talks about irreversible investments, and software is full of irreversible-feeling investments. You know, the schema shape, vendor SDK surface, or the authorization model we just talked about...
You don't need a Black-Scholes spreadsheet in a design review, just to answer a blunt question: if this assumption dies in twelve months, do we edit one module or gut the product?
Paying the premium looks boring:
// Domain and API layers talk to a contract.
await tools.paymentProcessor.createSubscription({
organizationId,
productId, // internal catalog id
quantity,
});
// Vendor SDK stays in one implementation package.
// stripe.subscriptions.create(...) lives there, not in resolvers.
// Config selects an implementation. Call sites stay stable.
tools.queue.client = 'bullmq' | 'sqs'
tools.mailer.client = 'brevo' | 'sendgrid' | 'local'
The premium is a typed contract, a loader, and the discipline to keep SDK imports out of GraphQL and utilities. The return is the option to change providers when pricing, reliability, or compliance forces your hand. Sometimes you never exercise the option. That's fine. You still paid for the right to not panic.
Not every interface pays for itself. A throwaway internal script doesn't need a mailer adapter. Billing, auth, queues, and the catalog do. Those are the places where a "temporary" Stripe type in a domain helper becomes a permanent second source of truth, and then somebody asks why price_id means two different things in two tables.
Parnas said the quiet part in 1972: draw module boundaries around design decisions that are likely to change, not around the steps in a flowchart. Vendor choice is a design decision. So is role naming, and so is framework routing. Processing order is usually not.
The cost of change is the real scoreboard
Teams love proxy metrics. Lines of coverage, framework novelty, "clean" folder trees that impress in a walkthrough. Those can matter. They're not the architecture score.
Ask what these changes cost:
- Swap the transactional email provider.
- Add a permission that isn't "admin."
- Introduce a second commercial price for the same product packaging.
- Finalize a checkout from both a user confirm path and a webhook without double-applying side effects.
If (1) means grepping sendEmail across the monorepo, you skipped the contract. If (2) means rewriting UI gates and resolvers that hard-code role strings, you skipped scopes. If (3) means every subscription row is secretly a vendor price id, you skipped an internal catalog. If (4) means two handlers each invent their own "mark paid" logic, you skipped ownership. That last one usually surfaces the first time Stripe retries an invoice.paid while the user is still staring at the success page.
A pattern that survives contact with production money:
// One business outcome, one finalizer.
// Webhooks and synchronous confirm both call it.
// Second arrival must no-op because the session is already closed.
await finalizeCheckoutForSession({ sessionId, source: 'invoice_paid' });
That's not elegance for its own sake. It's how you keep change local when payment providers emit duplicate events and users click twice. Systems that look tidy on a folder tree can still detonate across packages on a one-line product request.
Defaults that quietly buy your roadmap
Vendor SDKs in domain code
The failure mode is specific. A resolver returns error.message from the provider and a customer sees something about a PaymentIntent that means nothing to them. A utility stores a vendor price id as if it were your product primary key. A webhook consumer reaches back into the SDK to "discover" an invoice id the event didn't carry.
What works instead is a provider-agnostic contract, an implementation folder that may import the SDK, and application code that only sees tools.*. Customer-facing errors stay curated. Internal ids stay internal. External ids resolve at the edge.
Sovereignty here is funnel and data ownership. If your checkout and catalog only exist as shapes inside one vendor's dashboard mental model, you don't own the commercial system. You rent it until the rewrite.
Authorization as stringly-typed roles
if (role === 'admin') ships the first admin screen. It fails when "admin" must invite members but not refund charges, when a plan sells a subset of capabilities, or when you add a partner role that is neither admin nor member and suddenly every gate is a conversation.
Scope-based checks push the decision into data:
// Gate on capability, not job title folklore.
hasScopes(['members.invite'], 'EVERY')
// Plan × role → scopes lives in mappings you can change
// without rewriting every resolver's if-ladder.
The rewrite pain from role strings is social as much as technical. Product language drifts. Engineering keeps patching booleans. Eventually nobody trusts the checks, so people add more checks, and then two gates disagree and a bug only shows up for the partner role on the billing page. Waiting until the fifth role is too late. That one I know from waiting.
Stack as identity
Choosing a meta-framework or a backend-as-a-service is a real decision. Treating it as destiny is the invisible one.
Here's the simple version. Your stack (Express, Next, a BaaS auth bundle) is a commitment you live with for a while. Same for tooling vendors like Stripe, a queue provider, or an analytics SDK. You pick those on purpose because they buy speed. Leaving any of them later is work.
What should not be tangled into them is the product itself: domain models, authorization rules, billing invariants, and thin contracts that call the vendor. Those should still make sense if you swap the provider or move the HTTP server.
The mistake is writing product rules so they only work inside the stack or the vendor. When "who can refund" only exists as a Next middleware quirk, or "what plan are they on" only exists as a Stripe Dashboard mental model, leaving means rewriting the product. When teams publicly regret a stack, read the post-mortems carefully. The regret is often "we let the platform become the domain," not "the platform had zero value on day one."
The wrong split is easy to spot: domain logic that can only run inside one vendor's runtime assumptions.
Catalog and money modeled as vendor leftovers
Billing is product architecture wearing a finance costume. If subscriptions point at opaque external price ids with no internal product/price distinction, grandfathering, trials, and plan changes become string surgery.
A healthier default: persist internal product and price rows, resolve external ids at the processor boundary, keep webhook side effects owned by one path per business action. Dual-era catalogs (legacy rows beside versioned rows) are ugly. They're still cheaper than pretending history doesn't exist, which is what you're doing when every old customer is "just on that Stripe price."
The contract itself should speak your domain too. Parameters in, results out, no vendor types leaking into GraphQL or utilities:
// Call sites pass our models / internal ids.
// They get our result shape back, never Stripe.Subscription.
await tools.paymentProcessor.createSubscription({
organization,
product, // internal catalog row
quantity,
});
// → { success: true, result: { subscription } }
// or { success: false, reason: 'Something unexpected happened' }
The Stripe adapter does the leg work: map to price_*, call the SDK, persist what you need on your rows. Ditching Stripe still means new webhook routes and workers (those stay vendor-specific at the edge). What you don't want is a monorepo-wide rewrite because every resolver imported Stripe.Price.
When people say billing is "just Stripe," they are announcing a default. The invoice is a document and the PaymentIntent is an attempt, but your subscription row is a product fact. Mixing those layers is how a two-hour "pricing tweak" becomes a week of reading webhook logs.
A small decision filter
Before you merge the "obvious" path, force a one-paragraph decision record. Nothing big, just enough to make sure you don't default to something.
Name the default. What are we accepting without a meeting?
The change that would hurt is usually something like a provider swap, a new role, a second price, or a dual entrypoint for the same money event.
What does the premium look like in practice? A contract module, a scope table, an internal id, a single finalizer. If that premium is more than a day or two of work, you're probably inventing enterprise cosplay.
And the refusal matters as much as the premium. What are we explicitly not abstracting yet, and why is that safe for six months?
If you can't name the painful change, you aren't done thinking. The investment has to be smaller than the rewrite it prevents.
Legibility is part of the investment
Architecture that only exists in one person's head fails when that person is out, and it fails again when an LLM proposes an edit that crosses a boundary it can't see.
Hard boundaries shrink the context window for humans and models. "Payment SDK only in the processor implementation" and "one finalizer for checkout completion" are cheaper than cleverness. They're also how you stop a confident-looking refactor from becoming a mergeable disaster because the model didn't know the webhook and the confirm path share a finalizer.
This isn't anti-AI. AI is infrastructure, and infrastructure needs fences.
What I know, and what I don't
How much optionality to buy on day one of a three-person team remains a live argument. Over-abstracted toys are real. So is the six-month Stripe-shaped domain model where every helper takes a Stripe.Price and you can't add a second price without a migration project.
One thing I'm sure about though: I've worked codebases from bootstrapped to publicly traded companies, and the debt that comes due sooner than people expect is usually the same short list. Teams tell themselves they'll "clean it up after the raise." They pay for it in the business change, or the first serious compliance ask.
The heuristic that holds up better than mood: if the dependency touches money, identity, permissions, or durable customer data, pay the premium early. If it is a presentational convenience, wait until the second provider or the second product surface shows up. Waiting is also optionality. Just don't confuse waiting with coupling.
Sources and further reading
- David L. Parnas, "On the Criteria To Be Used in Decomposing Systems into Modules", Communications of the ACM 15(12), 1972. Information hiding as the criterion for module boundaries.
- Kevin Sullivan et al., "Software Design Decisions as Real Options". Treats delayable, irreversible design investments under uncertainty with options reasoning.
- Pallab Saha, "A Real Options Perspective to Enterprise Architecture as an Investment Activity", The Open Group. EA value partly as embedded flexibility.
- Ipek Ozkaya, Rick Kazman, Mark Klein, "Quality-Attribute Based Economic Valuation of Architectural Patterns", SEI / IEEE. Architectural patterns as carriers of real-option value across quality attributes.
- Carlos Carrillo et al., "Guiding Flexibility Investment in Agile Architecting", HICSS 2014. Flexibility investment framed with technical debt and real options.
- Carliss Y. Baldwin and Kim B. Clark, Design Rules, Volume 1: The Power of Modularity (MIT Press, 2000). Modularity as economic structure, not only code taste.
- Michael Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004). Practical cost-of-change tactics when boundaries were never drawn.
- Industry discussion of framework/host coupling, e.g. analyses of Next.js portability trade-offs such as Shubham Sharma on Next.js vendor lock-in architecture. Use as a cautionary map of leaky defaults, not as a ban on the tool.
FAQ
- What are invisible architectural decisions?
Choices that never show up on a design doc because they feel like the obvious path. Importing a payment SDK in a resolver, checking
if (role === 'admin'), binding product behavior to a framework's routing model. They compound into rewrite tax later, usually without anyone remembering when the decision got made.- What does 'optionality is an architectural investment' mean?
Spending a bounded amount of design and code upfront so future switches stay cheap. A thin contract around a queue, a mailer, or a payment processor is the premium. The payoff is changing providers without rewriting every call site, or at least only rewriting one place.
- How do I measure whether an architecture is good?
Ask what a realistic change costs in calendar time and blast radius. Can you swap a mailer without touching GraphQL? Can you add a permission without hunting every role string? If the answer is a multi-week rewrite, something went wrong earlier, regardless of how clean the tree looks.
- Is putting every vendor behind an interface over-engineering?
Not for anything that touches money, identity, messaging, or the primary data plane. Skip it for throwaway scripts. For SaaS core paths the interface is cheap insurance compared to the rewrite after lock-in.
- Why do teams leave Next.js or BaaS stacks later?
Often because product needs outgrew defaults they never treated as commitments. Hosting assumptions, auth models, and data access patterns leak into domain code. Leaving then means rewriting product rules that should have lived behind a thin contract. The tool itself was usually fine on day one.
- Should billing just be Stripe?
Stripe (or any processor) is fine as the speed bet. What fails is treating vendor price ids as your product model. Persist internal product and price rows, resolve external ids at the processor boundary, and keep the payment contract's inputs and outputs in your domain types. Webhook ingress can stay vendor-specific. GraphQL and utilities should not import Stripe.Price.
- Why does architecture need to be legible to AI coding tools?
Architecture that only exists in one person's head fails when that person is out, and again when an LLM proposes an edit that crosses a boundary it can't see. Short hard rules (payment SDK only in the processor implementation, one finalizer for checkout) shrink the context window for humans and models. That isn't anti-AI. AI is infrastructure, and infrastructure needs fences.