All articlesShoaib
Engineering perspective//6 min read

How I Approach System Design Before Writing Code

A practical sequence for turning an ambiguous product request into a system that can be discussed, tested, and changed.

System designProduct engineeringCommunication
How I Approach System Design Before Writing Code

Start with the irreversible decisions

Before choosing a framework, I write down the data that must not be lost, the actions that must be auditable, and the parts of the system that can be eventually consistent. Those constraints remove more ambiguity than a long list of technologies.

Sketch the pressure points

I draw the request path, the write path, and the recovery path. The recovery path is usually where the real design lives: retries, partial failure, duplicate messages, and a user who closes the browser at exactly the wrong moment.

Keep the first version honest

A good first design has explicit seams for growth without pretending to be a distributed system on day one. I prefer a boring deployable slice with observable boundaries over a beautiful architecture diagram that no one can operate.

The final artifact is not the diagram. It is a shared vocabulary for making the next decision.

The sequence I use

I start by writing the user action in one sentence and listing what must be true when it finishes. Then I identify the source of truth, the actors allowed to change it, the expected volume, and the failure that would be most expensive. Only after that do I draw components.

flowchart TD
  A[User outcome] --> B[Invariants]
  B --> C[Source of truth]
  C --> D[Commands and reads]
  D --> E[Failure and recovery paths]
  E --> F[Smallest deployable slice]
  F --> G[Measurements that prove it works]

This sequence prevents a common mistake: designing the shape of the system before agreeing on the behavior it must protect.

Make assumptions explicit

Suppose a product request says that an invoice should be generated instantly. That sentence hides several questions. Is instant measured from the click or from the external accounting service? Can the user retry? Is a duplicate invoice dangerous? What happens if the browser closes after the server accepts the request?

I capture those assumptions in the design itself. A request ID, a durable status, and a visible pending state are not implementation details if they define what the user experiences during a failure.

type WorkflowStatus = "queued" | "running" | "completed" | "failed";

type Workflow = {
  id: string;
  status: WorkflowStatus;
  requestedAt: string;
  completedAt?: string;
  failureReason?: string;
};

function canRetry(workflow: Workflow) {
  return workflow.status === "failed" && !workflow.failureReason?.includes("permission");
}

Choose boundaries that can be tested

Each boundary should have a contract that can be exercised without deploying the entire system. For a backend, that may be a command handler with a database transaction. For a frontend, it may be a route that receives a stable view model. For an integration, it may be a fake provider that returns timeout, duplicate, and malformed responses.

Review the design with scenarios

I review the happy path, duplicate submission, expired authorization, dependency timeout, partial completion, and migration from the previous version. If the design cannot explain one of those scenarios, it is not finished; it is only visually tidy.