How Does a Senior Front-End Developer Think?
A developer may decide to create:
UserApiClientUserRepositoryInterfaceHttpClientInterfaceFetchHttpClientUserRepositoryImplementationUserServiceUserMapperUserDTOUserEntityGetUserUseCaseUserServiceFactory
Is this always wrong?
No.
It may be justified in a large system with:
- Multiple data sources.
- Offline support.
- A complex domain.
- A requirement to replace the transport layer.
- Independent domain testing.
- Large teams and strict boundaries.
However, in a small dashboard with one API provider, this may be complexity without sufficient value.
Instead of opening one file to understand how the user is loaded, a developer may need to navigate through ten layers to find where the user’s name comes from.
Premature abstraction
One of the most common causes of overengineering is creating abstractions before fully understanding the problem.
A developer sees two similar lines and immediately creates a generic utility.
A developer sees two similar components and merges them into a single reusable component with many configuration options.
A developer sees two forms and builds a dynamic form engine.
A developer sees two tables and builds a universal table component.
A developer sees two modal windows and builds a modal framework capable of handling every possible scenario.
Over time, the supposedly reusable component may look like this:
<DataTable enableSelection disableSelectionOnMobile enableExpandableRows disableExpansionForDisabledItems useRemotePagination enableStickyHeader renderCustomHeader useLegacySorting preserveQueryState enableConditionalActions />
At this point, the team no longer has a reusable component.
It has created an internal framework that requires its own documentation, testing, and maintenance.
The Rule of Three
A useful guideline for avoiding premature abstraction is the Rule of Three:
Do not extract an abstraction at the first sign of duplication. Wait until the real pattern becomes clear.
A common interpretation is:
- The first time: write the solution.
- The second time: notice the similarity.
- The third time: evaluate whether abstraction is justified.
This is not a strict law, but it is an important reminder that temporary duplication may be cheaper than an incorrect abstraction.
Duplication is not always the worst outcome.
Sometimes two clear implementations are better than one highly configurable component filled with conditions.
When reuse becomes harmful
Reuse becomes harmful when it connects pieces of code that change for different reasons.
Imagine that you have:
- A product card.
- An employee card.
Both initially contain:
- An image.
- A title.
- A description.
- A button.
It may seem reasonable to create a generic card component.
However, the product card may later require:
- Price.
- Discount.
- Stock status.
- Rating.
- Add-to-cart behavior.
The employee card may later require:
- Job title.
- Department.
- Employment status.
- Contact information.
- Profile actions.
Although the components initially looked similar, they belong to different domains and evolve for different reasons.
Forcing them into a single abstraction may produce a component filled with conditional logic.
A better principle is:
Do not create reuse based only on visual similarity. Reuse should be based on shared responsibility and shared reasons for change.
Signs of overengineering
1. Simple flows are difficult to trace
If understanding a button click requires opening a large number of files, the project may have too many layers.
2. Many files contain only one or two meaningful lines
File separation is not a goal by itself.
3. Many interfaces have only one implementation
Interfaces are not inherently bad, but they should have a reason, such as:
- Multiple implementations.
- Clear module boundaries.
- Testability.
- Isolation from an external dependency.
4. Generic components contain too many conditions
A growing number of Boolean properties is often a sign that the component owns too many responsibilities.
5. Simple features require long explanations
If a new developer needs an entire day to understand where to add one field, something may be wrong.
6. The system solves problems that have not happened
Examples include:
- Supporting five backend providers when only one exists.
- Supporting ten themes when only one is planned.
- Building a plugin system without plugins.
- Supporting several languages in a single-language internal tool without a roadmap for localization.
7. Small changes are unexpectedly expensive
If changing a label requires modifying six layers, the abstraction has become a burden.
How to avoid overengineering
1. Start with the direct solution
Begin with the simplest clear implementation.
Do not begin with an imagined final architecture.
Start with what the feature needs today, and allow the architecture to evolve as the team understands the problem better.
2. Add complexity gradually
An API layer may begin like this:
export async function createOrder(payload: CreateOrderPayload) { return api.post("/orders", payload); }
As real needs appear, the team may add:
- Validation.
- Data mapping.
- Retry logic.
- Caching.
- Error normalization.
- Logging.
- A repository layer.
There is no need to add everything on the first day.
3. Do not apply a pattern simply because you recently learned it
After learning design patterns, developers often feel motivated to use them everywhere.
A pattern is not a badge that proves experience.
It is a common solution to a common problem.
If the problem does not exist, the solution is unnecessary.
4. Prefer readable code over clever code
The following implementation may look concise and sophisticated:
const result = items.reduce( (acc, item) => ({ ...acc, [item.type]: [...(acc[item.type] ?? []), item], }), {} );
However, it may be less readable for the team and may also create unnecessary objects.
A more direct version may be easier to understand:
const result: Record<string, Item[]> = {}; for (const item of items) { if (!result[item.type]) { result[item.type] = []; } result[item.type].push(item); }
The goal is not to use the shortest syntax.
The goal is to reduce the mental effort required to understand the code.
5. Monitor cognitive load
Every feature should have a reasonable cognitive cost.
If a small change requires understanding:
- An event bus.
- Dependency injection.
- The repository pattern.
- The command pattern.
- A state machine.
- Custom middleware.
- A custom internal framework.
The team should ask whether all these concepts are truly necessary.
Part Three: How to Conduct Code Reviews Without Creating Conflict
Code review is not a trial
Code review is not a place to prove who is the strongest developer.
It is not an examination of the pull request author’s intelligence.
It is not an opportunity to rewrite the implementation according to the reviewer’s personal style.
The purpose of code review is to:
- Protect system quality.
- Discover defects.
- Share knowledge.
- Improve readability.
- Reduce risk.
- Maintain consistency.
- Develop team skills.
- Confirm that the implementation meets business requirements.
When code review becomes personal conflict, the team loses one of its most valuable quality practices.
What should be reviewed?
Code review should not focus only on syntax.
It should evaluate several dimensions.
1. Correct behavior
Ask:
- Does the code meet the requirement?
- Are important edge cases handled?
- What happens when the API fails?
- What happens on a slow connection?
- What happens when the data is empty?
- What happens if the user clicks twice?
- Are permissions enforced?
- Is there a race condition?
2. Readability
Ask:
- Are names clear?
- Does each function have one understandable responsibility?
- Is there unnecessary complexity?
- Can the flow be followed easily?
- Do comments explain important reasons?
- Are comments merely repeating the code?
3. Architecture
Ask:
- Is the code placed in the right module?
- Does the component contain too much business logic?
- Is there unnecessary coupling?
- Does the change violate module boundaries?
- Is existing logic being duplicated?
- Does the solution align with the project’s architecture?
4. Performance
Ask:
- Are there unnecessary renders?
- Is data requested more than once?
- Is a large list rendered without virtualization?
- Are expensive operations performed during rendering?
- Are images properly optimized?
- Is optimization actually required?
5. Security
Ask:
- Is sensitive information exposed in the front end?
- Is the implementation relying on hiding a button instead of backend authorization?
- Is HTML being injected unsafely?
- Is the authentication token stored dangerously?
- Are logs exposing private information?
6. Testing
Ask:
- Are critical behaviors protected by tests?
- Do tests validate behavior rather than implementation details?
- Are important edge cases covered?
- Are the tests stable and understandable?
How to write a useful code review comment
Explain the reason
A weak comment:
Change this.
A stronger comment:
Consider moving the pricing calculation outside the component because it represents business logic. Keeping it here will make it harder to reuse and test independently.
The second comment explains the reason and helps the author make better decisions in the future.
Separate blockers from suggestions
Not every comment has the same importance.
Teams may use labels such as:
- Blocker: Must be fixed before merging.
- Important: Significant issue that should be discussed.
- Suggestion: Optional improvement.
- Nitpick: Minor stylistic observation.
- Question: Request for clarification.
- Praise: Positive feedback on a strong decision.
Example:
Suggestion: We could move this logic into a custom hook to reduce the component’s responsibilities, but the current implementation does not need to block the merge.
This prevents the pull request author from treating every comment as a mandatory order.
Ask before assuming
Instead of saying:
This is wrong. Use Context.
Ask:
Was there a reason for passing this data through four component levels instead of using Context? Are we intentionally keeping the dependency local to the feature?
The author may have a valid reason that is not immediately visible.
Questions open conversations.
Judgments often close them.
Discuss the code, not the person
An unhealthy comment:
You always make things too complicated.
A healthy comment:
This abstraction adds several levels of indirection for a single use case. Could we start with a direct implementation and extract the abstraction if a second case appears?
Do not connect code quality with a person’s intelligence or competence.
Code can be changed.
Personal attacks damage trust within the team.
Do not turn code review into personal preference
There is a difference between:
- A real defect.
- A violation of an agreed team standard.
- A personal preference.
For example:
if (!user) return null;
Compared with:
if (user === null) { return null; }
If the project has no explicit rule, a pull request should not be blocked because of an individual preference.
Tools should handle decisions that can be automated:
- ESLint.
- Prettier.
- TypeScript.
- Automated tests.
Machines should handle formatting and deterministic rules.
Human review should focus on behavior, architecture, risk, and clarity.
The responsibility of the pull request author
Code review is a shared responsibility.
The author should make the change easy to review.
A useful pull request should contain:
- A clear title.
- A description of the problem.
- A summary of the solution.
- Screenshots or videos for UI changes.
- Testing instructions.
- Known risks.
- Decisions that require discussion.
- A link to the task or requirement.
- A clear explanation of what is outside the scope.
Keep pull requests as small as reasonably possible
Reviewing 300 lines is easier than reviewing 4,000 lines.
Very large pull requests often lead to:
- Superficial reviews.
- Loss of focus.
- Missed defects.
- Difficult testing.
- Difficult rollback.
- Slow merging.
- More merge conflicts.
This does not mean splitting changes artificially.
It means dividing work into logical, reviewable units.
For example:
- Introduce the API client.
- Add state management.
- Add the user interface.
- Add tests.
- Enable the feature.
How should teams handle disagreement?
Return to the goal
When two solutions compete, ask:
- Which solution is clearer?
- Which solution has less risk?
- Which solution is easier to maintain?
- Do we already have an established pattern?
- What are the performance requirements?
- What are the time constraints?
- Can we measure the outcome?
- Is the decision reversible?
The discussion should not be:
My way is better.
It should be:
Which option serves the project more effectively?
Use small experiments
If the disagreement concerns performance, measure both approaches.
If the disagreement concerns a library, build a small proof of concept.
If the disagreement concerns architecture, test it on a limited feature.
Experiments reduce theoretical debates.
Define a decision owner
The team does not need complete agreement on every decision.
It should be clear who has final decision authority:
- The technical lead.
- The front-end lead.
- The feature owner.
- The architecture group.
After listening to the available perspectives, a decision should be made and the team should commit to it unless new information appears.
Endless debate is often more damaging than making a reasonable but imperfect decision.
Part Four: Technical Debt in Front-End Development
What is technical debt?
Technical debt is the future cost created by choosing a faster, lower-quality, or less flexible solution today.
It is similar to financial debt.
You receive an immediate benefit, but you may pay additional cost later.
Imagine that a feature must launch in two days.
The team adds validation directly inside a component instead of building a clean validation layer.
That decision may be acceptable.
However, the project now owes several future improvements:
- Move validation logic into a better location.
- Add tests.
- Standardize error messages.
- Remove duplication.
The problem is not the existence of technical debt.
The problem is ignoring it, failing to understand its size, or allowing it to grow without control.
Types of technical debt
1. Deliberate technical debt
The team knowingly chooses a temporary solution because of:
- A deadline.
- An experiment.
- A production incident.
- A high-priority customer.
- Unclear requirements.
This may be reasonable when it is documented.
Example:
We will keep the filters inside the component for the first version. If the feature is approved, we will move them into URL state.
2. Accidental technical debt
This happens because of:
- Limited experience.
- Misunderstood requirements.
- Weak review.
- An unsuitable architecture.
- Incorrect use of technology.
- Missing tests.
This form is more dangerous because the team may not know it exists.
3. Debt caused by system evolution
Code may have been well-designed when it was written, but the system changed.
For example:
- The system had one user type and now has five.
- The application supported one country and now supports several.
- Pricing was fixed and now includes taxes and currencies.
- A component was used once and is now used in twenty places.
- An API was simple and now supports pagination, caching, and real-time updates.
Old code is not automatically bad.
Sometimes the requirements simply outgrow the original design.
4. Tooling and dependency debt
Examples include:
- An outdated React version.
- Old dependencies.
- A legacy build tool.
- An unmaintained library.
- Slow test suites.
- Unstable CI/CD pipelines.
- Weak TypeScript configuration.
- Missing error monitoring.
Users may not directly see this debt, but it increases the cost of every new feature.
Common technical debt in front-end systems
Examples include:
- Components with thousands of lines.
- Repeated API logic across many files.
- Excessive use of
any. - Disorganized global state.
- Direct use of local storage everywhere.
- No consistent error handling.
- Difficult and highly coupled CSS.
- Missing design tokens.
- Inconsistent components across pages.
- Missing tests for critical flows.
- Outdated dependencies.
- Ignored console warnings.
- Using array indexes as keys in reorderable lists.
- Race conditions in search and filtering.
- Memory leaks caused by listeners or subscriptions.
- Requests that are never cancelled.
- Files that may no longer be used.
- Old feature flags that were never removed.
- Workarounds whose original reason no longer exists.
- Authorization logic scattered throughout the UI.
When is technical debt acceptable?
Technical debt may be acceptable when:
- The team is aware of it.
- The reason is clear.
- The future cost is understood.
- The temporary solution has limited scope.
- There is a plan for repayment.
- The solution can be replaced without extreme difficulty.
- Security is not compromised.
- Data integrity is not threatened.
- Operational risk remains controlled.
A reasonable example:
For the MVP, we will use an existing UI library rather than build a design system. If the product succeeds and reaches a specific number of screens, we will gradually introduce design tokens and internal components.
This is a conscious decision.
When does technical debt become dangerous?
Technical debt becomes dangerous when:
- Every feature takes longer than the previous one.
- Fixing one area breaks unrelated areas.
- Developers are afraid to modify the code.
- No one fully understands the system.
- The same defects keep returning.
- Debugging time continuously increases.
- Team velocity decreases even as the team grows.
- Tests are unreliable.
- Deployments become stressful.
- Production defects increase.
- New developer onboarding takes too long.
- Most engineering time is spent on workarounds.
- Adding a simple field requires changes in many unrelated files.
At this stage, technical debt becomes a tax that the team pays on every task.
How to manage technical debt
1. Make the debt visible
Do not leave important debt inside comments or developers’ memories.
Maintain a technical debt register containing:
- Problem description.
- Business and technical impact.
- Severity.
- Affected areas.
- Suggested solution.
- Rough estimate.
- Reason for postponement.
- Reevaluation date.
Not every TODO comment represents managed technical debt.
Important debt should be visible to both engineering and product management.
2. Classify debt by impact
A possible classification is:
Critical
- A security vulnerability.
- Risk of data loss.
- Unsafe dependencies.
- Code causing production outages.
High
- Code that blocks important feature development.
- A central component that is extremely difficult to modify.
- Unstable tests that delay releases.
- Architecture that repeatedly causes defects.
Medium
- Significant duplication.
- Weak naming.
- Incomplete type safety.
- Areas that need refactoring.
Low
- Cosmetic cleanup.
- File reorganization.
- Simplification opportunities with little immediate impact.
This prevents teams from treating all technical debt as equally urgent.
3. Connect technical debt to business impact
Saying:
The code is not clean.
May not convince business stakeholders.
A stronger explanation would be:
Adding a new payment method currently requires changes in seven components, and the current structure caused three defects last month. Refactoring the payment flow could reduce the implementation time for the next payment provider from five days to two.
This translates the problem into:
- Time.
- Cost.
- Risk.
- Delivery speed.
- User experience.
- System reliability.
Technical improvements become easier to prioritize when their business impact is clear.
4. Repay debt gradually
The team does not always need to stop product work for three months and rebuild everything.
The Boy Scout Rule is useful:
Leave the code slightly better than you found it.
When working on a feature, you can:
- Improve an unclear name.
- Add a test for the area you are changing.
- Extract duplicated logic.
- Remove dead code.
- Fix a weak type.
- Reduce component responsibility.
- Improve error handling.
Small continuous improvements are often more realistic than waiting for a perfect rewrite that may never happen.
5. Allocate regular time
A team can reserve part of each sprint for technical improvements.
For example:
- Ten to twenty percent of development capacity.
- One technical improvement task per sprint.
- A monthly maintenance day.
- Refactoring tied to upcoming feature work.
- Debt cleanup in the area currently being modified.
The right percentage depends on the health of the product.
A new project may need less.
An unstable legacy project may need significantly more.
Should we rewrite the entire application?
A complete rewrite can be very attractive.
Teams often say:
The current codebase is terrible. We should start again.
However, full rewrites carry serious risks:
- Hidden business behavior may be lost.
- Previously solved defects may return.
- New feature development may stop for a long period.
- Requirements may change during the rewrite.
- The new system may become complicated as well.
- Old and new systems may need to run together.
- Completion may be difficult to define.
A rewrite may be correct in certain situations, but it should not be the default answer.
When may a rewrite be justified?
A rewrite may be reasonable when:
- The current technology is no longer supported.
- The architecture blocks essential new requirements.
- Maintenance cost is clearly higher than replacement cost.
- There are fundamental security or operational problems.
- Migration can be divided into manageable stages.
- The team understands the behavior of the existing system.
- There is a realistic migration plan.
The alternative: incremental modernization
The Strangler Pattern can be used to replace a system gradually:
- Build the new part beside the old part.
- Migrate one feature at a time.
- Create clear boundaries between the systems.
- Move users or workflows gradually.
- Remove old parts after validating the replacement.
In front-end applications, this may include:
- Rebuilding one route.
- Moving one feature into a new module.
- Gradually replacing a central component.
- Introducing design tokens before replacing every component.
- Moving API requests into a unified layer feature by feature.
The relationship between decisions, overengineering, code review, and technical debt
These topics are not separate.
A poor technical decision may create overengineering.
Overengineering may create technical debt.
Weak code reviews may allow debt to accumulate.
Heavy technical debt may make every new decision more difficult.
The opposite is also true:
- Conscious decisions reduce unnecessary complexity.
- Simple designs reduce maintenance cost.
- Good code reviews identify problems early.
- Technical debt management protects team velocity.
The role of a senior front-end developer is not limited to writing components.
The role includes protecting the team’s ability to continue developing the product.
A complete practical scenario
Imagine that the team needs to add filters to a product listing page.
The current requirements are:
- Filter by price.
- Filter by category.
- Filter by rating.
- Share the filtered results through a link.
- Preserve filters after refreshing the page.
A possible junior-level decision
Create a global store and place every filter inside it.
This may work, but it does not naturally support shareable URLs.
A senior-level thought process
First, classify the state.
The filters:
- Must be shareable through the URL.
- Must survive a browser refresh.
- Must affect the API query.
- Do not necessarily need to be global across the entire application.
Therefore, URL search parameters may be the most appropriate source of truth.
React Query can then fetch product results based on the URL parameters.
This avoids:
- An unnecessary global store.
- Complex synchronization between the store and the URL.
- Losing filters after refresh.
- Manual share-link generation.
Now imagine that a developer proposes building a generic filter engine that supports every possible filter type.
The team should ask:
- Do other pages need the same system?
- Are the future filter types known?
- Will the backend send dynamic filter definitions?
- Is building an engine now cheaper than implementing the current page directly?
The team may choose to implement the current filters clearly, then extract shared behavior when a second or third page appears.
During code review, the reviewer notices that every input change immediately sends a request.
The reviewer asks:
Do we need debouncing for text filters? What happens if an older request finishes after a newer one?
The team may add AbortController or use a data-fetching library that manages request cancellation.
After launch, the team notices that adding one new filter requires changes in five files.
The team records technical debt:
Centralize filter definitions into one configuration to reduce repeated changes.
When the next filtered page is introduced, the team repays that debt because the duplication is now real and the pattern is understood.
This is an example of architecture growing with the problem rather than attempting to predict everything from the beginning.
Practical principles for senior front-end developers
1. Do not search for the perfect solution
Search for the solution that fits the current stage.
2. Every decision has a cost
Even excellent technologies have disadvantages.
3. Simplicity is not a lack of experience
Reaching a simple solution after deeply understanding the problem is a sign of maturity.
4. Do not measure code quality by the number of patterns
Measure it by clarity and ease of change.
5. Do not optimize without measurement
Identify the bottleneck before attempting to improve it.
6. Do not build for an imaginary future
Build for the reasonably expected future and leave room for evolution.
7. Code review is a conversation
Its goal is to improve the system and the team, not prove superiority.
8. Technical debt is not always a failure
The real failure is unmanaged and invisible debt.
9. Refactoring is not a goal by itself
It should reduce change cost, risk, or complexity.
10. Understanding the business is part of technical engineering
A good technical decision cannot be made without understanding what the product is trying to achieve.
Conclusion
A senior front-end developer is not the person who writes the most complicated TypeScript types, uses the largest number of libraries, or converts every component into a generic reusable system.
A senior front-end developer is the person who knows:
- When to use a powerful tool.
- When a simple solution is enough.
- When to create an abstraction.
- When some duplication is acceptable.
- When delivery speed matters most.
- When a shortcut creates unacceptable risk.
- When code needs refactoring.
- When rebuilding would waste time.
- How to discuss technical decisions without ego.
- How to communicate trade-offs to engineering and business stakeholders.
- How to protect system quality without stopping product development.
Technical maturity is not visible only in the code you write.
It is also visible in the complexity you deliberately choose not to add.
It is visible in the modern technology you choose not to use because it does not serve the project.
It is visible in the pull request you review with clarity and respect.
It is visible in the technical debt you consciously accept and later repay before it becomes a serious burden.
It is visible in your ability to balance quality, speed, simplicity, and flexibility.
Ultimately, the success of a front-end architecture is not measured by how impressive it looks in a diagram.
It is measured by whether the team can add features, fix defects, understand the system, and continue developing it with confidence.
That is the real difference between a developer who knows how to write code and a software engineer who understands why the code should be written that way.
Fuente: Artículo original