Home / Articles / Seven Questions to Settle Before Writing the First Line of a Feature

This article is published in English.

Seven Questions to Settle Before Writing the First Line of a Feature

A pre-implementation checklist covering the real user problem, completion guarantees, rule ownership, legacy data, retries and concurrency, observability and rollout safety.

2797 words

The fastest way to feel productive on a new ticket is to open an editor and start building: add the endpoint, the column, the component. The trouble is that the first implementation quietly answers every question nobody asked, and those answers harden into schemas, API contracts and tests that are expensive to reverse. This guide walks through seven questions experienced engineers settle before coding, what goes wrong when they are skipped, and how to keep the exercise proportionate so it speeds delivery up instead of slowing it down.

Why the first implementation carries so much weight

When a request lands, starting with code makes an abstract task feel concrete and smaller. Yet the open questions have not gone anywhere. Someone still has to decide who owns a business rule, what "done" means for a multi-step operation, and what happens to records created before the change. If no person decides, the code decides by accident.

That accidental decision is rarely confined to one place. The shape of the first version tends to become the table layout, the response format, the shared helper everyone imports and the state model the UI depends on. Once other code calls it and production data conforms to it, changing course means migrations, compatibility shims and coordinated releases. Spending an hour on the right questions up front is cheap by comparison.

From the outside, this can look like hesitation: reading existing workflows, asking what the user is really trying to achieve, checking how old data behaves, discussing partial failures. In practice it is the same problem-solving the code would have to do anyway, just done while it is still cheap to change your mind.

1. Separate the requested feature from the underlying problem

Tickets often arrive as solutions: add a button, a filter, an export, a new status. The request may be entirely reasonable, but it describes what someone pictures being built, not the frustration or business outcome behind it.

Recovering that underlying need changes what you build. A request for a CSV export might really come from managers who cannot compare weekly numbers across departments. An export helps, but it also creates recurring manual spreadsheet work that a saved report or a scheduled summary could remove altogether. A request for one more status value might reveal that a single field is already overloaded, standing in for payment, approval and fulfillment at once. Adding the value closes the ticket and makes the data model even harder to reason about.

None of this means interrogating every small request or turning a one-line change into a product workshop. The aim is to learn enough about the user's situation to judge whether the proposed change actually improves the outcome. A handful of pointed questions usually suffices:

  • Who cannot get their work done today?
  • What are they currently doing by hand?
  • What decision will this new information support?
  • What becomes possible once the feature exists?

Skip this step and engineers naturally optimize the literal request. The button is clean, the component reusable, the API tidy, and the feature still disappoints because it solves the ticket more precisely than it solves the problem. The point is not to delay coding; it is to make sure the code is the right answer.

2. Define what success means for the whole operation

Many requirements name an action without defining when it is complete. Submitting an application, approving a record, duplicating a project or syncing data all sound simple until several steps are involved and one of them fails.

Take an approval flow that persists the change, writes an audit log row, emits an event and notifies the requester. If the write succeeds and the notification fails, has the approval succeeded? If the user retries, might the record be approved twice? If the audit entry cannot be written, should the status change be undone? If the event is delayed, is the approval finished or pending? These are not implementation trivia. They define what the product promises its users.

The useful exercise is to draw the completion boundary before writing the workflow:

  • Effects that must succeed or fail together, because a partial result would be an invalid state, belong in one transaction.
  • Effects that are valuable but secondary, such as a welcome email, should not decide whether the primary action succeeded. They often belong in a background job with retries and an explicit "pending" record.

The same question shows up in small features. When someone kicks off an export, is it successful once the file exists, the job was accepted, or a link will appear later? When the UI says "Saved", did the server confirm persistence or did only local state change?

Leave this vague and each layer invents its own definition. The UI shows success while the backend is still working, a worker retries something the user already believes failed, and monitoring reports a healthy request even though an essential side effect vanished. Settling the guarantee first tends to simplify the implementation, because every step now has a clear job. It also shapes the response contract: an API that returns "accepted" is different from one that returns "done", and clients need to know which they are getting.

3. Decide which layer owns each decision

A feature can produce correct results and still be unsafe if the decision lives in the wrong layer. Common examples:

  • The frontend hides a button from users without permission, but the API accepts the call when it is made directly.
  • A controller validates a state transition, but a scheduled job calls the underlying service and bypasses the check.
  • A service rejects duplicates, but a maintenance script writes straight to the table.

In each case the rule exists, but not every path is forced through it.

The fix is to find the layer with enough authority to own the rule. The UI can mirror permissions for usability, but it is never the security boundary. A controller is a fine place to validate the shape of an HTTP request, while business rules usually need to sit deeper so background jobs and internal callers get identical behavior. An application-level uniqueness check gives friendly errors, but only a database constraint actually protects the invariant when writes happen concurrently.

Ownership applies to state as well as rules. Filters that must be shareable and survive a refresh are a natural fit for the URL. Temporary input belongs to the form. Permissions and persisted status come from the server, and the client should not rebuild its own version from local guesses.

When authority is unclear, you get coordination code: duplicated checks in several layers, copies of the same value that must be kept in sync, and changes that turn into search-and-replace hunts. A policy update then touches the UI, the controller, the service, a worker and a query or two, with no assurance that every copy still means the same thing. Give each important decision one deliberate owner. Other layers may display, cache, enforce or pass along the result, but they should not redefine it. That lowers both security risk and maintenance cost, because everyone knows where the source of truth lives.

4. Account for the data that already exists

New code is written for the model you want. Production data, however, carries traces of every earlier model too.

Making a field mandatory is easy for records created after the release, but thousands of older rows may not have it. A redesigned status model may describe future workflows well while leaving historical rows stuck in statuses the new code no longer recognizes. A newly mandatory relationship can point at an entity that simply did not exist when older rows were written.

Before adding validation or changing the schema, ask:

  • Can existing records be migrated truthfully?
  • Is a temporary "unknown" state needed?
  • Is this change rewriting history, or only changing future behavior?

Truthfully is the important word. Backfilling every gap with a convenient default can satisfy a NOT NULL constraint while planting false data. If the owning department of an old record was never captured, stamping it with the current department makes queries simpler and historical reports less trustworthy. Sometimes the honest schema has room for values like "unknown" or "legacy", since not knowing is a genuine part of that record's past.

Old shapes also live outside the database. Older clients may still send previous payload formats, scheduled jobs may depend on status values the new flow wants to remove, and reports may interpret columns according to rules that changed long ago.

You do not have to support every past behavior forever, but the migration decision should be explicit. Some data can be transformed safely, some needs manual review, some old clients deserve a compatibility window and others can be retired on purpose. Ignoring the question does not make it disappear. It resurfaces as scattered fallbacks, nullable columns nobody understands, failed migrations and support incidents after deployment. Deciding early gives the team one coherent transition instead of many local guesses.

5. Assume work will be repeated and will compete

Feature descriptions usually imagine one user, one click and one clean sequence: the request arrives once, nothing else touches the record in the meantime, and the response reaches the client. Production offers none of those guarantees.

  • A user clicks again because the page looks stuck.
  • A mobile connection drops after the server finished but before the response arrived.
  • A queue delivers the same message twice.
  • Two administrators approve the same pending item seconds apart.
  • A scheduled job updates a record the user is still looking at in an older version.

The question to ask up front is whether the operation is safe to run more than once and safe to run concurrently. If a repeat is harmless, extra machinery may be unnecessary. If a repeat creates a second payment, invitation, file or stock reservation, the system needs a way to recognize that several attempts represent one logical action.

The toolbox includes idempotency keys, unique constraints, conditional updates, version columns for optimistic locking, transactions and a table of processed message IDs. Which one fits depends on where the risk lives. What never works is "we checked first", as if no other process could act between the check and the write.

Concurrency bugs are especially treacherous because each line looks correct in review. The defect sits in the gap between read and write: two requests read an identical, valid snapshot, each passes its checks, and each records an outcome that should have happened only once. It is far better for the database to reject one of two competing operations with a visible conflict than to store two contradictory truths that someone must later untangle by hand.

6. Plan how the feature will explain itself in production

On your machine you have breakpoints, the ability to repeat an action and fresh memory of the design. In production, the team may get nothing more than a support message saying something did not work.

So imagine the investigation before you write the code. If the operation fails, how will anyone tell which step broke? Can a single request be traced across services? Will the logs show whether the action ran once or three times? Can you distinguish "never started", "in progress", "partially done" and "failed"?

This is not a call to log everything. Unstructured volume makes investigations harder, not easier. Useful observability captures just enough to rebuild the story of a single important operation:

  • A request or correlation ID
  • The resource ID and operation name
  • Duration
  • The state transition that occurred
  • The attempt number
  • A stable error category

Preserve the meaning of failure as well. A failed query should not silently become an empty list. A provider timeout should not discard the fact that the remote side may have completed the work. A broad catch block should not flatten every cause into one generic message before it reaches the logging boundary.

Think about recovery at the same time. Is it safe to rerun a job that failed? Can support see an operation's current state without querying several tables by hand? Can the user safely try again, and can someone give them an accurate account of the outcome?

Opaque features get expensive the moment something goes wrong, and bolting on logging afterwards often comes too late because the relevant context only existed while the operation was running. Designing the evidence up front makes it part of the feature rather than an emergency patch after an incident.

7. Decide how you will prove the change is safe

When tests are written after the code, they tend to mirror its current structure. A helper exists, so the test asserts it was called; a fallback exists, so a test locks it in; a repository is mocked, so the test confirms the mock returned what it was told to. Such tests pass without proving much.

Deciding on the proof first often exposes design weaknesses. If an operation must be idempotent, the test should run it several times. If concurrent approvals of one record must be impossible, the test needs genuinely competing updates. If "not found" and "failed" are different outcomes, the contract must make both observable. If the rule is enforced by a database constraint, no mocked unit test can show that it holds.

That does not mean every feature needs a heavy end-to-end suite. Pick the test level based on which layer actually enforces the guarantee:

  • A pure transformation can be unit tested directly.
  • An API contract usually calls for an integration test.
  • An invariant enforced in the database has to be tested against a real one.
  • A risky migration may call for monitoring, a staged rollout, or a feature flag with a planned removal date.

Reversibility belongs in the same conversation. If the change misbehaves, can it be switched off or rolled back without losing data written in the meantime? Will the previous application version still run after the schema change, or does the migration need an expand-and-contract sequence? Can you release to a small group before everyone depends on it?

If a design is hard to test or hard to reverse, that is often a sign that one operation carries too many responsibilities, or that the change needs a smaller intermediate step. Absolute proof is not the goal, since software always carries uncertainty. What you want is for the critical guarantees to be visible in tests and metrics, and for the dangerous choices to be undoable, so a mistake becomes a lesson rather than permanent damage.

Keeping the pre-work proportionate

These questions are not an argument that planning always beats doing. Overanalysis can stall simple work and produce architecture for risks that will never materialize. A reasonable filter is to focus only on decisions that would be expensive if the code got them wrong: anything touching persisted data, money, permissions, external side effects or public contracts. A copy change or an internal refactor behind a stable interface rarely needs the full list.

In a lightweight form, the checklist fits in a ticket description:

  • The real problem, in one sentence, and who has it
  • What "done" means, and which effects are secondary
  • The single owner of each business rule
  • The plan for existing data and old clients
  • Behavior on retry and under concurrent access
  • What gets logged and how failures are recovered
  • How the guarantee will be tested and how the change can be rolled back

Wrapping up

Once these questions have answers, the code usually becomes pleasantly direct. The state model has fewer impossible combinations, each rule has one home, the database enforces the invariants, responses say whether work finished or was merely accepted, and tests target the guarantee instead of the current arrangement of functions.

That is why careful engineers can look slow at the start of a task and still finish sooner: they refuse to let product ambiguity, legacy data, concurrency and operational blind spots be converted silently into permanent technical decisions. The right moment to address those issues is before the first convenient implementation gains callers, tests and production data. Writing the function is rarely the hard part; deciding what it is allowed to mean is.