← Back to list

9 Rules I Follow Before Writing Any Code

Most developers do not lose time because they type slowly. They lose time because they start implementing before they understand what the…

CodeByUmar in Skill Stuff · 2026-07-14 06:46 · 90 claps · 19.0 min read paywalled
#web-development #software-engineering #software-development #programming #coding
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development

9 Rules I Follow Before Writing Any Code

Most developers do not lose time because they type slowly. They lose time because they start implementing before they understand what the system actually needs.

9 Rules I Follow Before Writing Any Code

9 Rules I Follow Before Writing Any Code

I used to think the fastest developer was the one who opened the editor first.

I was usually one of them.

A ticket would arrive, I would find the nearest related file, and within minutes I would be adding conditions, creating functions, or changing database queries.

It felt efficient until the requirements changed, the edge cases appeared, or the first production failure exposed an assumption nobody had discussed.

The code was rarely the hardest part.

The hard part was discovering what the code should protect.

That was when I started treating the minutes before implementation as part of the engineering work.

Coding Was Not Where Most of My Mistakes Began

For the first few years of my career, I approached software tasks as implementation exercises. If the product team wanted a new subscription rule, I looked for the subscription service. If an endpoint was slow, I searched for an expensive loop. If a customer reported duplicate emails, I opened the notification worker.

That approach occasionally worked.

It also encouraged me to accept the first explanation that matched the visible symptom. I would patch the conditional that failed, add another field to an object, or introduce a helper that made the current case pass.

The solution looked complete because the immediate example worked.

Then the surrounding system reminded me that tickets are compressed descriptions of much larger realities. A “simple” status change affected permissions, billing, analytics, and background jobs. An API retry created duplicate records because the operation was not idempotent. A validation rule existed in the frontend but not in the worker that processed imported files. A supposedly harmless database query slowed down only when one customer accumulated several years of data.

The senior engineers I worked with did not rush toward implementation. Before writing code, they tried to understand the shape of the decision.

They asked what success meant, which constraints could not be violated, where the data came from, how the current system handled similar cases, what would happen during failure, how the behavior would be tested, and whether the change could be reversed.

Their preparation sometimes looked slower than my approach.

Their implementations were usually faster to review, safer to deploy, and easier to modify six months later.

I eventually adopted nine rules that I now follow before I write any meaningful code. I do not apply them with the same level of ceremony to every task. A copy change does not need an architecture session. A payment workflow probably deserves more than a quick scan of the ticket.

The purpose is not to delay implementation.

The purpose is to avoid solving the wrong problem efficiently.

1. I Define the Observable Outcome Before the Implementation

Before writing code, I ask what someone should be able to observe when the work is complete.

That question sounds obvious, but many tickets describe activity rather than outcome.

“Add caching to the dashboard.”

“Refactor the notification service.”

“Handle expired subscriptions.”

“Improve checkout validation.”

Each statement suggests work, but none clearly defines success.

Earlier in my career, I might have interpreted “handle expired subscriptions” by adding a date check inside an API route:

async function getDashboard(req: Request) {
  const user = await userRepository.findById(
    req.user.id
  );

  if (
    user.subscriptionEndsAt &&
    user.subscriptionEndsAt < new Date()
  ) {
    return {
      status: 403,
      body: {
        message: "Subscription expired",
      },
    };
  }
  return dashboardService.load(user.id);
}

The code handles one request path. It does not tell us whether expired users should lose access immediately, retain read-only access, receive a grace period, or keep access through an organization account.

The implementation begins by answering questions that the requirement never resolved.

I now rewrite the task as observable behavior:

A user whose individual subscription has expired cannot create or edit projects, but can still export existing data for seven days. Active organization membership continues to grant full access.

That statement gives the code something concrete to protect.

It also reveals that the problem is larger than one route. The behavior may affect API authorization, background jobs, the interface, and data export.

A useful outcome usually includes three things: the actor, the situation, and the expected result.

For example:

Given a customer with an unpaid invoice older than 30 days,
when they attempt to create a new order,
the API rejects the request with a billing-specific error,
while existing orders remain viewable.

That wording is already close to a test.

It also prevents implementation details from becoming the goal. Caching is not the outcome; lower response time under a defined workload is. Refactoring is not the outcome; making a behavior easier to change or isolate is. Adding a queue is not the outcome; preventing a slow external API from blocking the request is.

In a production system, unclear outcomes create disagreements late in the process. The code may work exactly as written while still failing the product expectation. Reviewers debate implementation because the behavior was never made explicit.

For a tiny internal change, a one-sentence outcome may be enough. The goal is not to produce formal specifications for everything. It is to make sure I can describe success without mentioning the code I plan to write.

Takeaway: Define success as an observable behavior before choosing the mechanism that produces it.

2. I Identify the Rules That Must Never Be Violated

Once I know the desired outcome, I look for invariants: conditions that must remain true regardless of the implementation path.

These rules often matter more than the feature itself.

Suppose the task is to allow customers to apply store credit during checkout. The visible requirement appears simple: subtract credit from the order total.

An inexperienced implementation might do this:

function applyStoreCredit(
  orderTotal: number,
  availableCredit: number,
  requestedCredit: number
): number {
  return orderTotal - requestedCredit;
}

The function ignores several invariants.

The final total must not be negative. The customer cannot spend more credit than they own. Credit should probably use integer minor units rather than floating-point values. The same credit must not be consumed twice if the request is retried.

A safer decision function begins by making those rules explicit:

type CreditApplication = {
  creditUsedInCents: number;
  amountDueInCents: number;
};

function calculateCreditApplication(
  orderTotalInCents: number,
  availableCreditInCents: number,
  requestedCreditInCents: number
): CreditApplication {
  if (
    !Number.isInteger(orderTotalInCents) ||
    !Number.isInteger(availableCreditInCents) ||
    !Number.isInteger(requestedCreditInCents)
  ) {
    throw new Error(
      "Money values must use integer cents"
    );
  }
  if (
    orderTotalInCents < 0 ||
    availableCreditInCents < 0 ||
    requestedCreditInCents < 0
  ) {
    throw new Error(
      "Money values cannot be negative"
    );
  }
  const creditUsedInCents = Math.min(
    orderTotalInCents,
    availableCreditInCents,
    requestedCreditInCents
  );
  return {
    creditUsedInCents,
    amountDueInCents:
      orderTotalInCents - creditUsedInCents,
  };
}

This function still does not solve concurrency or persistence. Two checkout requests could read the same available credit before either writes the deduction.

That reveals another invariant:

A unit of credit can be consumed only once.

Protecting that rule may require a database transaction, row locking, an atomic update, or a ledger model.

The key is that I identify the rule before selecting the technique.

Invariants appear everywhere:

  • An order cannot be shipped before payment is confirmed.
  • A user cannot grant a permission they do not possess.
  • A refresh token can be revoked only once but may be presented multiple times.
  • Inventory cannot fall below zero.
  • A completed invoice should not return to the draft state.
  • A webhook event must not apply the same state transition twice.

These constraints shape architecture. If I discover them after implementation, the current design may make them difficult to enforce.

A common production failure happens when rules are distributed across user interfaces, API handlers, and background workers. One path checks the invariant while another bypasses it. The system accepts a state that no single team intended.

Not every function needs a list of formal invariants. I use this rule when the feature changes money, permissions, lifecycle state, inventory, identity, or other data that becomes expensive to repair.

Takeaway: Write down the conditions that must always remain true before deciding where and how to enforce them.

3. I Trace the Data From Its Source to Its Final Effect

Before changing code, I follow the data.

I want to know where it enters the system, which transformations it passes through, where it is stored, and which downstream behaviors depend on it.

This habit became important after I repeatedly fixed the visible end of a data-flow problem.

Suppose customers report that password-reset links occasionally fail. The email template looks like the obvious place to investigate:

const resetUrl =
  `${config.webUrl}/reset-password?token=${token}`;

It is tempting to modify the URL immediately:

const resetUrl =
  `${config.webUrl}/reset-password?token=` +
  encodeURIComponent(token);

That may be correct, but it is still a guess.

The token could already be corrupted before this line. It may be truncated in the database, altered during queue serialization, decoded twice by the frontend, or replaced when a second reset request invalidates the first.

I now trace the value through the system:

HTTP request
    ↓
token generation
    ↓
database storage
    ↓
queue message
    ↓
email worker
    ↓
URL construction
    ↓
browser request
    ↓
token verification

Then I ask where the value first becomes incorrect.

A small diagnostic test may verify the queue boundary:

type PasswordResetMessage = {
  userId: string;
  token: string;
};

function serializeMessage(
  message: PasswordResetMessage
): string {
  return JSON.stringify(message);
}
function parseMessage(
  payload: string
): PasswordResetMessage {
  const parsed = JSON.parse(payload) as unknown;
  if (
    typeof parsed !== "object" ||
    parsed === null
  ) {
    throw new Error(
      "Invalid password reset message"
    );
  }
  const record =
    parsed as Record<string, unknown>;
  if (
    typeof record.userId !== "string" ||
    typeof record.token !== "string"
  ) {
    throw new Error(
      "Invalid password reset fields"
    );
  }
  return {
    userId: record.userId,
    token: record.token,
  };
}
it("preserves the token through serialization", () => {
  const original = {
    userId: "user_42",
    token: "abc+123/xyz==",
  };

  const restored = parseMessage(
    serializeMessage(original)
  );
  expect(restored.token).toBe(original.token);
});

If this passes, I move to the next boundary.

Tracing data also reveals ownership. Which service is authoritative for subscription status? Does the API calculate invoice totals, or does the billing service? Is a username normalized before storage or during every comparison? Does the frontend send a display value or a stable identifier?

Without a clear source of truth, different components may interpret the same field differently.

This creates subtle production problems. One service treats timestamps as UTC while another assumes local time. An enum gains a new value, but an older worker silently maps it to “unknown.” A database field is updated, yet a stale cache continues driving authorization.

The goal is not to read the entire repository. I focus on the path that carries the value relevant to the behavior.

For isolated utility functions, the path may be one input and one output. For distributed workflows, a quick diagram can save hours of random searching.

Takeaway: Trace important data from entry to effect, and find the first boundary where its meaning or value can change.

4. I Search for Existing Decisions Before Inventing New Ones

Before creating a new pattern, helper, status, or abstraction, I search the codebase for how the system already handles the same idea.

This is not about copying code blindly. It is about avoiding accidental inconsistency.

Suppose I need to add validation for a new invoice endpoint. I could create a local error response:

return {
  status: 400,
  body: {
    error: "Invalid invoice request",
  },
};

That response may work, but the application might already use structured errors:

type ApiErrorResponse = {
  code: string;
  message: string;
  details?: Record<string, unknown>;
};

Other endpoints may return:

{
  "code": "INVALID_AMOUNT",
  "message": "amountInCents must be greater than zero",
  "details": {
    "field": "amountInCents"
  }
}

Creating a different format forces clients to support another convention. It also makes logging, analytics, and documentation less consistent.

Before implementation, I search for:

  • Similar endpoints.
  • Existing domain types.
  • Error conventions.
  • Validation utilities.
  • Database transaction patterns.
  • Logging fields.
  • Retry policies.
  • Queue naming conventions.
  • Test fixtures.
  • Feature-flag usage.
  • Previous migrations involving the same table.

This often reveals design history that the ticket does not mention.

An apparently unnecessary helper may exist because a provider once returned inconsistent status values. A transaction wrapper may attach request metadata. A custom date parser may enforce UTC handling. A strange field name may be part of a public API that cannot be changed casually.

Experienced developers learn that unfamiliar code is not automatically bad code. Sometimes it is accidental complexity. Sometimes it is the fossil of an important production lesson.

I try to understand which one I am looking at before replacing it.

The opposite problem is following existing patterns that should not be repeated. A codebase may contain outdated conventions, duplicated logic, or unsafe practices. Existing code is evidence, not authority.

I compare the pattern with current requirements and ask whether it still serves the system. If not, I document why the new approach differs.

In larger teams, skipping this search creates several ways to perform the same task. One service uses exceptions for validation, another returns result objects, and a third uses nullable values. Each local decision seems reasonable, but the collective system becomes harder to learn.

For a tiny, isolated script, codebase consistency may matter less. In a long-lived application, consistency often delivers more value than a locally elegant invention.

Takeaway: Search for existing domain decisions and conventions before adding another way to express the same idea.

5. I List the Failure Modes Before Designing the Happy Path

I used to design for successful execution and add error handling afterward.

That order produced workflows that looked clean until something failed halfway through.

Consider an order cancellation:

async function cancelOrder(orderId: string) {
  const order =
    await orderRepository.findById(orderId);

    await paymentProvider.refund(
    order.paymentId
  );
  await inventory.restore(order.items);
  await orderRepository.markCancelled(
    order.id
  );
  await emailService.sendCancellation(
    order.customerEmail
  );
}

The happy path is easy to read.

The important questions begin when the first operation succeeds and the second fails.

What happens if the refund succeeds but inventory restoration times out? What happens if the database update fails after both external operations succeed? If the job retries, will it issue a second refund? Should an email failure prevent the order from being marked cancelled?

These are not edge cases added around the workflow.

They are part of the workflow.

Before writing code, I now list likely failure modes:

1. Order does not exist.
2. Order is already cancelled.
3. Order has already shipped.
4. Refund request times out before a response arrives.
5. Provider completes the refund but the response is lost.
6. Inventory restoration fails temporarily.
7. Database update fails.
8. Cancellation email fails.
9. The same cancellation request arrives twice.

That list changes the design.

The payment operation needs an idempotency key. The order needs a cancellation state that distinguishes “requested” from “completed.” Email delivery probably belongs after the durable state transition and should not repeat the refund.

A simplified orchestration might begin like this:

async function requestOrderCancellation(
  orderId: string
) {
  const order =
    await orderRepository.findById(orderId);

  if (!order) {
    throw new Error(
      `Order ${orderId} was not found`
    );
  }

  if (order.status === "cancelled") {
    return;
  }

  if (order.status === "shipped") {
    throw new Error(
      "Shipped orders cannot be cancelled"
    );
  }

  await orderRepository.markCancellationPending(
    order.id
  );

  await cancellationQueue.publish({
    orderId: order.id,
  });
}

The worker can then handle retries and idempotent external operations:

async function processCancellation(
  orderId: string
) {
  const order =
    await orderRepository.findById(orderId);

  if (!order || order.status === "cancelled") {
    return;
  }

  await paymentProvider.refund(
    order.paymentId,
    {
      idempotencyKey:
        `order-cancellation:${order.id}`,
    }
  );

  await inventory.restore(order.items, {
    operationId:
      `order-cancellation:${order.id}`,
  });

  await orderRepository.markCancelled(
    order.id
  );

  await emailQueue.publish({
    type: "order-cancelled",
    orderId: order.id,
  });
}

This code is still incomplete. The exact design depends on the database, provider guarantees, queue behavior, and business rules.

The point is that failure analysis guides the architecture before it becomes expensive to change.

When teams ignore failure modes, production incidents become design sessions under pressure. A retry duplicates a payment. A timeout creates uncertainty about whether an operation succeeded. A supposedly minor email failure blocks a core state transition.

Not every function needs a failure matrix. I apply this rule most carefully to workflows involving external APIs, queues, state transitions, money, file processing, and other operations that can partially succeed.

Takeaway: List how the operation can fail, repeat, or partially complete before designing the path where everything works.

6. I Reduce the Problem to the Smallest Useful Change

Before writing code, I ask how little I can change while still learning something useful or delivering the required behavior.

My earlier instinct was to solve the immediate problem and redesign the surrounding area at the same time.

A bug in payment handling might become a plan to replace the provider integration, rename database fields, introduce new interfaces, rewrite tests, and restructure the order service.

Large changes feel efficient because they promise to finish the cleanup in one pass.

They also combine too many assumptions.

Suppose application code directly depends on a provider-specific response:

const intent =
  await stripe.paymentIntents.create({
    amount: order.totalInCents,
    currency:
      order.currency.toLowerCase(),
  });

await orderRepository.update(order.id, {
  stripePaymentIntentId: intent.id,
  paymentStatus: intent.status,
});

A complete provider abstraction might be useful eventually. It may also be based on guesses about requirements that have not appeared.

A smaller first step can isolate one operation:

type PaymentResult = {
  externalId: string;
  status: "pending" | "succeeded" | "failed";
};

interface PaymentGateway {
  charge(input: {
    orderId: string;
    amountInCents: number;
    currency: string;
  }): Promise<PaymentResult>;
}

One workflow can migrate without touching refunds, reporting, reconciliation, or support tools.

That change creates a seam and produces feedback. The first production use may reveal that the interface needs provider error categories or asynchronous settlement states. Learning that early is cheaper than building a large abstraction around incomplete assumptions.

Sometimes the smallest useful change is not code.

It may be a database query that confirms the suspected data pattern. A log field that reveals which branch production is taking. A failing test that reproduces the bug. A feature flag that separates deployment from release. A script that verifies whether existing records violate a proposed invariant.

Reducing scope improves review quality. A reviewer can understand the behavioral change without also processing unrelated renaming and architecture work. Rollback becomes simpler because fewer concerns move together.

This does not mean I avoid refactoring. It means I separate required behavior from optional structural improvement whenever possible.

Some changes genuinely require coordinated migration. A schema update may need temporary compatibility code across services. Even then, I look for stages that produce observable progress and keep the system operational.

Takeaway: Choose the smallest change that proves the idea, delivers value, or creates a safe boundary for the next step.

7. I Decide How I Will Observe the Change in Production

Before implementation, I ask how I will know whether the new behavior is working after deployment.

Passing tests tells me that the code behaves as expected under the scenarios we wrote. It does not tell me what real traffic will do.

I learned this after deploying changes that were technically successful but operationally invisible. The code produced errors, but the logs lacked customer IDs. A queue consumer slowed down, but we tracked only the total message count. A new cache reduced database traffic while serving stale data, and no metric exposed the mismatch.

Suppose I am adding a retry mechanism for a third-party invoice API:

async function sendInvoice(
  invoice: Invoice
): Promise<void> {
  await invoiceProvider.send(invoice);
}

Adding retries without observability creates uncertainty. A request may succeed on the third attempt, but nobody knows that the provider failed twice. Retry volume may quietly grow until the dependency becomes a bottleneck.

Before implementing, I decide which signals matter:

async function sendInvoice(
  invoice: Invoice,
  requestId: string
): Promise<void> {
  const startedAt = Date.now();

  try {
    await invoiceProvider.send(invoice);
    metrics.increment(
      "invoice_delivery_success"
    );
    metrics.observe(
      "invoice_delivery_duration_ms",
      Date.now() - startedAt
    );
  } catch (error) {
    metrics.increment(
      "invoice_delivery_failure",
      {
        provider: "primary",
      }
    );
    logger.error(
      "Invoice delivery failed",
      {
        invoiceId: invoice.id,
        customerId: invoice.customerId,
        requestId,
        error,
      }
    );
    throw error;
  }
}

The exact tooling is less important than the questions the signals answer.

I may need to know:

  • How often does the new branch execute?
  • How many operations succeed, fail, or retry?
  • Which customer, tenant, or provider is affected?
  • How long does the operation take?
  • Does the new behavior increase queue backlog?
  • Are fallbacks being used more than expected?
  • Did the error rate change after deployment?
  • Can I distinguish validation failures from infrastructure failures?

Observability also affects privacy and security. Useful logs should not include passwords, tokens, session cookies, payment details, or unnecessary personal data. Identifiers should help locate the operation without exposing sensitive contents.

Planning signals before coding usually produces better instrumentation. When observability is added afterward, it often captures whatever values happen to be available rather than the information needed to answer operational questions.

For a small deterministic utility, production metrics may be unnecessary. For distributed workflows, background jobs, external integrations, and high-traffic endpoints, deploying without a way to observe behavior is a preventable risk.

Takeaway: Decide which logs, metrics, and identifiers will prove the change is working before the code reaches production.

8. I Write the Test Cases Before the Function

I do not always practice strict test-driven development, but I often write the test scenarios before the implementation.

The exercise exposes ambiguity early.

Suppose the requirement says users can change their email address after verifying the new one. Before writing the service, I list the important cases:

1. A valid verification token changes the email.
2. An expired token is rejected.
3. A token cannot be used twice.
4. A token created for one user cannot change another user.
5. The new email must not already belong to another account.
6. Repeating the request after success does not corrupt state.

Those tests reveal design requirements.

The token needs an expiration time, user ownership, and consumed state. The email update probably requires a uniqueness constraint. The operation may need a transaction to prevent two accounts from claiming the same address concurrently.

A test can express one rule before the implementation exists:

it(
  "does not allow a verification token to be reused",
  async () => {
    const token =
      await verificationFactory.create({
        userId: "user_1",
        newEmail: "new@example.com",
      });

    await emailChangeService.confirm(
      token.value
    );
    await expect(
      emailChangeService.confirm(token.value)
    ).rejects.toThrow(
      "Verification token has already been used"
    );
  }
);

Another test can define ownership:

it(
  "changes the email only for the token owner",
  async () => {
    const token =
      await verificationFactory.create({
        userId: "user_1",
        newEmail: "new@example.com",
      });

    await emailChangeService.confirm(
      token.value
    );
    const firstUser =
      await userRepository.findById("user_1");
    const secondUser =
      await userRepository.findById("user_2");
    expect(firstUser.email).toBe(
      "new@example.com"
    );
    expect(secondUser.email).toBe(
      "second@example.com"
    );
  }
);

Writing scenarios first keeps tests focused on behavior rather than implementation structure. If I write tests after the code, I am more likely to mirror the functions I created and assert internal calls.

Behavior-first tests also improve communication. A product manager can understand “an expired token is rejected” more easily than a discussion about repositories and adapters. Reviewers can compare the implementation against a visible contract.

Tests do not discover every production condition. Concurrency, infrastructure failures, and unexpected data may require additional analysis. A passing suite is evidence, not proof of correctness.

For trivial functions, writing tests first may not add much value. I use this rule when behavior includes boundaries, lifecycle transitions, authorization, retries, time, or several competing conditions.

Takeaway: List and write the important behavior cases before implementation so the design must satisfy the rule rather than the current code shape.

9. I Plan the Rollback Before I Plan the Release

Before writing a risky change, I ask how we will undo it.

This question changes implementation decisions in useful ways.

A new database field, background worker, authentication rule, or billing flow may be correct in testing and still behave unexpectedly under real traffic. If the only rollback plan is “revert the commit,” the change may not actually be reversible.

Suppose a deployment replaces a legacy subscription-status calculation with a new persisted field:

type SubscriptionStatus =
  | "trial"
  | "active"
  | "past_due"
  | "cancelled"
  | "expired";

A direct migration might backfill the new field and immediately make every request depend on it.

If the backfill contains incorrect assumptions, reverting application code may not restore the original data.

A safer rollout could separate the steps:

function resolveSubscriptionStatus(
  subscription: Subscription,
  usePersistedStatus: boolean
): SubscriptionStatus {
  if (
    usePersistedStatus &&
    subscription.status
  ) {
    return subscription.status;
  }

  return calculateLegacyStatus(
    subscription
  );
}

The team can first write the new value without reading it. Then it can compare the persisted status with the legacy calculation:

const legacyStatus =
  calculateLegacyStatus(subscription);

const persistedStatus =
  subscription.status;
if (
  persistedStatus &&
  persistedStatus !== legacyStatus
) {
  logger.warn(
    "Subscription status mismatch",
    {
      subscriptionId:
        subscription.id,
      legacyStatus,
      persistedStatus,
    }
  );
}

After mismatch rates are understood, a feature flag can move a small percentage of traffic to the new path. If problems appear, the team disables the flag instead of performing an emergency deployment.

Rollback planning may involve:

  • Backward-compatible schema changes.
  • Dual reads or writes during migration.
  • Feature flags.
  • Gradual traffic rollout.
  • Idempotent backfills.
  • Database backups.
  • Versioned events.
  • Compatibility between old and new workers.
  • A clear definition of which data changes are irreversible.

This is especially important in distributed systems. An older service version may still process messages created by the new version. A rollback can fail if the event schema changed without compatibility.

Planning reversibility also forces me to separate deployment from activation. Code can reach production without immediately affecting every user. That distinction reduces pressure and gives the team time to observe behavior.

Not every change needs a sophisticated rollout. A text correction can ship normally. I use this rule for changes involving persistent data, external contracts, security, billing, infrastructure, and workflows that are difficult to repair manually.

Takeaway: Before implementation, decide how the change can be disabled, reversed, or safely contained if production disagrees with your assumptions.

The Principle Behind These Rules

These nine rules are not really about delaying code.

They are about moving important thinking to the point where it is still cheap.

Defining the outcome prevents me from implementing activity instead of behavior. Identifying invariants exposes the rules the system must protect. Tracing data reveals where meaning changes and which component owns the truth. Searching existing decisions avoids unnecessary inconsistency.

Listing failure modes makes partial success part of the design. Reducing scope limits the number of assumptions introduced at once. Planning observability ensures the production system can explain what it is doing. Writing tests first turns vague requirements into executable behavior. Designing rollback keeps mistakes containable.

All of these practices serve the same goal: reduce uncertainty before creating more code that the team must maintain.

I used to believe experienced developers moved quickly because they knew the right implementation immediately.

The best ones I worked with moved carefully because they understood how expensive a confident mistake could become.

They did not try to predict every future requirement. They made the current decision explicit, testable, observable, and reversible.

That preparation rarely looks impressive in a code review. There is no clever algorithm to admire and no giant abstraction to discuss.

But when requirements change, incidents happen, or another developer inherits the feature, the value becomes obvious.

Good engineering begins before the first line of code.

It begins when we decide what must be true, what can go wrong, and how the system will help us understand the result.

Share this with a developer who tends to start coding immediately, and leave a comment with the rule you follow before opening your editor.

Read Next

If you liked this idea, you may enjoy these too:

7 Coding Patterns I Stole From Senior Engineers

Link Here: **Read**

9 Coding Habits I Learned From Senior Engineers

Link Here: **Read**


메타데이터
post_id
086ff773b6fd
slug
9-rules-i-follow-before-writing-any-code-086ff773b6fd
url
https://medium.com/skillstuff/9-rules-i-follow-before-writing-any-code-086ff773b6fd
canonical_url
https://medium.com/skillstuff/9-rules-i-follow-before-writing-any-code-086ff773b6fd
author_url
https://medium.com/@codebyumar
status
ok
fetched_at
2026-07-15 12:46:07