← All posts

Software Engineering14 min read

From Frontend to Backend: Learning to Think in Systems

Moving from React and Next.js to backend development with NestJS changed how I approach software. I explore why reliable applications require more than well-written components and endpoints — they require thinking about contracts, state, failure, and the system as a whole.

Abstract technology components representing the connection between frontend and backend systems.

For a long time, it was natural for me to think about software in terms of the part I was building.

When working on the frontend, I focused on components, layouts, state management, API integration, and user experience. I wanted interfaces to be responsive, interactions to feel intuitive, and the application to behave as users expected.

Working with React and Next.js gave me a solid foundation for thinking in components. I learned to break interfaces into smaller, reusable pieces, manage state, and structure applications so that they remained maintainable as they grew.

But as I spent more time working with backend technologies like NestJS and TypeScript, my perspective began to expand.

I started thinking less about individual features and more about the systems behind them.

How does data move through an application? What happens when a request fails? Where should a particular responsibility live? How do different services communicate without becoming tightly coupled? And what happens when the system behaves in ways I didn't anticipate?

These questions changed how I think about building software.

The frontend is only one part of the experience

One of the things I appreciate about frontend development is how close it brings me to the user.

Every decision has a visible consequence. A poorly structured component can make a feature difficult to maintain. An inefficient rendering strategy can affect performance. A confusing interaction can make an otherwise useful product frustrating to use.

With React, I learned to think carefully about component boundaries, state, and the relationship between data and the UI.

Next.js introduced additional architectural considerations, including server-side rendering, static generation, routing, and the distinction between server and client components.

These decisions matter because a frontend isn't simply a collection of screens. It is a system for presenting information, collecting user input, and helping people accomplish tasks.

However, the interface can only be as dependable as the systems it relies on.

Consider a simple payment interface. A user enters an amount, selects a payment method, and clicks a button. The frontend might validate the input, disable the button, display a loading indicator, and eventually show a success message.

From the user's perspective, this is one interaction.

Behind the scenes, it may involve an API request, business-rule validation, a payment provider, database updates, transaction verification, and notifications.

A button that works correctly doesn't necessarily mean the payment flow is reliable.

The request might time out after the provider has already processed the payment. The user might click twice. A response might arrive later than expected. The application might receive an ambiguous result and need to determine the actual transaction status.

The frontend is responsible for communicating the state of the operation clearly. The backend is responsible for enforcing business rules and coordinating the operation safely.

Neither can solve the entire problem alone.

That is where thinking in systems becomes important.

Moving from components to responsibilities

React encourages component-based thinking. A well-designed component has a clear purpose, a manageable interface, and a limited set of responsibilities.

I find a similar principle useful in backend development, although the boundaries look different.

In NestJS, modules, controllers, providers, and services give me ways to organize an application around distinct responsibilities.

A controller can handle an incoming request. A service can implement business logic. A repository or data-access layer can manage persistence. A separate integration service can communicate with an external provider.

The goal isn't to create as many classes as possible. It is to make responsibilities clear enough that changes can be made without creating unnecessary dependencies elsewhere.

For example, imagine an application that allows users to purchase a subscription.

A poorly structured implementation might put request validation, payment initiation, database updates, provider communication, and response formatting into one controller method.

It might work initially. But as the feature grows, changing one part becomes increasingly risky because the entire operation is tightly coupled.

A more deliberate design separates the concerns:

subscriptions.controller.ts
@Post()
async createSubscription(
  @Body() dto: CreateSubscriptionDto,
) {
  return this.subscriptionsService.create(dto);
}

The controller delegates the operation to a service that coordinates the business process:

subscriptions.service.ts
import { Body, Controller, Post } from '@nestjs/common';

@Controller('subscriptions')
export class SubscriptionsController {
  constructor(
    private readonly subscriptionsService: SubscriptionsService,
  ) {}

  @Post()
  async createSubscription(
    @Body() dto: CreateSubscriptionDto,
  ) {
    return this.subscriptionsService.create(dto);
  }
}

This is an illustrative example rather than a complete payment implementation. A real subscription flow would also need appropriate authorization, server-side price calculation, transaction persistence, provider-response handling, and verification before granting the paid entitlement.

Still, the separation demonstrates an important principle: each part should have a clear responsibility, and the application should make the relationships between those responsibilities explicit.

I apply the same principle on the frontend. I don't want every component to know how authentication works, how an API request is constructed, or how every business rule is implemented.

Good architecture, whether on the frontend or backend, makes it easier to understand where a responsibility belongs.

TypeScript helps, but types are not the whole story

Working with TypeScript across React, Next.js, and NestJS has reinforced the value of making assumptions explicit.

Types help define the shape of data, describe function contracts, and catch many mistakes before runtime. They also make code easier to navigate, especially when multiple developers are working on the same application.

Consider an API response that returns a transaction:

typescript
type TransactionStatus =
  | 'pending'
  | 'successful'
  | 'failed';

interface Transaction {
  id: string;
  amount: number;
  status: TransactionStatus;
}

This is more informative than representing the status as an unrestricted string. It tells other developers which values the application expects.

But there is an important distinction between describing data and proving that the data is valid.

A TypeScript interface does not validate an incoming HTTP request at runtime. It does not guarantee that a third-party API will return the expected structure. It does not ensure that an amount supplied by a client is trustworthy.

External data needs to be validated at the appropriate boundaries.

In NestJS, that might mean using DTOs with class-validator and a configured ValidationPipe. It can also mean validating provider responses before using them, depending on the integration.

On the frontend, it means treating API responses as external inputs rather than assuming that the UI's TypeScript definitions guarantee their correctness.

This distinction becomes especially important when the same data crosses several layers.

A value may be correctly typed inside one function and still be invalid when it arrives from a network request. A client may send a validly formatted amount that violates a business rule. A response may contain a valid transaction ID but represent a transaction that has not actually succeeded.

Types help me describe what should happen. Runtime validation and business logic help ensure that what actually happens follows those expectations.

State exists beyond the interface

Frontend development taught me to pay attention to state: where it lives, who owns it, and how changes affect the interface.

With React, I think about local component state, shared state, server state, and how unnecessary updates can affect rendering. The choice between local state, Context, or a library such as Zustand depends on the problem rather than on a desire to use a particular tool.

Backend development introduced me to a different dimension of state.

A backend may need to manage the lifecycle of an order, subscription, payment, or background job. That state can persist across requests, survive application restarts, and be affected by multiple processes.

Consider a payment transaction. Its lifecycle might include:

  • pending: the transaction has been initiated.
  • successful: the transaction has been verified as successful.
  • failed: the transaction has been confirmed as unsuccessful.

These states are not merely labels for a frontend badge. They represent business facts that determine what the application is allowed to do.

For example, a subscription should not become active merely because a payment initiation endpoint returned successfully. Initiation means that the process has started, not necessarily that the money has been received.

The application needs a reliable verification step before granting the entitlement.

This is also why state transitions matter. If a transaction has already been marked successful, a delayed or duplicated callback should not accidentally cause the system to process the payment again.

The frontend can display a transaction's status, but the backend must be responsible for enforcing the rules that govern it.

Thinking in systems means understanding not only what state a feature has, but also who is allowed to change it, under what conditions, and with what consequences.

Reliability begins where the happy path ends

It is relatively easy to design a feature around the assumption that every request succeeds.

The user submits a form, the server processes it, the database responds, and the UI displays the result.

Real applications cannot rely on that sequence always working.

Networks fail. External services become unavailable. Requests time out. Database operations encounter errors. Users repeat actions when they don't receive an immediate response.

These are not unusual exceptions that can be ignored until later. They are part of the environment in which software operates.

This is particularly relevant when integrating external services or working with asynchronous processing.

For example, a payment verification task may need to be retried if a provider is temporarily unavailable. A queue consumer may receive the same message more than once. A request may have reached an external service even though the calling application never received its response.

Each situation requires a deliberate decision.

Should the operation be retried? Is it safe to execute again? Should the transaction remain pending? Should the application alert an operator? What information is needed to investigate the failure?

A few concepts become particularly important here.

Idempotency helps ensure that repeating an operation does not produce unintended duplicate effects. Bounded retries with backoff can help recover from temporary failures without overwhelming a dependency. Logging and monitoring help reveal what happened when the result isn't obvious.

These techniques solve different problems, and none is a substitute for understanding the business operation itself.

A retry policy that works for a temporary network error may be inappropriate for a permanent validation failure. A dead-letter queue can preserve messages that repeatedly fail processing, but it does not automatically resolve the underlying problem. A log can record that a request failed, but useful observability also requires enough context to understand where and why it failed.

The goal isn't to eliminate every failure. It is to design software that handles failures predictably, makes its state understandable, and provides a safe path to recovery.

That is a different mindset from simply ensuring that the successful path works.

The boundaries between services matter

As applications grow, they often depend on more than one component or service.

A Next.js frontend might communicate with a NestJS API. The API might communicate with a payment provider and a relational database. Background work might be processed asynchronously through a message broker such as Kafka.

Each additional boundary introduces a different kind of responsibility.

The frontend and backend need an agreed API contract. The backend and database need a consistent persistence strategy. An external provider needs authentication and correct request handling. A message producer and consumer need to agree on the structure and meaning of messages.

These boundaries deserve attention because the assumptions on one side are not automatically guaranteed on the other.

For instance, a frontend might expect a request to return a completed result immediately, while the backend is designed to accept the request and process it asynchronously. Both implementations can be reasonable, but the contract must make that distinction clear.

The frontend may need to show a pending state and poll for an update, subscribe to notifications, or retrieve the result later.

Similarly, a backend service that publishes a message to Kafka cannot assume that publishing a message means every downstream operation has completed. The consumer may process it later, encounter an error, or need to retry it.

The system's behavior depends on how those boundaries are designed.

This has changed how I approach integrations. I try to understand not only the request and response formats, but also the lifecycle of the operation, the possible failure modes, and the guarantees each component actually provides.

Good integration work is not simply connecting two APIs. It is making their assumptions compatible.

Architecture is about trade-offs, not fashionable tools

Learning different parts of the stack has also made me more conscious of trade-offs.

It can be tempting to treat a particular framework, state-management library, database, or messaging system as the answer to a broad category of problems. But technology choices only make sense in the context of the requirements.

A small application may not need a distributed architecture. A synchronous request may be perfectly adequate for an operation that completes quickly and reliably. A queue becomes useful when asynchronous processing, buffering, or independent consumers solve a real problem.

Likewise, server-side rendering in Next.js is not automatically better than client-side rendering for every page. A shared state store is not necessary for every piece of UI state. Breaking an application into many services introduces operational complexity that may outweigh the benefits at a smaller scale.

I find it more useful to start with questions than with tools.

What does the feature need to guarantee? How much traffic should it support? What happens when a dependency is unavailable? Does the operation need to complete immediately? What data must remain consistent? How difficult will the design be to test, debug, and maintain?

The answers help determine which tools and patterns are appropriate.

This approach also makes it easier to explain architectural decisions. Instead of saying that a system uses a queue because queues are scalable, I should be able to explain what problem asynchronous processing solves and what complexity it introduces.

The best design is not necessarily the most sophisticated one. It is the simplest design that satisfies the requirements while leaving room for the system to evolve.

Testing the system, not just the functions

Thinking in systems has implications for testing, too.

Unit tests are valuable because they let me verify individual pieces of logic in isolation. I can test a service's business rules, confirm that invalid input is rejected, or verify that a particular state transition is handled correctly.

But a collection of passing unit tests does not prove that the application behaves correctly as a whole.

An API might return the expected response in isolation while the frontend sends a payload with the wrong shape. A payment integration might pass a mocked test while the real provider returns an unexpected response. A queue consumer might process a valid message correctly but fail to handle duplicates.

Different tests answer different questions.

  • Unit tests verify individual pieces of logic.
  • Integration tests verify interactions between components such as services and databases.
  • End-to-end tests exercise important user journeys across application boundaries.
  • Failure-path tests examine what happens when dependencies fail, inputs are invalid, or operations are repeated.

The objective is not to test everything at every level. It is to identify the risks that matter and choose the right level of verification for each one.

For a payment flow, I would want to test more than whether the initiation endpoint returns a successful HTTP response. I would also want to verify that an unconfirmed payment does not activate a subscription, that duplicate verification events do not produce duplicate effects, and that failures leave the transaction in a recoverable state.

This is where the frontend and backend perspectives come together. A complete user journey crosses several layers, and testing should reflect the guarantees those layers are expected to provide.

What thinking in systems means to me now

Working across React, Next.js, TypeScript, and NestJS has not made me think that frontend development is less important than backend development, or that one side of the stack is inherently more difficult than the other.

They require different kinds of attention, but they are connected by the same engineering principles: clear responsibilities, explicit contracts, predictable behavior, and deliberate handling of complexity.

The difference is in the scope of the questions I ask.

When building a frontend feature, I still care about component design, rendering performance, accessibility, and user experience. But I also think about the quality and consistency of the data the interface receives, the loading and failure states it must represent, and the assumptions it makes about the backend.

When building a backend feature, I care about business logic, validation, persistence, and API design. But I also think about how the frontend will consume the contract, how users will understand intermediate states, and how the application will recover when an operation doesn't finish as expected.

And when those components interact, I think about the system that emerges from their relationships.

That is the shift I find most valuable: moving from asking whether a piece of code works to asking whether the feature behaves correctly within the larger application.

A component can be correct while the integration is wrong. An endpoint can return the expected response while the business operation remains incomplete. A service can work under normal conditions while leaving the system in an inconsistent state when a dependency fails.

Thinking in systems means learning to recognize those gaps before they become production problems.

It is also an ongoing process. Every new integration, architectural decision, and unexpected failure provides another opportunity to understand how the pieces fit together.

I don't think becoming a better engineer means knowing every tool or mastering every layer of the stack. It means developing the judgment to understand the problem, identify the important constraints, choose an appropriate design, and reason about what happens beyond the code immediately in front of me.

The more I work across the stack, the more I realize that good software engineering is not just about building individual parts well. It is about making those parts work together reliably.

And that is what learning to think in systems means to me.


Thanks for reading. I write about software engineering, backend architecture, frontend development, and the lessons I learn while building products.

← Back to the blog