One question separates how a junior developer thinks from how a senior thinks.
It is not about knowing more patterns. It is not about years of experience. It is the question that fires first — before any file is opened, before any component is named.
Sit with these three questions until you can answer them on paper.
Not in code. On paper. If you cannot answer them in plain language, you do not understand the domain yet — and any code you write will need to be restructured when the understanding arrives.
-
What is always true here?
These are your invariants. Things that hold regardless of implementation. A teacher always assesses a student. Evidence is always collected. A result is always saved. These become your domain layer — your types, your contracts. The most stable layer of the system. It imports nothing from React, nothing from any framework.
-
What differs across instances of this feature?
These are your variation points. How evidence is captured differs. Which AI analyzes it differs. What the UI looks like differs. These become your discriminated union arms, your pluggable components — the parts that can change without touching the stable core. Adding a new variant touches zero existing files.
-
What is the direction of dependency?
The stable thing must never depend on the unstable thing. Domain must not import from React. Use case hooks must not import components. If the most stable layer depends on the least stable, every sprint that changes the UI breaks the core. Dependencies always point inward — toward stability.
Four questions that produce an architecture — not just a plan.
Fill this in before writing any code for a new feature. Not as a formality — as a design act. If any quadrant is blank, the feature is not ready to build yet. You are missing understanding, not code.
Before putting logic in a component, ask: would I keep this if we rebuilt in Flutter?
If yes — it belongs in the use case layer. It is business logic, and it belongs in a hook that can be tested with renderHook and zero native mocks. If no — it is display logic, and it belongs in the UI layer.
// ❌ Fails the Flutter test — UI makes a decision
if (isFormValid && !isSubmitting) {
submitAssessment(); // component is deciding when to submit
}
// ✅ Passes the Flutter test — UI reports; use case layer decides
onSubmitPressed(); // hook owns the guard logic, the timing, the ref
Every time you write a type, ask: is TypeScript enforcing this, or am I relying on discipline?
Discipline fails. TypeScript does not forget. If correctness depends on every developer always remembering to check a nullable field, that check will eventually be missed — at 2am, in production, on a student's record.
// ❌ Discipline-based — flat bag. TypeScript cannot help here.
type CollectedEvidence = {
audioUrl?: string | null
imageUrl?: string | null // ~40% of fields set by each consumer
selectedLevel?: Level | null // the rest are silently undefined
durationSec: number
}
// ✅ Enforcement-based — discriminated union. TypeScript catches the bug.
type CollectedEvidence =
| { type: 'audio'; audioUrl: string; durationSec: number }
| { type: 'photo'; imageUrl: string; durationSec: number }
| { type: 'teacher'; selectedLevel: Level; durationSec: number }
| { type: 'interactive'; selectedLevel: Level; durationSec: number }
A discriminated union where each arm is complete means TypeScript catches the bug — not QA, not a toast in production, not a teacher whose student was graded on a stale value.
They are not smarter. They have been burned more times.
Architecture is not cleverness. It is accumulated scar tissue, written down so the next person does not get the same wound.
| The shortcut taken | What actually happened |
|---|---|
| Flat nullable bag | Bug at 2am because one consumer didn't fill the field everyone assumed was always set. Manual DB fix. Embarrassment. |
| Business rule in a component | Fixed in 2 of 3 places. The third crashed a real user's session three months later during a demo. |
useState as a submit guard |
Rapid double-tap wrote the same record twice on the same student. Support ticket. Manual cleanup. |
|| on a numeric field |
Valid 0 threshold silently became null. The backend applied its own default. Wrong students were promoted for weeks. |
No response.ok check |
HTTP error silently parsed as JSON. Garbage written to the DB. No error in the logs. Found during an audit. |
reject(string) in Promises |
The actual backend error reason was discarded at the callsite. Teachers saw a generic toast. Nobody knew why saves were failing. |