Oct 08, 2025 - 14 min read
Designing Frontend and Backend Architecture That Can Evolve
A practical framework for boundaries, APIs, deployment shapes, and choosing technology without chasing trends.
I've inherited more architectures than I've designed from scratch, and that turns out to be the more useful vantage point. You learn a lot more from a system three years in — the module nobody wants to touch, the shared table with four owners, the "temporary" synchronous call to a service that's now down every Tuesday — than you do from a greenfield diagram. This piece is written from that side of the table.
The uncomfortable truth about architecture is that you rarely get punished for the decision itself. You get punished for how expensive it is to reverse. A bad choice you can undo in an afternoon is a non-event. A mediocre choice baked into your data model, your deployment topology, or your team's org chart is a multi-quarter tax. So the actual skill being exercised here isn't "pick the right technology" — it's identifying which decisions are load-bearing and which are cosmetic, and spending your scarce attention accordingly.
Start with the shape of the problem, not the shape of the solution
Before any diagram gets drawn, I want answers to a small set of questions, and I want them from people, not from a kickoff deck:
- Who are the actual users, and what are the two or three journeys that, if broken, would generate a page at 2am or a call from a VP?
- What's the real scale — not the pitch-deck scale, the actual p50/p99 request volume and growth curve?
- What are the latency and consistency requirements per journey, not per system? (Checkout has different consistency needs than "view your order history.")
- What compliance or data-residency constraints exist, and do they apply to the whole system or a slice of it?
- What does the team actually know how to operate well at 3am without the person who built it?
- How fast is this domain expected to change relative to the domains around it?
That last question is the one people skip, and it's the one that predicts almost everything else. A domain that changes weekly (pricing rules, promotions, onboarding flows) wants loose coupling and fast, isolated deploys. A domain that changes twice a year (tax calculation, core ledger entries) wants stability and strong guarantees far more than it wants agility. Treating both domains with the same architectural pattern is one of the most common sources of unnecessary complexity I see — either over-engineering the stable core or under-protecting the volatile edges.
A modular monolith is still the strongest default, and I say that as someone who has also shipped plenty of microservices. One deployable system, with domain modules that have enforced boundaries — no reaching into another module's tables, no importing another module's internal types — gives you almost all the organizational benefits of services (clear ownership, testable seams, the option to extract later) without the distributed-systems tax (network partitions, eventual consistency you didn't ask for, a service mesh someone has to operate). You split a module into its own service when it needs to scale independently, deploy on its own cadence, meet a different reliability bar, or be owned by a different team with different on-call boundaries — not because a conference talk made microservices sound inevitable.
If you can't currently draw a hard line around a module inside your monolith — imports, database tables, transaction boundaries — you are not ready to draw that line around a network call either. Extraction should feel like lifting out something already separable, not performing surgery to create separability that never existed.
The boundary map, with the parts people gloss over
This shape is correct and also nearly useless on its own, because the interesting decisions live in what's not drawn:
- What crosses the edge boundary, and why. Is the CDN doing static asset caching only, or is it doing edge auth, request shaping, and A/B routing? Every piece of logic you push to the edge is logic that now has two implementations to keep in sync, or one implementation your origin can't reason about independently.
- What the API boundary actually promises. Not "REST vs GraphQL" — I mean the contract: versioning strategy, backward-compatibility guarantees, what happens when a field is deprecated, who owns schema evolution. A boundary without a stated compatibility policy isn't a boundary, it's a suggestion.
- What's synchronous vs. queued, and what that implies for user-facing latency and failure semantics. Every arrow that goes through a queue is an arrow where the caller has explicitly accepted "eventually" instead of "now." Make sure that acceptance was a decision, not a default.
- Where observability actually lives vs. where it's aspirational. Logs, metrics, and traces "everywhere" on a diagram usually means well-instrumented in the two services someone cared about and silent everywhere else. The workers behind the queue are disproportionately the ones nobody instruments, and disproportionately the ones that fail silently in production.
Frontend: separate the four kinds of state before you separate anything else
The framework choice — React/Next.js, Vue/Nuxt, Angular, SvelteKit — matters less than people arguing about it would suggest. Rendering needs, accessibility requirements, ecosystem maturity, and what your team can hire for and operate confidently are the real deciding factors, not benchmark charts from a framework's own marketing site.
What actually determines whether your frontend stays maintainable is whether you keep these four categories of state genuinely separate:
- Server state — data that lives on the backend and that your frontend is caching a view of. This has its own lifecycle: staleness, refetching, optimistic updates, cache invalidation. Treat it as a cache, not as application state, and use a tool built for that job (React Query, SWR, or equivalent) rather than hand-rolling it into a global store.
- URL state — anything that defines "what page/view am I on," which should be shareable, bookmarkable, and survive a refresh. Filters, pagination, selected tab. If a user can't share a link and land exactly where they were, this state is in the wrong place.
- Form state — transient, local, and validated independently of both of the above. It becomes server state only at submission.
- Ephemeral UI state — is this dropdown open, is this modal visible. This should never leak into a global store; it's component-local by definition.
Almost every "our frontend state management is a mess" conversation I've been in turns out to be these four categories collapsed into one global store, with server data, URL params, and modal-open booleans all fighting for the same shape. Untangling that after the fact is expensive. Keeping them apart from day one is nearly free.
Backend: contracts and failure semantics beat language choice
Node/TypeScript reduces context-switching for teams that are already JS-heavy on the frontend. Java/Kotlin and .NET remain excellent choices for mature enterprise domains with existing platform investment. Go suits lean, high-throughput network services. Python still wins where the domain is data- or ML-heavy. None of these choices matters nearly as much as what you do within whichever one you pick:
- Contracts. REST remains the sane interoperable default for most service-to-service and client-to-service communication. GraphQL earns its complexity when you have genuinely varied clients with different data-shape needs — it is not a default, it's a response to a specific composability problem. Events decouple work that legitimately doesn't need to complete within the request lifecycle — but "we made it async" is not automatically an architectural improvement if the caller actually needed to know the outcome.
- Idempotency. Any endpoint that can be safely retried should be designed to be safely retried — idempotency keys on writes, not just reads. Networks fail, clients retry, and the default behavior of a non-idempotent POST under retry is silent data corruption, not a clean error.
- Timeouts and retries with jitter, designed together, not bolted on separately. A retry policy without a timeout budget just means your system falls over slower and with more log noise. Decide the end-to-end latency budget for a user journey first, then divide it across the calls in that path, then set timeouts and retry counts to fit inside that budget — not the other way around.
- Graceful degradation as a designed behavior, not an accident. What does the checkout page do when the recommendations service is down? If the honest answer is "the whole page 500s," that's a coupling problem disguised as a reliability problem.
- Authorization at the boundary, not scattered through business logic. Every service that reimplements "is this user allowed to do this" locally is a service that will eventually get it wrong in a way that's hard to audit.
Default to a relational database. Add caches, search indexes, or document stores when you have a measured access pattern that the relational store genuinely struggles with — not because a document store sounded like a better fit for "flexible" data at the design stage. Flexibility at write time is usually a tax you pay in consistency and query complexity at read time, and you rarely see that bill until much later.
Recording the decisions, not just making them
The single highest-leverage habit I've picked up is writing short Architecture Decision Records for anything expensive to reverse: chosen boundary, chosen contract style, chosen consistency model, chosen deployment shape. Not a design doc — a paragraph. Context, decision, what you gave up, and what would make you revisit it. Six months later, when someone (often you) asks "why is it like this," the answer shouldn't require reconstructing a Slack thread from memory. It should take thirty seconds to read.
The actual test of good architecture
Good architecture isn't the one that looks impressive in a diagram or name-drops the newest pattern. It's the one where, eighteen months from now, a mid-level engineer can trace a request from browser to database without a tour guide, extend a module without touching three unrelated ones, and where the boundaries you drew on day one still make sense — because they were drawn around how the business changes, not around what was fashionable to build with at the time.
Boring, observable, and reversible beats clever almost every time. The teams I've seen move fastest two years in are the ones who resisted the urge to look sophisticated in year one.